diff --git a/README.md b/README.md
index c6f4e472..2dfe49b1 100644
--- a/README.md
+++ b/README.md
@@ -430,7 +430,10 @@ def fetch_logs(args: dict[str, Any]) -> dict[str, Any]:
Use both `@tool` and `@workflow_tool` when the same callable should be
available both directly in chat and inside workflows. See
[`docs/workflows.md`](docs/workflows.md) for the Activity handler
-contract and `workflows.exclude`.
+contract and `workflows.exclude`. Any agent can enable workflows; triggers and
+built-in endpoints independently determine how that agent is invoked. See the
+[`per-agent-workflows`](samples/per-agent-workflows) sample for two independent
+non-main workflow-enabled agents sharing one Durable engine.
## Built-in Endpoint Routes
@@ -554,6 +557,7 @@ See the [`samples/`](samples/) directory for complete, deployable example apps:
- [`workflow-incident-triage`](samples/workflow-incident-triage) — interactive Dynamic Workflow with live progress
- [`workflow-queue-p0-report`](samples/workflow-queue-p0-report) — queue-started fan-out workflow that publishes an HTML Blob report
- [`workflow-subagents-preview`](samples/workflow-subagents-preview) — queue-started parallel PR analysis with isolated workflow specialists and a stable HTML Blob report
+- [`per-agent-workflows`](samples/per-agent-workflows) — Engineering Operations Hub with two non-main workflow-enabled agents and independent policies
## Deployment Notes
diff --git a/docs/architecture.md b/docs/architecture.md
index 492114ba..aff1d203 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -18,21 +18,34 @@ flowchart LR
E -->|"list of ResolvedAgent"| E2["app.py
identity index
fail-fast on duplicate slugs"]
E2 -->|"ResolvedAgent + known_slugs"| F["config/validation.py
validate_resolved_agent
validate_subagent_references"]
F -->|"ResolvedAgent"| G["registration/capabilities.py
build_capabilities"]
- G -->|"AgentCapabilities"| G2["registration/catalog.py
build_catalog (immutable)"]
+ G -->|"AgentCapabilities"| G2["registration/catalog.py
AgentCatalog (immutable)"]
+ G2 -->|"complete agent inventory"| W["workflows/integration.py
handler catalog + workflow-agent policy catalog
(immutable)"]
+ W -->|"any workflow agent?"| I["FunctionApp or DFApp"]
+ W -->|"register Durable runtime once"| I
G2 -->|"AgentCatalog"| H["registration/triggers.py
registration/endpoints.py"]
- H -->|"Decorators applied"| I["azure.functions.FunctionApp"]
+ W -->|"workflow-agent policy by slug"| H
+ H -->|"Decorators applied"| I
J["client_manager.py
ClientManager"] -.->|"chat client"| K["runner.py
run_agent
run_agent_stream
build_subagent_tools"]
H -.->|"handler closures + AgentCatalog"| K
K -.->|"prompt + tools + session"| L["Microsoft Agent Framework"]
```
-Read left to right: files on disk become typed config, typed config becomes a `ResolvedAgent`, and each resolved agent is registered as Azure Functions bindings plus optional built-in endpoints. The two extra nodes in the middle (`app.py`'s identity index and `registration/catalog.py`'s `build_catalog`) exist for multi-agent delegation (FRD 0007, Section 5 below): every agent's slug and capabilities are indexed and frozen *before* `H` mutates the `FunctionApp`, so a coordinator's `delegate_` tools can resolve any specialist regardless of file order.
+Read left to right: files on disk become typed config, typed config becomes a
+`ResolvedAgent`, and each resolved agent is registered as Azure Functions
+bindings plus optional built-in endpoints. Before registration, startup freezes
+the complete `AgentCatalog`, complete workflow-handler catalog, and immutable
+workflow-agent policy catalog. This makes both delegation and per-agent workflow
+authorization independent of file order.
A few boundaries are worth calling out explicitly:
- **Discovery is read-only.** These modules inspect the project tree and return inventories; they do not decide what any one agent is allowed to use.
- **Translation is type-driven.** The loader and merge layers convert loose YAML/markdown input into `AgentSpec`, `GlobalConfig`, and then `ResolvedAgent`.
-- **Composition is two-pass and side-effect-free until pass 2.** `app.py`'s composition root builds the app-wide identity-slug index, fails fast on collisions, validates every `subagents:` reference, and freezes each agent's `ResolvedAgent` + `AgentCapabilities` into an immutable `AgentCatalog` — none of this touches the `FunctionApp` object. Only pass 2 (trigger/endpoint registration) mutates it (FRD 0007 §4.2).
+- **Composition is two-pass and side-effect-free until pass 2.** `app.py` builds
+ the slug index and validates references, then
+ freezes the `AgentCatalog`, complete workflow-handler catalog, and per-agent
+ workflow-policy catalog. Only pass 2 creates/mutates the app, registers the
+ workflow runtime once, and registers agent surfaces (FRDs 0004 and 0007).
- **Registration is Azure-specific.** This is the first stage that knows about `azure.functions.FunctionApp`, decorators, routes, and trigger bindings.
- **Execution is deferred.** The runner is not part of startup registration; it is called later by handler closures when an HTTP route or trigger actually fires.
@@ -40,7 +53,7 @@ A few boundaries are worth calling out explicitly:
| Package/module | Role | Key entry points |
| --- | --- | --- |
-| `azure_functions_agents/app.py` | Top-level orchestrator that runs the startup pipeline and returns the configured app; owns the two-pass composition root (FRD 0007 §4.2) — builds the app-wide identity-slug index, fails fast on duplicate slugs, and freezes every agent's validated `ResolvedAgent` + `AgentCapabilities` into an immutable `AgentCatalog` before any `FunctionApp` mutation. | `create_function_app()`, `_fail_on_duplicate_slugs()` |
+| `azure_functions_agents/app.py` | Top-level two-pass composition root. Before app mutation it builds the slug index, `AgentCatalog`, complete workflow-handler catalog, and immutable workflow-agent policy catalog. It chooses `DFApp` when any agent enables workflows, registers the workflow runtime once, then registers each agent. | `create_function_app()`, `_fail_on_duplicate_slugs()` |
| `azure_functions_agents/config/paths.py` | Resolves the app root and the optional config/history directory. | `set_app_root()`, `get_app_root()`, `resolve_config_dir()` |
| `azure_functions_agents/config/env.py` | Performs env-var substitution and bool coercion across config string values in YAML, JSON, front matter, and markdown body content. | `substitute_env_vars_in_value()`, `resolve_env_vars_in_data()`, `substitute_env_vars_in_text()`, `_to_bool()` |
| `azure_functions_agents/config/schema.py` | Defines the Pydantic models for raw, global, and merged config, including independent object-only chat and workflow Sub Agent grants. | `AgentSpec`, `GlobalConfig`, `ResolvedAgent`, `TriggerSpec`, `BuiltinEndpointsConfig`, `SubagentRef`, `WorkflowConfig`, `WorkflowSubagentRef` |
@@ -63,7 +76,11 @@ A few boundaries are worth calling out explicitly:
| `azure_functions_agents/system_tools/web_request.py` | Builds the default-on, SSRF-guarded `web_request` outbound HTTP tool, built once per agent at registration (no Azure resource required). | `create_web_request_tools()` |
| `azure_functions_agents/runner.py` | Executes prompts through the Microsoft Agent Framework, managing sessions, tools, and streaming; builds per-request `delegate_` tools and fresh stateless workflow leaf agents; attempts one internal token-usage record through the shared runtime logger for each actual MAF invocation attempt. | `run_agent()`, `run_agent_stream()`, `build_subagent_tools()`, `run_leaf_agent_task()` |
| `azure_functions_agents/client_manager.py` | Defines the pluggable inference-client abstraction, immutable inference-target metadata, and the default MAF-backed implementation. | `ClientManager`, `InferenceTarget`, `get_client_manager()`, `set_client_manager()` |
-| `azure_functions_agents/workflows/*` | Experimental Dynamic Workflow runtime: Durable orchestration registration, workflow tool and Sub Agent execution, immutable owner policy, plan validation/schema, session ownership, and workflow-management tools. | `register_workflows()`, `build_workflow_integration()`, `WorkflowPlanPolicy` |
+| `azure_functions_agents/workflows/integration.py` | Builds the complete immutable handler catalog, immutable slug-keyed workflow-agent policy catalog, per-agent management tools/addenda, validates declared trigger support for workflow-enabled agents, and performs the one app-wide Durable registration. | `build_workflow_handler_catalog()`, `build_workflow_agent_policy_catalog()`, `build_workflow_agent_integration()`, `validate_workflow_agent_trigger()`, `register_workflow_runtime()` |
+| `azure_functions_agents/workflows/engine.py` | Registers one Durable blueprint per app and executes the orchestrator, workflow-tool Activity, and Workflow Sub Agent Activity. Capability-bearing Activities reauthorize against the current workflow-agent policy before complete-catalog dispatch. | `register_workflows()` |
+| `azure_functions_agents/workflows/context.py` | Tracks invocation context by `(workflow_agent_slug, session_id)` and derives non-revealing 128-bit agent/session prefixes for Durable instance IDs. | `session_instance_prefix()`, `new_workflow_instance_id()`, `workflow_matches_agent_session()` |
+| `azure_functions_agents/workflows/registry.py` | Defines immutable workflow handler entries/catalogs; production app composition passes this complete catalog explicitly rather than using the compatibility singleton allowlist as authorization. | `WorkflowHandlerCatalog`, `build_handler_catalog()` |
+| `azure_functions_agents/workflows/schema.py`, `workflows/tools.py` | Define workflow plans/policies and build agent-scoped management tools. Start-time validation and list/status/cancel/terminate operations use the captured workflow-agent policy and agent/session identity. | `WorkflowPlanPolicy`, `validate_plan()`, `build_workflow_tools()` |
| `azure_functions_agents/_function_tool.py` | Thin local shim around MAF `FunctionTool` creation so project tools can use `@tool`, plus `@workflow_tool` metadata for Dynamic Workflow Activity targets. | `tool()`, `workflow_tool()` |
| `azure_functions_agents/_logger.py` | Shared package logger used across discovery, registration, and runtime code. | `logger` |
| `azure_functions_agents/_observability.py` | Cross-cutting OpenTelemetry bootstrap and conventions: enables MAF `gen_ai` instrumentation and, when the optional `[monitor]` extra is installed, the Azure Monitor exporter, provides the `af.*` span/attribute helpers (fault domain, lifecycle stage), the resolved sensitive-data flag from `ENABLE_SENSITIVE_DATA`, minimal dynamic-session and delegate-call metrics, and third-party log-noise control. | `configure_observability()`, `start_span()`, `current_span()`, `FaultDomain`, `LifecycleStage`, `record_delegate_call()` |
@@ -91,10 +108,21 @@ When the host imports your app module and calls `create_function_app()`, control
7. `app.py`'s `_fail_on_duplicate_slugs()` builds the app-wide slug index and fails fast on collisions; `config/validation.py:validate_subagent_references()` then rejects unknown/duplicate/self `subagents:` references against that index.
8. `config/validation.py:validate_resolved_agent()` checks each merged object for missing triggers, bad MCP references, and similar config mistakes (an agent referenced only as a `subagents:` target is exempt from the trigger-or-`builtin_endpoints` requirement).
9. `registration/capabilities.py` converts name-based filters into concrete tool lists and skill paths, and fails fast on any `delegate_` tool-name collision.
-10. `registration/catalog.py:build_catalog()` freezes every agent's `ResolvedAgent` + `AgentCapabilities` into one immutable `AgentCatalog` — this is the last step before anything touches the `FunctionApp`.
-11. `registration/triggers.py` and `registration/endpoints.py` mutate one `FunctionApp` instance until all agents are registered, threading the frozen `AgentCatalog` through so handler closures can build `delegate_` tools later, at request time.
-
-That ordering matters because later modules assume earlier stages have already reduced free-form author input into typed, validated objects. For example, registration code does not re-parse YAML or front matter; it trusts `ResolvedAgent` and `AgentCapabilities`. Steps 6-10 are FRD 0007's "two-pass composition" (§4.2): everything through step 10 is pure/side-effect-free (no `FunctionApp` mutation), and only step 11 is pass 2.
+10. `registration/catalog.py:build_catalog()` freezes every agent's
+ `ResolvedAgent` + `AgentCapabilities`. `workflows/integration.py` then builds
+ the complete immutable workflow-handler catalog and one immutable
+ `WorkflowPlanPolicy` per workflow-enabled agent.
+11. `app.py` creates a `DFApp` when the workflow-agent policy catalog is non-empty
+ (otherwise a plain `FunctionApp`) and registers the app-wide Durable runtime
+ exactly once.
+12. `registration/triggers.py` and `registration/endpoints.py` register every
+ agent, looking up workflow policy by workflow-agent slug and threading the catalogs
+ into handler closures.
+
+That ordering matters because registration does not re-parse YAML or front
+matter; it trusts typed resolved values and immutable catalogs. Steps 6-10 are
+pass 1 and side-effect-free. Steps 11-12 are pass 2 and own all Azure Functions
+mutation.
## 4. Pipeline stages
@@ -137,55 +165,60 @@ The `create_function_app()` docstring in `src/azure_functions_agents/app.py:crea
- **Notes:** this is FRD 0007 §4.2's "two-pass composition" pass 1a — the first cross-agent check, and it must run before any other per-agent validation. A slug doubles as the registered Azure Function name, the `/agents//` built-in endpoint route, and the `delegate_` tool name other agents use to reach it, so two source files that sanitize to the same slug now **fail startup** with an actionable rename error instead of silently registering under an auto-suffixed name (a **breaking change** — see FRD 0007 §5 Decision #17 and the callout in `docs/front-matter-spec.md`, "File Naming Conventions"). The app validates unknown, duplicate, and self references independently for top-level `subagents:` and `workflows.subagents`, then collects both sets when deciding whether an endpoint-less specialist is reachable.
7. **Validate the merged configuration**
- - **Implemented by:** `src/azure_functions_agents/config/validation.py:validate_resolved_agent()`
+ - **Implemented by:** `src/azure_functions_agents/config/validation.py:validate_resolved_agent()`, `src/azure_functions_agents/workflows/integration.py:validate_workflow_agent_trigger()`
- **Input:** each `ResolvedAgent`, discovered MCP server names as `list[str]`, discovered skill names as `list[str]`, and whether the agent is referenced as a subagent (from stage 6)
- **Output:** the same validated `ResolvedAgent` (or an exception that skips registration for that agent)
- - **Notes:** validation checks that each agent defines a trigger or enables at least one built-in endpoint, rejects trigger decorator names that the agent runtime does not support, and ensures per-agent `mcp.exclude` entries match MCP servers discovered from `mcp.json`. Unknown skill and tool excludes are logged as warnings during the same pass. An agent referenced only as an internal specialist (present in stage 6's referenced-slug set) is exempt from the trigger-or-`builtin_endpoints` requirement — an endpoint-less agent is valid as long as some coordinator's `subagents:` reaches it.
+ - **Notes:** validation checks that each directly invokable agent defines a trigger or enables at least one built-in endpoint, rejects unsupported trigger decorators, and validates capability references. A referenced internal specialist may remain endpoint-less. Workflow enablement is independent: any agent may set `workflows.enabled: true`; if it declares a trigger, that trigger must support workflow startup.
8. **Build per-agent capabilities**
- **Implemented by:** `src/azure_functions_agents/registration/capabilities.py:build_capabilities()`, `validate_subagent_tool_names()`
- **Input:** `ResolvedAgent`, discovered user tools, discovered workflow tools, discovered MCP tools, discovered skills (`dict[str, Path]`)
- **Output:** `AgentCapabilities`
- - **Notes:** this stage converts name-based filters into actual runtime objects. `tools.exclude` applies only to normal MAF tools; `workflows.exclude` applies only to workflow Activity targets. Capability filtering is owner-agnostic; the v1 `main.agent.md` ownership restriction is applied later, once, by the app composition root. Immediately afterward, `validate_subagent_tool_names()` fails fast if any auto-derived `delegate_` name would collide with another tool already on the same agent (user, MCP, sandbox, workflow-management, or another specialist's tool). After this point, the registration and runner layers do not need to reason about `exclude` lists; they only consume concrete tool lists and the final list of enabled skill directories.
+ - **Notes:** this stage converts name-based filters into actual runtime objects. `tools.exclude` applies only to normal MAF tools; `workflows.exclude` applies only to that agent's workflow Activity targets. Immediately afterward, `validate_subagent_tool_names()` fails fast on derived tool-name collisions. Registration consumes concrete lists rather than re-reading exclude metadata.
-9. **Freeze the per-agent results into an immutable `AgentCatalog`**
- - **Implemented by:** `src/azure_functions_agents/registration/catalog.py:build_catalog()`
+9. **Freeze app-wide execution and workflow-agent policy catalogs**
+ - **Implemented by:** `src/azure_functions_agents/registration/catalog.py:build_catalog()`, `src/azure_functions_agents/workflows/integration.py:build_workflow_handler_catalog()`, `build_workflow_agent_policy_catalog()`
- **Input:** `dict[str, CatalogEntry]` — one entry per agent slug, pairing its validated `ResolvedAgent` and `AgentCapabilities`
- - **Output:** `AgentCatalog` (a read-only `MappingProxyType`, keyed by identity slug)
- - **Notes:** this closes FRD 0007 §4.2 pass 1 — everything through this stage is pure and side-effect-free; no `FunctionApp` exists yet and nothing has been mutated. Because the catalog holds every agent (not only the ones registered so far), a coordinator's `delegate_` tools can resolve *any* specialist by slug at request time, regardless of file or registration order.
+ - **Output:** immutable `AgentCatalog`, complete `WorkflowHandlerCatalog`, and immutable slug-keyed `WorkflowAgentPolicyCatalog`
+ - **Notes:** the handler and Agent catalogs answer what exists app-wide. They do not grant a workflow-enabled agent access. Each workflow-enabled agent receives a separate `WorkflowPlanPolicy` derived from its filtered workflow tools and independent `workflows.subagents` grants. This closes side-effect-free pass 1.
10. **Create the Azure Functions app container**
- **Implemented by:** `src/azure_functions_agents/app.py:create_function_app()`
- **Input:** startup defaults such as `http_auth_level=func.AuthLevel.FUNCTION`
- - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when the main agent opts into `workflows.enabled`, otherwise a plain `FunctionApp`)
- - **Notes:** only one app object is created per startup pass. Every subsequent registration call mutates this object by attaching decorators and handlers.
+ - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one workflow-agent policy exists, otherwise a plain `FunctionApp`)
+ - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and workflow-agent policies are captured by one app-level Durable registration before agent registration begins. Ordinary apps without workflow-enabled agents retain the lower-overhead plain `FunctionApp`.
11. **Register triggers and built-in endpoints (pass 2)**
- **Implemented by:** `src/azure_functions_agents/app.py:create_function_app()`, `src/azure_functions_agents/registration/triggers.py:register_agent()`, `src/azure_functions_agents/registration/endpoints.py:register_builtin_endpoints()`, `src/azure_functions_agents/registration/_handlers.py`
- **Input:** `FunctionApp`, `ResolvedAgent`, `AgentCapabilities`, and the frozen `AgentCatalog`
- **Output:** the same `FunctionApp`, now decorated with trigger bindings, HTTP routes, SSE streaming routes, and/or MCP endpoints
- - **Notes:** agents go through `register_agent()` when they have a `trigger`. Any agent with built-in endpoints enabled also goes through `register_builtin_endpoints()`, which can add debug chat UI, `/agents/{slug}/chat`, `/agents/{slug}/chatstream`, and MCP tool surfaces. Each agent's identity slug (already guaranteed globally unique by stage 6) is used directly as its function name / built-in endpoint route — there is no allocator or de-duplication pass here anymore. Both registration calls also thread the frozen `AgentCatalog` through to the handler closures they build, so a coordinator's `delegate_` tools can be built later, at request time (see "Multi-agent delegation" below). When the main agent enables Dynamic Workflows, both built-in endpoints and every supported Markdown-declared trigger receive a Durable client input; workflow-disabled and non-main handlers retain their original binding signatures.
+ - **Notes:** agents go through `register_agent()` when they have a trigger and `register_builtin_endpoints()` when endpoints are enabled. Each lookup uses the agent slug's workflow-agent policy. Eligible trigger/chat API/MCP surfaces receive workflow guidance, agent-scoped tools, and a Durable client binding; debug UI alone is not a starter. Workflow-disabled handlers retain their original signatures.
### Where the registration stage hands off to execution
Registration does not run the agent itself. Instead, `registration/_handlers.py` builds closures that call `runner.run_agent()` or `runner.run_agent_stream()`, passing the `ResolvedAgent` instructions plus the already-filtered `AgentCapabilities` — and, when the agent declares `subagents`, its `ResolvedAgent.subagents` list plus the frozen `AgentCatalog`. For non-HTTP triggers, the closure delegates payload construction to `registration/_trigger_serialization.py`: native `to_dict()`/`model_dump()` contracts are used first, then public Azure Functions binding adapters, batch recursion, and byte encoding produce JSON-safe prompt data. HTTP handlers build their request-body JSON separately and do not use this serializer. The runner then asks the active `ClientManager` to build a chat client, builds any `delegate_` tools fresh for this request, and executes through the Microsoft Agent Framework (`src/azure_functions_agents/runner.py`, `src/azure_functions_agents/client_manager.py`).
-For a workflow-enabled main agent, `workflows/integration.py` produces one
-immutable `WorkflowPlanPolicy` from the concrete workflow tools and
-`workflows.subagents` grant. The same policy instance generates model guidance
-and is captured by `start_workflow` for runtime authorization. Built-in chat/MCP
-handlers receive the chat addendum; declared-trigger handlers receive the
-trigger addendum together with `workflow_enabled=True`, the Durable client,
-agent name, and policy. Registration consumes these resolved values and does not
-re-parse workflow metadata.
+For each workflow-enabled agent, `workflows/integration.py` uses the cataloged immutable
+`WorkflowPlanPolicy` to generate model guidance and agent-scoped management
+tools. Built-in chat/MCP handlers receive the chat addendum; declared-trigger
+handlers receive the trigger addendum, Durable client, workflow-agent slug, and policy.
+`start_workflow` validates against that policy. The orchestrator carries
+`workflow_agent_slug`, and each tool/Sub Agent Activity reauthorizes against the currently
+deployed policy before dispatching through the complete app-wide catalogs.
### Dynamic Workflow execution lifetimes
A declared trigger handler is a short-lived Durable **client/starter**. The agent authors a plan, calls `start_workflow`, receives the Durable instance ID, and ends its turn without polling. The starter remains subject to the normal model-call and Function timeout, but the orchestration does not: Durable checkpoints and resumes the DAG independently across Activities and timers.
+Application management identity is `(workflow_agent_slug, session_id)`, encoded in instance IDs as a
+32-hex-character (128-bit) truncated SHA-256 digest over a length-delimited pair.
+Thus equal session IDs on different workflow-enabled agents do not share active limits or
+list/status/cancel/terminate access. HTTP uses the request/generated session;
+non-HTTP triggers generate an invocation session and no application-wide agent index.
+
### Registration paths in practice
-- **Endpoint-only agent (no trigger):** `create_function_app()` skips `register_agent()` whenever an agent has no `trigger`. If built-in endpoints are enabled, `register_builtin_endpoints()` can still expose the chat UI, REST, SSE, and MCP surfaces for interactive use. An agent with *neither* a trigger *nor* built-in endpoints is only valid when another agent's `subagents:` references it (stage 7's relaxation) — it is then reachable solely as a `delegate_` tool.
+- **Endpoint-only or internal agent (no trigger):** `create_function_app()` skips `register_agent()` whenever an agent has no `trigger`. If built-in endpoints are enabled, `register_builtin_endpoints()` can still expose the chat UI, REST, SSE, and MCP surfaces for interactive use. An agent with *neither* a trigger *nor* built-in endpoints is valid only when another agent references it through `subagents` or `workflows.subagents` (stage 7's relaxation). It is then reachable only in the corresponding internal role: a `delegate_` tool, a workflow `sub_agent` node, or both.
- **HTTP agent:** `registration/triggers.py` routes `http_trigger` to `make_http_agent_handler()`, which enforces the trigger's inbound `auth` policy (via the shared `_auth` module, identical to built-in endpoints — the route `AuthLevel` for key/anonymous modes and the in-app Easy Auth `x-ms-client-principal` check for `entra`), validates JSON input, and optionally validates the model's JSON-shaped response before replying. The registered function name is the agent's identity slug, already guaranteed unique at stage 6 — a colliding sanitized stem is a startup error, not an auto-suffixed name.
- **Built-in trigger:** `registration/triggers.py` calls `make_agent_handler()`, which uses the native-contract-first, adapter-based trigger serializer (`registration/_trigger_serialization.py`) to turn public binding data into JSON before sending the prompt to `runner.run_agent()`.
- **Connector trigger:** `connector_trigger` uses the Azure Functions Python `app.connector_trigger(...)` decorator when available, falling back to the equivalent generic `connectorTrigger` binding on older Azure Functions packages. It then reuses the same `make_agent_handler()` closure pattern as the built-in trigger path.
@@ -205,8 +238,10 @@ By the time a handler calls `runner.run_agent()` or `runner.run_agent_stream()`,
- `ResolvedAgent.instructions` becomes the per-agent instruction block.
- `ResolvedAgent.timeout` and `ResolvedAgent.model` become execution settings.
- `AgentCapabilities.filtered_user_tools` becomes the concrete user-tool list.
-- `AgentCapabilities.filtered_workflow_tools` becomes the workflow Activity target inventory used by `build_workflow_integration()` for the main agent when workflows are enabled.
-- `WorkflowIntegrationResult` supplies the immutable `WorkflowPlanPolicy` and separate chat and declared-trigger system addenda; the declared-trigger handler also receives the bound Durable client.
+- `AgentCapabilities.filtered_workflow_tools` contributes to that agent's
+ `WorkflowPlanPolicy`; it does not shrink the complete Activity handler catalog.
+- `WorkflowIntegrationResult` supplies agent-scoped management tools and separate
+ chat/trigger addenda; handlers also receive the policy and bound Durable client.
- `AgentCapabilities.filtered_mcp_tools` becomes the concrete MCP-tool list.
- `AgentCapabilities.enabled_skill_paths` becomes the list of skill directories handed to MAF's `SkillsProvider`.
- `AgentCapabilities.web_request_tools` becomes the concrete `web_request` tool list, passed to the runner via its own `web_request_tools` parameter.
@@ -239,7 +274,7 @@ Every `ResolvedAgent` can be built into a MAF `Agent` in one of two execution ro
A specialist built in the `delegated` role "runs as itself" (FRD 0007 §5 Decisions #13/#15): its own instructions, model, and static tools are unchanged from how it would run directly. What differs is everything tied to being invoked *as a sub-agent rather than the top-level agent for this request*:
-- **Per-request sandbox tools and main-only Dynamic-Workflow tools are naturally absent, not stripped** — `_build_delegated_agent()` simply never passes `sandbox_tools`/`workflow_enabled=True` when building a specialist, because those capabilities are scoped to the top-level request in the first place.
+- **Per-request sandbox and Dynamic-Workflow tools are naturally absent, not stripped** — `_build_delegated_agent()` never passes those direct-invocation capabilities when building a specialist.
- **No `delegate_*` tools of its own.** `_build_delegated_agent()` deliberately never reads `resolved.subagents` for a specialist it is building — delegation is single-level (FRD 0007 §5 Decision #6). This is enforced *structurally*, by what the delegated-role builder never wires up, not by a runtime recursion-depth counter. A delegated specialist cannot itself delegate further, even if its own front matter declares `subagents:` for when it runs directly.
- **Isolated context.** The handler's `agent.run(task)` call passes no `session=` argument at all, so the specialist gets a private, empty conversation rather than the coordinator's history — the FRD's guidance is that a `task` argument should be a self-contained instruction, not "continue the conversation above."
@@ -278,7 +313,7 @@ Delegation needs very little new plumbing because the runtime already enables MA
| --- | --- | --- |
| Who decides the plan | The model, turn by turn, inside one `agent.run()` call | The model authors an explicit multi-step plan up front, executed by a Durable Functions orchestration |
| Execution model | Synchronous function-tool calls nested in the coordinator's own run | Durable orchestrator + Activities, potentially long-running and independently retryable |
-| Scope in v1 | Any agent may declare `subagents`; single-level only (a delegated specialist cannot itself delegate) | Only `main.agent.md` in v1 |
+| Scope in v1 | Any agent may declare `subagents`; single-level only (a delegated specialist cannot itself delegate) | Any agent with a supported trigger, chat API, or MCP starter |
| Relationship | Independent grants and execution paths; the same specialist slug may be authorized by either or both | A workflow `sub_agent` node uses `workflows.subagents`, never the chat-time list |
Workflow Sub Agents are v1 leaf nodes. Each node schedules one async Durable
@@ -314,6 +349,10 @@ These are the main "passport" objects that move through the pipeline:
- `CatalogEntry` / `AgentCatalog` — the pairing of one agent's `ResolvedAgent` and `AgentCapabilities`, and the immutable, slug-keyed `MappingProxyType` collecting every such pairing app-wide. Defined in `src/azure_functions_agents/registration/catalog.py` as `CatalogEntry` and `AgentCatalog`.
- **Created by:** `registration/catalog.py:build_catalog()`, once per startup, after pass 1 validation completes for every agent
- **Consumed by:** `registration/triggers.py`, `registration/endpoints.py` (threaded into handler closures), and `runner.py:build_subagent_tools()` (resolves a `SubagentRef.agent` slug to a specialist's identity + capabilities at request time)
+- `WorkflowHandlerCatalog` / `WorkflowAgentPolicyCatalog` — complete immutable
+ Activity handler inventory plus immutable per-agent authorization policies.
+ Built once after `AgentCatalog`; consumed by one-time Durable registration and
+ agent-specific endpoint/trigger integration.
- `azure.functions.FunctionApp` — the final Azure Functions app object created in `src/azure_functions_agents/app.py:create_function_app()` and returned to the host after registration completes.
- **Created by:** `app.py:create_function_app()`
- **Consumed by:** Azure Functions itself after the host imports the module and inspects the registered bindings
@@ -322,7 +361,9 @@ These are the main "passport" objects that move through the pipeline:
In shorthand, the runtime's startup path is:
-`Path` --load--> `GlobalConfig` + `list[AgentSpec]` --compose--> `ResolvedAgent` (incl. `slug`, `subagents`) --validate+filter--> `AgentCapabilities` --freeze--> `AgentCatalog` --register--> `FunctionApp`
+`Path` --load--> `GlobalConfig` + `list[AgentSpec]` --compose--> `ResolvedAgent`
+--validate+filter--> `AgentCapabilities` --freeze--> `AgentCatalog` + handler
+catalog + workflow-agent policy catalog --choose/register--> `FunctionApp` or `DFApp`
At invocation time, the runtime continues with:
diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md
index 4e15cf15..44a44c7e 100644
--- a/docs/frds/0004-dynamic-workflows.md
+++ b/docs/frds/0004-dynamic-workflows.md
@@ -4,9 +4,9 @@ title: Dynamic workflows
status: Finalized
author: TsuyoshiUshio
created: 2026-07-06
-updated: 2026-07-24
-issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108]
-pull_requests: [https://github.com/Azure/azure-functions-agents-runtime/pull/77, https://github.com/Azure/azure-functions-agents-runtime/pull/112, https://github.com/Azure/azure-functions-agents-runtime/pull/117]
+updated: 2026-08-12
+issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108, https://github.com/Azure/azure-functions-agents-runtime/issues/109, https://github.com/Azure/azure-functions-bucees-planning/issues/1274, https://github.com/Azure/azure-functions-bucees-planning/issues/1275]
+pull_requests: [https://github.com/Azure/azure-functions-agents-runtime/pull/77, https://github.com/Azure/azure-functions-agents-runtime/pull/112, https://github.com/Azure/azure-functions-agents-runtime/pull/117, https://github.com/Azure/azure-functions-agents-runtime/pull/151]
---
# FRD 0004 — Dynamic workflows
@@ -14,16 +14,27 @@ pull_requests: [https://github.com/Azure/azure-functions-agents-runtime/pull/77,
## 1. Summary
Add experimental Dynamic Workflows support to the markdown-first Azure Functions
-Agents Runtime. A workflow-enabled main agent can ask the runtime to launch a
+Agents Runtime. Any workflow-enabled agent can ask the runtime to launch a
Durable Functions-backed DAG of tool and wait tasks, observe progress through
built-in endpoints/UI, and receive final workflow notifications in the chat
session. Workflow task tools are authored under the existing `tools/` directory
but opt into Durable Activity execution explicitly with a new `@workflow_tool`
decorator; normal plain-function tool discovery remains backward compatible.
-Workflow-enabled main agents can also start the same Durable workflows from any
+Workflow-enabled agents can also start the same Durable workflows from any
supported Markdown-declared trigger; the trigger starts the workflow
asynchronously and does not wait for it to finish.
+## Evolution
+
+This FRD evolves with the experimental Dynamic Workflows surface. The initial
+design assumed one workflow-enabled `main.agent.md` and session-only workflow
+identity. PR #112 added Markdown-declared trigger starters, PR #117 added
+Workflow Sub Agents, and PR #151 extends the same feature to every
+workflow-enabled agent with agent/session isolation. The
+[multi-agent addendum](#multi-agent-workflow-isolation-addendum-pr-151)
+records only that extension's behavioral and architectural delta instead of
+repeating the base workflow design.
+
## 2. Motivation / problem
Today agents can call tools directly through the Microsoft Agent Framework (MAF)
@@ -48,12 +59,12 @@ explicitly opt a function into the Durable Activity execution path.
**Goals**
-- Enable `workflows.enabled: true` for `main.agent.md` to register Durable
+- Enable `workflows.enabled: true` for any agent to register Durable
workflow management tools and a Durable orchestrator/activity engine.
- Add `workflows.exclude` so workflow filtering matches existing exclude-style
capability UX (`tools.exclude`, `mcp.exclude`, `skills.exclude`).
- Keep sample `function_app.py` minimal so workflow authoring is expressed
- through `main.agent.md` plus `tools/`.
+ through agent markdown plus `tools/`.
- Add `@workflow_tool` as an explicit workflow authoring decorator for functions
placed in `tools/`.
- Preserve existing normal `tools/` behavior: public plain functions and `@tool`
@@ -68,14 +79,13 @@ explicitly opt a function into the Durable Activity execution path.
warning rather than failing startup when safe to do so.
- Keep discovery read-only and keep Azure Functions/Durable registration in the
registration/integration stage.
-- Enable every supported Markdown-declared trigger on a workflow-enabled
- `main.agent.md` to start Dynamic Workflows through the existing runner.
+- Enable every supported Markdown-declared trigger on a workflow-enabled agent
+ to start Dynamic Workflows through the existing runner.
- Document the workflow authoring surface in `docs/workflows.md`,
`docs/front-matter-spec.md`, and `docs/architecture.md`.
**Non-goals**
-- Enabling workflows for non-main agents in v1.
- Hand-authored workflow YAML/markdown templates; workflow plans remain
LLM-authored through `start_workflow`.
- Per-task retry/timeout/concurrency settings in v1, beyond reserving
@@ -90,15 +100,15 @@ explicitly opt a function into the Durable Activity execution path.
| Pipeline stage | Module(s) | Change |
| --- | --- | --- |
| discover | `discovery/tools.py`, `_function_tool.py` | Load `tools/*.py` once, preserving normal `FunctionTool` discovery while also discovering explicit workflow tool declarations. Add a public `workflow_tool` decorator that records workflow metadata without making the function a normal MAF tool by itself. |
-| translate | `config/schema.py`, `config/merge.py`, `registration/capabilities.py` | Parse and validate the public workflow config shape (`enabled`, optional `exclude`, and independent `subagents`) and compute concrete capabilities without hard-coding the v1 owner. Unknown workflow excludes warn, mirroring `tools.exclude`. |
-| register | `app.py`, `workflows/integration.py`, `workflows/registry.py`, `workflows/engine.py`, `registration/endpoints.py`, `registration/triggers.py` | The app composition root selects `main.agent.md` as the v1 owner. Integration consumes its filtered workflow tools and Sub Agent grants, builds one immutable owner policy, registers the Durable blueprint and catalog-backed Sub Agent Activity, and threads the policy plus Durable client through endpoints and declared triggers. |
-| execute | `workflows/tools.py`, `workflows/engine.py`, `runner.py`, `registration/_handlers.py`, `public/index.html` | MAF invokes workflow management tools (`start_workflow`, status/list/cancel/terminate). Runtime validation uses the same policy that generated prompt guidance. Durable Activities invoke registered workflow tools or fresh stateless leaf specialists. Trigger handlers pass the bound Durable client and trigger-specific workflow guidance to the runner. UI polls workflow status and injects terminal notifications. |
+| translate | `config/schema.py`, `config/merge.py`, `registration/capabilities.py` | Parse and validate the public workflow config shape (`enabled`, optional `exclude`, and independent `subagents`) and compute concrete capabilities without hard-coding the v1 workflow agent. Unknown workflow excludes warn, mirroring `tools.exclude`. |
+| register | `app.py`, `workflows/integration.py`, `workflows/registry.py`, `workflows/engine.py`, `registration/endpoints.py`, `registration/triggers.py` | The app composition root freezes one immutable policy per workflow-enabled agent, registers one app-wide Durable blueprint and complete execution catalogs, then threads the matching policy and Durable client through each agent's endpoints and declared triggers. |
+| execute | `workflows/tools.py`, `workflows/engine.py`, `runner.py`, `registration/_handlers.py`, `public/index.html` | MAF invokes workflow management tools (`start_workflow`, status/list/cancel/terminate). Runtime validation uses the same agent policy that generated prompt guidance. Durable Activities reauthorize against the currently deployed policy before invoking registered workflow tools or fresh stateless leaf specialists. UI polls workflow status and injects terminal notifications. |
### Authoring / API surface
#### Frontmatter
-Workflow enablement remains explicit on the main agent:
+Workflow enablement remains explicit on each participating agent:
```yaml
---
@@ -112,23 +122,21 @@ workflows:
---
```
-- `workflows.enabled`: `bool`; `true` enables Dynamic Workflows for
- `main.agent.md`.
+- `workflows.enabled`: `bool`; `true` enables Dynamic Workflows for that agent.
- `workflows.exclude`: optional `list[str]`; filters discovered workflow tool
names out of the effective workflow tool set.
- Durable backend and task hub configuration stay in `host.json` and app
settings, not frontmatter.
-- If `workflows.enabled: true` is set on a non-main agent in v1, the runtime
- logs a startup warning and ignores the workflows block for that agent. This
- matches the current v1 constraint without failing unrelated agents.
+- No separate workflow-agent, role, or starter field is required. Invocation remains
+ controlled independently by the agent's trigger and built-in endpoints.
#### Markdown-declared trigger starters
-When a supported Markdown-declared trigger belongs to a workflow-enabled
-`main.agent.md`, registration adds a Durable client input to that generated
-Function. The handler passes the bound client, workflow enablement, the agent
-identity slug, and trigger-specific system guidance to the existing runner.
-Workflow-disabled and non-main handlers retain their original signatures.
+When a supported Markdown-declared trigger belongs to a workflow-enabled agent,
+registration adds a Durable client input to that generated Function. The handler
+passes the bound client, workflow enablement, the agent identity slug, and
+trigger-specific system guidance to the existing runner. Workflow-disabled
+handlers retain their original signatures.
`start_workflow` schedules the orchestration and returns a `workflow_id` to the
agent. The initial trigger Function ends after that agent turn instead of
@@ -273,14 +281,13 @@ execution.
### Workflow Sub Agents
> [!IMPORTANT]
-> This extension is approved for the Dynamic Workflows v1 surface. Its first
-> implementation is limited to the workflow-enabled `main.agent.md`; issue #109
-> will apply the same contract to non-main workflow owners. The
-> `samples/workflow-subagents-preview/` directory becomes a runnable sample as
-> part of this implementation.
+> This extension is part of the Dynamic Workflows v1 surface. PR #151 applies
+> the same contract to every workflow-enabled agent. The
+> `samples/workflow-subagents-preview/` directory is the runnable single-agent
+> Workflow Sub Agent sample.
-The extension lets the workflow-enabled main agent authorize existing Markdown
-agents as DAG nodes:
+The extension lets a workflow-enabled agent authorize existing Markdown agents
+as DAG nodes:
```yaml
---
@@ -310,10 +317,10 @@ The static grant and every runtime plan are enforced independently. Before a
plan starts, each `sub_agent.agent` must be present in the owning agent's
`workflows.subagents` grant. An unauthorized or unknown slug rejects the plan;
the Activity also fails closed if its catalog lookup cannot resolve the
-already-authorized slug. The immutable owner-specific policy used for prompt
-guidance is the same policy used for plan validation. v1 constructs that policy
-only for `main.agent.md`; issue #109 can construct the same value per owner
-without changing the node or Activity contract.
+already-authorized slug. The immutable agent-specific policy used for prompt
+guidance is the same policy used for plan validation. Composition constructs one
+independent immutable policy per workflow-enabled agent without changing the
+node or Activity contract.
The Workflow plan uses a `sub_agent` task:
@@ -409,6 +416,113 @@ This syntax is illustrative only and is not accepted as part of the Workflow Sub
Agent contract in this draft. Review should decide whether positive allowlists
are a prerequisite, a parallel feature, or a later hardening step.
+### Multi-agent workflow isolation addendum (PR #151)
+
+This addendum supersedes the original `main.agent.md`-only assumption. It does
+not introduce new frontmatter or DAG syntax: every agent with
+`workflows.enabled: true` receives the existing workflow tools and may start
+workflows through whichever triggers or built-in endpoints it independently
+exposes.
+
+The implementation calls such an agent a *workflow-enabled agent*. Its
+`workflow_agent_slug` defines the authorization namespace but is not an
+authoring keyword.
+
+#### App-wide execution and per-agent authorization
+
+One Function App registers one Durable orchestrator, one copy of each Activity,
+one complete workflow-handler catalog, and the existing immutable
+`AgentCatalog`. Registering that engine once prevents duplicate Azure Functions
+when several agents enable workflows. Complete catalogs answer what exists; they
+do not grant access.
+
+Composition separately freezes one `WorkflowPlanPolicy` per workflow-enabled
+agent. That policy contains only the agent's workflow tools after
+`workflows.exclude` and its deny-by-default `workflows.subagents` grants. The
+same value drives prompt guidance, start-time plan validation, and
+defense-in-depth Activity authorization. One agent's exclusions cannot
+unregister a handler another agent may use.
+
+#### Agent and session isolation
+
+`ResolvedAgent.slug` is the stable agent identity on chat, MCP, HTTP-trigger, and
+non-HTTP-trigger paths. Workflow management is scoped by
+`(workflow_agent_slug, session_id)` internally. Durable instance IDs begin with a
+32-hex-character (128-bit) truncated SHA-256 digest over an unambiguous
+length-delimited encoding of both values, followed by the existing random UUID
+suffix. Raw slugs and session IDs are not exposed in instance IDs.
+
+Active-workflow limits, list, status, cancel, terminate, and HTTP polling all
+require both components. A mismatched agent or session returns the same
+not-found/empty result as an unknown workflow, so two agents remain isolated even
+when a caller deliberately reuses one session ID.
+
+This intentionally changes the experimental workflow-ID prefix from the legacy
+session-only 48-bit digest. New application tools and routes do not manage
+pre-upgrade IDs. Operators must drain or terminate legacy instances through
+Durable Functions or DTS tooling before upgrading.
+
+#### Activity-time reauthorization
+
+Capability-bearing Activities carry `workflow_agent_slug` and check the currently
+deployed policy immediately before shared-catalog dispatch:
+
+- tool Activities require the task tool in `policy.allowed_tools`;
+- Workflow Sub Agent Activities require the specialist in
+ `policy.allowed_subagents`; and
+- a missing policy, handler, or Agent catalog entry fails closed with a
+ non-sensitive error and correlated logs.
+
+Persisting the start-time policy as indefinitely authoritative would defeat
+revocation. Removing a workflow-enabled agent while another remains, or
+tightening its grants, therefore makes a pending disallowed Activity fail rather
+than continue with stale authorization. Durable orchestrator replay performs no
+mutable policy lookup.
+
+#### Final-agent removal lifecycle
+
+The last workflow-enabled agent is a special deployment edge case. With no agent
+policy, normal composition intentionally returns to a plain `FunctionApp` to
+avoid Durable overhead in apps that do not use workflows. A plain
+`FunctionApp`, however, has no registered workflow orchestrator or Activities.
+
+Consider an instance whose next Activity has been scheduled but has not yet
+executed:
+
+```mermaid
+sequenceDiagram
+ participant H as Task Hub
+ participant A as Deployment with final workflow agent
+ participant P as Plain FunctionApp after agent removal
+ H->>A: Activity work item is pending
+ Note over A,P: Final workflow-enabled agent is removed
+ H--xP: No Activity Function is registered to receive the work item
+ Note over H: Instance remains non-terminal instead of reaching policy rejection
+```
+
+This differs from ordinary policy revocation: the work item cannot reach
+`require_workflow_agent_policy()` and fail because the Function that executes
+that check is absent. The application does not expose a drain-mode environment
+variable for this edge case. Publishing such a switch would create a durable
+customer compatibility commitment without resolving privileged direct Durable
+starts or establishing whether lifecycle ownership belongs in this runtime or
+Durable itself.
+
+Before removing the final workflow-enabled agent, operators should stop new
+starters and use Task Hub tooling to let existing instances finish or terminate
+them. The supported long-term behavior is deferred to
+[issue #161](https://github.com/Azure/azure-functions-agents-runtime/issues/161),
+which tracks runtime/Durable ownership and a remediation that does not
+prematurely add public surface.
+
+#### Runnable proof
+
+`samples/per-agent-workflows/` contains two non-main workflow-enabled agents with
+different workflow-tool exclusions and Workflow Sub Agent grants. Repository E2E
+automation starts both with the same session ID against Azure Storage and DTS,
+proves each reaches a terminal state using only its own capabilities, and checks
+that cross-agent status access returns 404.
+
## 5. Decisions log
| # | Decision | Options considered | Choice | Decided by | Date |
@@ -436,6 +550,24 @@ are a prerequisite, a parallel feature, or a later hardening step.
| 21 | Dependency on per-agent Workflows (#109) | Wait for #109 / ship main-only then extend | Ship the existing `main.agent.md` owner scope now, while keeping engine and policy boundaries reusable by #109 | Human | 2026-07-24 |
| 22 | Documentation audiences | Explain internals in every document / separate maintainer and customer surfaces | Keep decisions and Durable internals in the FRD/architecture; make samples and authoring docs independently understandable to customers | Human + Chris Gillum | 2026-07-24 |
| 23 | Sub Agent failure diagnostics | Expose provider errors / one generic message / bounded error code plus correlated logs | Keep provider details out of Durable history, expose a stable non-sensitive error code, and correlate detailed logs by Workflow ID, node ID, and specialist slug | Human + Laveesh Rohra | 2026-08-03 |
+| 24 | Record multi-agent support | Create FRD 0009 / evolve this FRD | Keep the change as an addendum to FRD 0004 because it extends the existing experimental feature without adding a new authoring contract | Human + Laveesh Rohra | 2026-08-12 |
+| 25 | Workflow agent identity | Display name / source path / endpoint-specific name / canonical slug | Use app-wide unique `ResolvedAgent.slug` on every channel as `workflow_agent_slug` inside the authorization implementation | Agent | 2026-08-10 |
+| 26 | Workflow isolation scope | Session only / agent only / agent plus session | Scope application management by `(workflow_agent_slug, session_id)` so equal session IDs across agents remain isolated | Human | 2026-08-10 |
+| 27 | Existing workflow IDs | Dual-format fallback / migration map / no application fallback | Accept the experimental ID change, preserve Durable/DTS operator access, and require upgrade drain guidance | Human | 2026-08-10 |
+| 28 | Durable registration lifetime | Once per agent / once per app | Register the Durable engine and complete execution catalogs exactly once per app | Agent | 2026-08-10 |
+| 29 | Per-agent policy | Mutable process global / request-time reconstruction / immutable slug-keyed catalog | Freeze one independent `WorkflowPlanPolicy` per workflow-enabled agent during composition | Agent | 2026-08-10 |
+| 30 | Activity authorization | Trust start-time validation / persist start-time policy / reauthorize deployed policy | Reauthorize tool and Sub Agent Activities against the currently deployed agent policy so restrictive changes fail closed | Human | 2026-08-10 |
+| 31 | Non-HTTP trigger management | Add an app-wide index / share one synthetic session / generated invocation session | Keep generated sessions and no new application index; use Durable/DTS tooling for app-wide operations | Human | 2026-08-10 |
+| 32 | Authoring schema | Add owner/config fields / reuse current workflow config | Reuse existing fields; derive identity from the canonical agent slug | Agent | 2026-08-10 |
+| 33 | Ownership digest width | Keep 48 bits / store literal identity / increase digest | Use a 128-bit truncated SHA-256 prefix over length-delimited agent/session input | Human | 2026-08-10 |
+| 34 | Workflow agent eligibility | Require a dedicated starter and fail composition / allow every enabled agent | Treat every agent with `workflows.enabled: true` as workflow-enabled; invocation surfaces remain independent. This supersedes the earlier provisional fail-composition rule. | Human | 2026-08-11 |
+| 35 | Customer sample boundary | Put sender/verifier helpers in the sample / separate internal automation | Keep the sample documentation-led and directly runnable; keep E2E automation under `tests/scripts` | Human | 2026-08-11 |
+| 36 | Final-agent removal | Always register Durable / documentation-only drain / explicit runtime retention | Add opt-in drain mode that blocks application starts and retains Durable registration until Task Hub tooling confirms no non-terminal instances; ordinary non-workflow apps remain plain `FunctionApp` | Human | 2026-08-12 |
+| 37 | Exported compatibility helpers | Remove production-dead helpers / retain shared state / isolate compatibility state | Retain exported registry and one-shot integration helpers without an unrelated breaking change, but keep their registration token out of production `WorkflowSessionContext` and never authorize production execution from the singleton fallback | Agent | 2026-08-11 |
+| 38 | Trigger decorator resolution | Add a shared resolver / duplicate capability validation / retain registration-local fallback | Keep the registration-local `connector_trigger` to `generic_trigger` fallback and avoid an unrelated hard failure for non-workflow agents | Agent | 2026-08-11 |
+| 39 | Activity-wave failure propagation | Rely on Durable wrapper behavior / explicitly rethrow the failed wave result | Explicitly rethrow failed `task_all` results so policy denials retain their actionable error instead of degrading to a secondary `TypeError` | Agent | 2026-08-11 |
+| 40 | Internal workflow-agent terminology | `owner_slug` / `agent_slug` / `workflow_agent_slug` | Use `workflow_agent_slug` throughout workflow plumbing and persisted payloads: it identifies the top-level agent that starts, authorizes, and namespaces the workflow without colliding conceptually with a delegated Sub Agent | Human | 2026-08-12 |
+| 41 | Final-agent lifecycle public surface | Keep `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` / remove it and track the lifecycle gap / always register Durable | Remove the environment variable before release and track the edge case in #161; publishing an operational switch would create a customer compatibility commitment before runtime versus Durable ownership is resolved. This supersedes Decision #36. | Human + Laveesh Rohra | 2026-08-13 |
## 6. Test plan
@@ -457,9 +589,6 @@ are a prerequisite, a parallel feature, or a later hardening step.
- `@workflow_tool` using a reserved runtime management name such as
`start_workflow` is rejected;
- effective workflow tool set respects `workflows.exclude`.
-- [ ] Unit: non-main workflow config
- - non-main `workflows.enabled: true` logs a warning and does not inject
- workflow tools.
- [ ] Unit: `tests/test_workflow_integration_validation.py`
- `workflows.exclude` shape validation;
- unknown workflow keys fail with actionable messages.
@@ -474,7 +603,7 @@ are a prerequisite, a parallel feature, or a later hardening step.
- [ ] E2E: run the `workflow-incident-triage` sample locally with Azurite/Durable
storage and confirm a workflow can start, execute sample tools, and complete.
- [x] Evolution #112: workflow-enabled HTTP and non-HTTP handlers receive the
- Durable client and trigger addendum while disabled/non-main handlers keep
+ Durable client and trigger addendum while workflow-disabled handlers keep
their existing signatures.
- [x] Evolution #112: timer and queue samples index their trigger, Durable
client, orchestrator, and Activity bindings and complete model-backed local
@@ -491,6 +620,20 @@ are a prerequisite, a parallel feature, or a later hardening step.
end through Queue, Durable execution, fake PR tools, HTML reduction, and
Blob publication, including convergence on the same Blob after repeated
publication.
+- [x] Evolution #151: multi-agent workflows
+ - compose every workflow-enabled agent with an independent immutable policy;
+ - register one app-wide Durable engine and complete execution catalogs;
+ - isolate IDs and management by agent plus session and return non-existence
+ semantics for cross-agent access;
+ - reauthorize capability-bearing Activities against the deployed policy;
+ - keep an ordinary app with no workflow-enabled agents on plain
+ `FunctionApp`;
+ - document and track the unresolved final-agent lifecycle edge case without
+ exposing a drain-mode environment variable;
+ - treat legacy session-only IDs as not-found without deleting or mutating
+ their Durable instances;
+ - prove independent agents and same-session isolation against Azure Storage
+ and DTS.
## 7. Docs impact
@@ -511,6 +654,11 @@ are a prerequisite, a parallel feature, or a later hardening step.
`docs/front-matter-spec.md`, `docs/workflows.md`, and `docs/architecture.md`;
keep the sample customer-facing and free of FRD/Durable implementation
details.
+- [x] Evolution #151: document multi-agent policy isolation, workflow-ID
+ migration, final-agent drain operations, and the runnable
+ `samples/per-agent-workflows/` app.
+- [x] Evolution #151: update `samples/README.md` with the multi-agent runnable
+ sample while keeping internal verifier details outside the customer app.
## 8. Status & sign-off
@@ -530,5 +678,20 @@ are a prerequisite, a parallel feature, or a later hardening step.
authorization enforcement, explicit at-least-once semantics, and an
Activity-owned timeout boundary; those findings are incorporated above.
- **Workflow Sub Agent human sign-off:** TsuyoshiUshio, 2026-07-24. Approved
- Activity-only execution, `{agent, text}` results, main-only v1 ownership, and
+ Activity-only execution, `{agent, text}` results, main-only v1 scope, and
implementation using TDD followed by sample E2E validation.
+- **Multi-agent workflow sign-off:** TsuyoshiUshio, 2026-08-10. Approved
+ app-wide Durable registration, immutable per-agent policies, agent/session
+ isolation, deployed-policy Activity reauthorization, the experimental
+ workflow-ID migration, and Storage/DTS E2E validation.
+- **Multi-agent architecture review:** An independent rubber-duck review on
+ 2026-08-10 evaluated the extension against `main`,
+ `docs/architecture.md`, FRDs 0004 and 0007, issues #1274/#1275, and the
+ existing trigger and Workflow Sub Agent implementation. Findings on digest
+ strength, provisional decisions, legacy-ID behavior, and verifier
+ prerequisites were incorporated with no remaining blockers. The human
+ sign-off ratified the resulting authorization, non-HTTP management, and
+ digest decisions before implementation.
+- **Final-agent drain sign-off:** TsuyoshiUshio, 2026-08-12. Approved explicit
+ runtime retention so pending work reaches Activity reauthorization instead of
+ being stranded after the last workflow-enabled agent is removed.
diff --git a/docs/frds/0007-multi-agent-delegation.md b/docs/frds/0007-multi-agent-delegation.md
index 86325d71..eac15775 100644
--- a/docs/frds/0007-multi-agent-delegation.md
+++ b/docs/frds/0007-multi-agent-delegation.md
@@ -290,8 +290,8 @@ created for that top-level invocation. A delegated call is not another
top-level request, so it opens no specialist sandbox session. A separate
delegated sandbox session could be a future enhancement.
-Dynamic-Workflow tools are already restricted to `main.agent.md` by FRD 0004,
-regardless of delegation. No delegation-specific removal is needed.
+Dynamic-Workflow tools are scoped by each workflow-enabled agent's policy under
+FRD 0004, so delegation does not need a separate removal rule.
Conversation history is also isolated: the delegate tool's handler calls
`agent.run(task)` with no `session=` argument at all, so the specialist
diff --git a/docs/front-matter-reference.md b/docs/front-matter-reference.md
index 97189a44..976f2ef4 100644
--- a/docs/front-matter-reference.md
+++ b/docs/front-matter-reference.md
@@ -190,7 +190,7 @@ Enable Dynamic Workflows, filter workflow tools, and grant leaf specialists.
| Property | Type | Required | Default | Description |
|----------|------|----------|---------|-------------|
-| `enabled` | boolean | No | `false` | Enable Dynamic Workflows for this agent. In v1, only `main.agent.md` is honored. |
+| `enabled` | boolean | No | `false` | Enable Dynamic Workflows for this agent. The agent must have a supported trigger, chat API, or MCP endpoint. |
| `exclude` | string[] | No | `[]` | Discovered `@workflow_tool` names to withhold from workflow plans. |
| `subagents` | object[] | No | `[]` | Independent, deny-by-default leaf-specialist grants. [Details](#agent-workflows-subagents) |
@@ -316,7 +316,7 @@ Applies to all string values in `agents.config.yaml`, `mcp.json`, and agent `.ag
**Agent Front Matter:**
- `name` (always required)
- `description` (always required)
-- `trigger` (required unless at least one `builtin_endpoints` value is enabled, or the agent is referenced only as an internal specialist via another agent's `subagents:`)
+- `trigger` (required unless at least one `builtin_endpoints` value is enabled, or the agent is referenced as an internal specialist via another agent's `subagents` or `workflows.subagents`)
**Global Configuration:**
- No required properties (entire file is optional)
diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md
index b81ee09c..7c51c412 100644
--- a/docs/front-matter-spec.md
+++ b/docs/front-matter-spec.md
@@ -26,7 +26,7 @@ Each agent is defined in a `.agent.md` file with YAML front matter followed by m
- **Inherits all discovered capabilities by default**
- Can apply **exclude lists** to filter out unwanted MCP servers, skills, or tools
- Can **override** runtime settings (model, timeout)
-- Can enable Dynamic Workflows on `main.agent.md`
+- Can enable Dynamic Workflows on any agent
- Must define **trigger** (how the agent is invoked)
- Can enable **HTTP/MCP endpoints** for testing and composition
@@ -90,7 +90,7 @@ YAML front matter at the top of each agent file.
- `mcp` — Boolean or object to inherit, disable, or exclude MCP servers
- `skills` — Object with exclude lists or false to filter skills
- `tools` — Object with exclude lists or false to filter tools
-- `workflows` — Object to enable Dynamic Workflows on `main.agent.md`
+- `workflows` — Object to enable Dynamic Workflows on an agent
- `subagents` — Array of `{agent, when?}` references to specialist agents this agent may delegate to at chat time
- `input_schema` — Object, JSON Schema for HTTP request validation
- `response_schema` — Object, JSON Schema for response validation
@@ -111,7 +111,27 @@ YAML front matter at the top of each agent file.
...
```
-Agent markdown files (`*.agent.md`) can be placed at the app root or in an `agents/` folder. The folder name is case-insensitive (`agents/` or `Agents/`). Files from both locations are combined and sorted by path for deterministic ordering. `main.agent.md` in either location is marked as the main agent.
+Agent markdown files (`*.agent.md`) can be placed at the app root or in an
+`agents/` folder. The folder name is case-insensitive (`agents/` or `Agents/`).
+Files from both locations are combined and sorted by path for deterministic
+ordering. `main.agent.md` in either location is marked as the main agent for
+compatibility, but neither its filename nor its directory determines whether an
+agent is directly invokable, a coordinator, workflow-enabled, or a specialist.
+
+### Agent roles and reachability
+
+Roles come from invocation surfaces and references, not file placement:
+
+| Role | How it is identified |
+| --- | --- |
+| Directly invokable agent | Defines a `trigger` or enables at least one `builtin_endpoints` value. |
+| Chat coordinator | Declares top-level `subagents`; each reference becomes a `delegate_` tool during direct invocation. |
+| Chat Sub Agent | Is referenced by another agent's top-level `subagents`. It may omit its own trigger/endpoints when it is internal-only. |
+| Workflow-enabled agent | Sets `workflows.enabled: true`. |
+| Workflow Sub Agent | Is referenced by a workflow-enabled agent's `workflows.subagents`. It does not need `workflows.enabled` and may omit its own trigger/endpoints when it is internal-only. |
+
+These roles can overlap. For example, an agent can have its own HTTP trigger and
+also be referenced as another agent's Chat or Workflow Sub Agent.
---
@@ -125,7 +145,7 @@ Fields are organized into categories based on how they can be used:
- `mcp` — MCP servers discovered from `mcp.json`, filtered in agents
- `skills` — Auto-discovered from `skills/` directory, exclude lists (agent only)
- `tools` — Auto-discovered from `tools/` directory, exclude lists (agent only)
-- `workflows` — Dynamic Workflow enablement and workflow-tool excludes (`main.agent.md` only)
+- `workflows` — Dynamic Workflow enablement, workflow-tool excludes, and workflow Sub Agent grants
- `system_tools` — System-level tools and capabilities (global configuration, agent opt-out)
- `dynamic_sessions_code_interpreter` — ACA Dynamic Sessions code interpreter
- `web_request` — Built-in outbound HTTP request tool (default-on, SSRF-guarded)
@@ -136,7 +156,7 @@ Fields are organized into categories based on how they can be used:
**Agent-Specific (Agent front matter only):**
- `name`, `description` — Agent identity (required)
-- `trigger` — Invocation method (required unless at least one built-in endpoint is enabled, or the agent is referenced only as an internal specialist via another agent's `subagents:`)
+- `trigger` — Invocation method (required unless at least one built-in endpoint is enabled, or the agent is referenced as an internal specialist via another agent's `subagents` or `workflows.subagents`)
- `builtin_endpoints` — Built-in chat UI, chat API, and MCP tool endpoints
- `subagents` — Chat-time delegation to specialist agents (`delegate_` tools; see [`subagents`](#subagents))
- `logger`, `substitute_variables` — Agent runtime behavior switches
@@ -147,7 +167,10 @@ Fields are organized into categories based on how they can be used:
### Required Fields (Agent Front Matter Only)
-**Summary:** Every `.agent.md` file must have `name` and `description`. It must also have either a `trigger` or at least one enabled `builtin_endpoints` value.
+**Summary:** Every `.agent.md` file must have `name` and `description`. It must
+also have either a `trigger` or at least one enabled `builtin_endpoints` value,
+unless another agent references it through `subagents` or
+`workflows.subagents` as an internal specialist.
#### `name`
- **Type:** `string`
@@ -567,7 +590,7 @@ tools: false
#### `workflows`
- **Type:** `object`
-- **Location:** Agent front matter (`main.agent.md` only in v1)
+- **Location:** Agent front matter (any agent)
- **Description:** Enables Dynamic Workflows, filters discovered workflow tools, and
grants access to leaf specialists for workflow tasks.
@@ -584,8 +607,10 @@ workflows:
`workflows.enabled` is a strict boolean. When true, it injects
workflow-management tools (`start_workflow`, `get_workflow_status`,
-`list_workflows`, `cancel_workflow`, `terminate_workflow`) and registers public
-`@workflow_tool` handlers discovered from `tools/*.py` as workflow task targets.
+`list_workflows`, `cancel_workflow`, `terminate_workflow`) and exposes the
+agent-allowed public `@workflow_tool` handlers discovered from `tools/*.py` as
+workflow task targets. No new role or starter fields are required; workflow
+identity comes from the agent's canonical slug.
The v1 runtime currently requires workflow tool handlers to be synchronous,
accept one dictionary argument, and return JSON-serializable values. This is an
implementation constraint of the v1 registry and Activity runner, not a Durable
@@ -593,7 +618,13 @@ Functions requirement.
Normal custom tools keep their existing behavior. Plain public functions and `@tool`/`FunctionTool` values in `tools/*.py` are normal MAF tools; `@workflow_tool` marks a callable for workflow execution. Use both decorators when a callable should be available both directly in chat and inside workflow tasks. Use `_`-prefixed helpers for functions that should be neither normal tools nor workflow tools.
-`workflows.exclude` filters only workflow Activity targets; it does not affect normal tools. Conversely, `tools.exclude` filters normal MAF tools and does not hide workflow tools. In v1, setting `workflows.enabled: true` outside `main.agent.md` logs a warning and is ignored.
+`workflows.exclude` filters only that agent's workflow Activity targets; it does
+not affect normal tools or another agent's workflow policy. Conversely,
+`tools.exclude` filters normal MAF tools and does not hide workflow tools.
+
+Any agent may enable workflows. Invocation remains governed independently by its
+configured trigger and built-in endpoints. `builtin_endpoints.debug_chat_ui`
+automatically enables its backing chat API.
`workflows.subagents` is independent from top-level [`subagents`](#subagents).
It is deny-by-default: only listed specialist slugs can appear in a workflow
@@ -654,7 +685,7 @@ relevant, then give the customer a single consolidated answer.
**Delegated execution ("runs as itself"):** A specialist invoked through delegation uses its own instructions, model, and static tools (its own user tools, MCP servers, and skills) exactly as if it had been triggered directly — same identity, same configuration. What differs is context and role:
- **Context isolation:** the specialist receives a single self-contained string argument, `task` (`propagate_session=False`) — it does not see the coordinator's conversation history or share session state.
-- **No per-request sandbox or Dynamic Workflow tools:** these are naturally absent for a delegated specialist (not stripped — they were never part of its own static configuration to begin with, since sandbox sessions are per-request and `workflows` only applies to `main.agent.md`).
+- **No per-request sandbox or Dynamic Workflow tools:** these are naturally absent for a delegated specialist (not stripped) because both capabilities belong to the top-level direct invocation, not the delegated execution role.
- **No recursive delegation:** delegation is single-level. A specialist invoked through `subagents:` never gets its own `delegate_*` tools, even if it declares `subagents:` of its own — its references are simply not wired for that call. This is enforced structurally (the specialist-building code path never reads a delegated agent's own `subagents`), not with a runtime depth counter, so mutual `A` ↔ `B` references are harmless.
**Trust boundary:** `subagents` is an explicit **capability grant** from the app author. A delegated call runs in-process and does not pass through the specialist's own endpoint authorization (`auth_level`, etc.) — treat one deployed app as one trust domain, and only delegate to specialists you are comfortable exposing to anyone who can reach the coordinator.
@@ -1228,7 +1259,7 @@ step-by-step answers.
**Agent Front Matter (`.agent.md`):**
1. **`name`** — Must always be present (string)
2. **`description`** — Must always be present (string)
-3. **`trigger` or `builtin_endpoints`** — A trigger is required unless at least one built-in endpoint is enabled, **or** the agent is referenced only as an internal specialist via another agent's `subagents:` (see "Internal specialist agents" under [File Naming Conventions](#file-naming-conventions) below)
+3. **`trigger` or `builtin_endpoints`** — A trigger is required unless at least one built-in endpoint is enabled, **or** the agent is referenced as an internal specialist through another agent's `subagents` or `workflows.subagents` (see "Internal specialist agents" under [File Naming Conventions](#file-naming-conventions) below)
**Global Configuration (`agents.config.yaml`):**
- **No required properties** — The entire file is optional
@@ -1322,9 +1353,18 @@ In other words, the display `name:` field is never used to derive registered Azu
**Endpoint-only agents:**
Any `.agent.md` file, including `main.agent.md`, may omit `trigger` when at least one built-in endpoint is enabled. For example, `main.agent.md` with `builtin_endpoints: true` is available at `/agents/main/`, `/agents/main/chat`, and `/agents/main/chatstream`, and registers an MCP tool named `main` on the shared runtime MCP transport.
-**Internal specialist agents:** An agent may also omit both `trigger` and `builtin_endpoints` if — and only if — another agent's `subagents:` references it. Such an agent has no endpoint of its own and is reachable only through delegation; see [`subagents`](#subagents) and [Example 6](#example-6-coordinator-with-delegated-specialists) above.
-
-Agents with neither `trigger` nor enabled `builtin_endpoints`, and that are not referenced by any other agent's `subagents:`, are invalid.
+**Internal specialist agents:** An agent may also omit both `trigger` and
+`builtin_endpoints` if — and only if — another agent references it through
+top-level `subagents` or `workflows.subagents`. Such an agent has no endpoint of
+its own. A top-level reference makes it reachable through a `delegate_`
+tool; a workflow reference makes it reachable as a workflow `sub_agent` node.
+See [`subagents`](#subagents),
+[`workflows`](#workflows), and
+[Example 6](#example-6-coordinator-with-delegated-specialists) above.
+
+Agents with neither `trigger` nor enabled `builtin_endpoints`, and that are not
+referenced by any other agent's `subagents` or `workflows.subagents`, are
+invalid.
**Example project structure:**
```
diff --git a/docs/triggers.md b/docs/triggers.md
index 32755921..bd849a3d 100644
--- a/docs/triggers.md
+++ b/docs/triggers.md
@@ -41,14 +41,17 @@ for a runnable example (`tech.agent.md` is one such endpoint-less specialist).
### Starting Dynamic Workflows
-When `main.agent.md` sets `workflows.enabled: true`, every supported declared
-trigger can initiate a Dynamic Workflow. The runtime schedules the workflow
-asynchronously, and the trigger Function does not wait for it to finish.
+When an agent sets `workflows.enabled: true`, each supported declared
+trigger can initiate a Dynamic Workflow. Its handler receives the Durable client
+and uses that agent's slug and immutable workflow policy. The runtime schedules the
+workflow asynchronously, and the trigger Function does not wait for it to finish.
This behavior is generic across HTTP, timer, queue, blob, Event Grid, Service
-Bus, connector, and the other supported trigger decorators. It is still limited
-to `main.agent.md`; workflow settings on other agent files are ignored with a
-startup warning.
+Bus, connector, and the other supported trigger decorators. HTTP uses the
+caller-provided or generated session ID. Non-HTTP invocations generate a fresh
+session ID; there is intentionally no app-wide index for finding those sessions, so
+the workflow should publish its terminal result and operators should use
+Durable/DTS tooling for management.
See [Trigger-started workflows](./workflows.md#trigger-started-workflows) for
HTTP and non-HTTP completion behavior, and the
@@ -150,7 +153,7 @@ HTTP requests can pass `x-ms-session-id`; otherwise the runtime creates a sessio
An HTTP request receives the agent's immediate response, not the eventual
workflow result. The configured response schema/example continues to govern the
immediate response. Runtime workflow monitoring routes are available only when
-the same main agent also enables the built-in chat API. For non-HTTP result
+the same workflow-enabled agent also enables the built-in chat API. For non-HTTP result
delivery, see
[Trigger-started workflows](./workflows.md#trigger-started-workflows).
diff --git a/docs/workflows.md b/docs/workflows.md
index 7fac9946..856abacd 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -9,7 +9,10 @@
> [queue-trigger sample](https://github.com/Azure/azure-functions-agents-runtime/blob/main/samples/workflow-queue-p0-report/README.md) for a
> non-interactive starter. The
> [parallel PR report sample](https://github.com/Azure/azure-functions-agents-runtime/blob/main/samples/workflow-subagents-preview/README.md)
-> demonstrates workflow Sub Agents. Larger features such as sub-orchestrations,
+> demonstrates workflow Sub Agents. The
+> [Engineering Operations Hub](https://github.com/Azure/azure-functions-agents-runtime/blob/main/samples/per-agent-workflows/README.md)
+> demonstrates two non-main workflow-enabled agents with independent policies in one app.
+> Larger features such as sub-orchestrations,
> configurable retry policies, and MCP Tasks integration are tracked as v2
> follow-up work.
@@ -41,9 +44,8 @@ They are **not** the right tool for:
immediately with an ID; the *result* is fetched on a later turn);
- hand-authored orchestration DSLs — plans are LLM-authored only, by
design, so there is no YAML/markdown workflow template format;
-- cross-app coordination. v1 workflows live inside one Functions app and are
- enabled only by its `main.agent.md`; authorized leaf specialists in that app
- can run as workflow Sub Agents.
+- cross-app coordination. v1 workflows live inside one Functions app; any
+ agent in that app can own workflows and authorize leaf specialists.
## Why workflows (token, latency, context)
@@ -164,12 +166,29 @@ prefer `start_workflow` over direct tool calls. The agent author does
not need to document the tools or the heuristics in their markdown — the
agent markdown stays focused on the domain.
-> [!IMPORTANT]
-> **v1 constraint:** `workflows.enabled: true` is only honored on
-> `main.agent.md`. That main agent may be invoked interactively or by a declared
-> trigger. Other agents
-> that set the flag get a startup warning and the tools are not injected.
-> A future release will lift this constraint.
+Any agent may enable workflows by setting `workflows.enabled: true`.
+Invocation remains independent: triggers and built-in endpoints determine how
+the agent can be reached, and `debug_chat_ui` automatically enables its backing
+chat API.
+
+File placement does not assign these roles. See
+[Agent roles and reachability](./front-matter-spec.md#agent-roles-and-reachability)
+for how direct, workflow-enabled, and internal specialist agents are identified.
+
+### App-wide engine, per-agent policy
+
+The app discovers complete, immutable catalogs of workflow handlers and agents.
+If at least one workflow-enabled agent exists, startup creates one `DFApp` and
+registers one Durable orchestrator plus one copy of each Activity for the whole
+app. It does **not** register a separate engine per agent.
+
+An app with no workflow-enabled agents remains a plain `FunctionApp`.
+
+Each workflow-enabled agent instead gets an immutable policy containing only its allowed
+workflow tools (after `workflows.exclude`) and its deny-by-default
+`workflows.subagents` grants. Prompt guidance, `start_workflow` validation, and
+Activity dispatch all use that agent's policy. One agent's exclusion never
+removes a handler another agent is allowed to use.
### Workflow tool authoring
@@ -260,12 +279,13 @@ hardening controls.
### Workflow Sub Agents
-The author grants access in `main.agent.md` with `workflows.subagents`. Each
+The workflow-enabled agent grants access in its frontmatter with
+`workflows.subagents`. Each
frontmatter grant contains `agent` and optional `when`; it is not a DAG node.
The model then generates a `sub_agent` DAG node with exactly `id`, `type`,
`agent`, `task`, and optional `depends_on`. A node does not accept `when`,
`tool`, `args`, `duration`, or `until`.
-The runtime validates every specialist slug against the workflow owner's
+The runtime validates every specialist slug against the workflow-enabled agent's
immutable grant before any node is scheduled and fails closed if the specialist
is unavailable.
@@ -394,7 +414,8 @@ channel from the orchestrator into the agent's chat thread.
output enters the agent's context window via `get_workflow_status`.
- The `GET /agents/{slug}/workflows` endpoint is scoped to the calling session
via the `x-ms-session-id` request header and the per-workflow
- ownership scheme described in [Ownership](#ownership).
+ isolation scheme described in
+ [Agent and session isolation](#agent-and-session-isolation).
The data shape maps directly onto MCP Tasks SEP-2557 (`CreateTaskResult`,
`tasks/get`, `tasks/cancel`); future direct MCP Tasks support is a thin
@@ -434,7 +455,7 @@ runtime contract enforced by the framework. External clients (e.g.
an MCP-Tasks-aware client) are free to adopt the same convention or
to drive completion handling some other way (e.g. a dedicated `task
completed` UI event with no synthetic prompt). The server-side
-mechanics — `GET /agents/{slug}/workflows`, `get_workflow_status`, ownership
+mechanics — `GET /agents/{slug}/workflows`, `get_workflow_status`, isolation
scoping — are the actual contract; the synthetic-prompt format is a
client-side detail.
@@ -446,7 +467,7 @@ turn.
### Trigger-started workflows
-Any supported Markdown-declared trigger on a workflow-enabled `main.agent.md`
+Any supported Markdown-declared trigger on a workflow-enabled agent
can start a Dynamic Workflow:
1. The agent receives the trigger payload and authors a workflow plan.
@@ -465,14 +486,76 @@ service. The trigger-specific system guidance directs the agent to use that tool
as the workflow's final step. Use Durable Functions or Durable Task Scheduler
tooling for operational monitoring and control.
-## Ownership
+Every trigger invocation uses that agent's slug, policy, and bound Durable
+client. HTTP triggers use the request session (or the normal generated session).
+Non-HTTP triggers generate a fresh invocation session and intentionally create
+no application-level session index or reconnect API. In all cases the starter
+returns after the initial model turn; orchestration continues asynchronously.
+
+## Agent and session isolation
-Every workflow's Durable instance ID is prefixed with
-`sha256(session_id)[:12]` at creation. `get_workflow_status`,
+Each workflow is isolated by the workflow-enabled agent's canonical slug and the
+invocation `session_id`. Internally, Durable payloads call this pair
+`(workflow_agent_slug, session_id)`; `workflow_agent_slug` is not a frontmatter field. The instance
+ID begins with a 32-hex-character (128-bit) truncated SHA-256 digest over an
+unambiguous length-delimited encoding of that pair; neither raw value appears in
+the ID. `get_workflow_status`,
`list_workflows`, `cancel_workflow`, and `terminate_workflow` filter
-on that prefix; a workflow whose prefix does not match the calling
-session's hash is treated as nonexistent (returns 404, never 403, so
-existence cannot be probed by guessing IDs across sessions).
+on that prefix. A workflow whose agent **or** session does not match is treated
+as nonexistent (404/empty, never 403), so two agents remain isolated even when
+callers deliberately reuse the same session ID.
+
+Activities reauthorize immediately before dispatch against the **currently
+deployed** agent policy. Removing a workflow-enabled agent while another remains, or
+tightening a tool/Sub Agent grant, therefore revokes pending capability-bearing
+nodes; they fail closed rather than continuing under a stale policy snapshot.
+
+### Removing the final workflow-enabled agent
+
+Removing the final workflow-enabled agent is a known deployment lifecycle edge
+case. An Activity work item may already be queued in the Task Hub but not yet
+executed. The resulting plain `FunctionApp` has no registered orchestrator or
+Activity Function to receive that work item, so it cannot reach policy
+reauthorization and fail explicitly; it may remain non-terminal in the hub.
+
+There is currently no application environment variable or supported runtime
+drain mode for this transition. Before removing the final workflow-enabled
+agent, stop new starters and use Durable Functions or DTS Task Hub tooling to
+let existing instances finish or terminate them. Confirm that no non-terminal
+instances remain, and preserve the Task Hub name, backend connection,
+`host.json` Durable settings, and extension bundle during the transition.
+
+The runtime/Durable ownership and long-term remediation are tracked in
+[the final-agent lifecycle issue](https://github.com/Azure/azure-functions-agents-runtime/issues/161).
+
+### Migration from legacy workflow IDs
+
+This experimental feature intentionally changes IDs from a session-only 48-bit
+prefix to the agent-and-session 128-bit prefix. New agent tools and polling
+routes cannot list, inspect, cancel, or terminate pre-upgrade IDs. In addition,
+legacy orchestration inputs contain no `workflow_agent_slug`, so an in-flight legacy
+workflow fails closed when it next dispatches a `tool` or `sub_agent` Activity;
+pure `wait` nodes do not require agent authorization. Drain or terminate active
+workflows before upgrading. Use Durable Functions or DTS tooling to inspect or
+control any legacy instances that remain.
+
+### Operational scaling notes
+
+Each worker reconstructs the immutable agent-policy and handler catalogs from
+the same deployed agent project during app startup. Orchestrators persist
+`workflow_agent_slug` in their input and pass it to Activities, so an Activity may safely
+run on a different worker. Do not share a Task Hub between applications or
+deployments with different agent definitions. During a rolling deployment,
+old and new workers may briefly enforce different policy versions; restrictive
+changes can therefore fail pending nodes closed as soon as a new worker handles
+them.
+
+Session workflow listing currently calls Durable's task-hub status API and
+filters by agent/session prefix in the application. Configure backend retention
+or periodically purge completed orchestration history so polling cost does not
+grow without bound. The active-workflow limit is per agent and session;
+non-HTTP trigger invocations generate new session IDs, so that limit is not an
+agent-wide throttle.
## Observability
@@ -502,6 +585,8 @@ existence cannot be probed by guessing IDs across sessions).
v1 includes:
- five built-in workflow tools;
+- any agent may enable workflows, with one app-wide engine and immutable
+ per-agent policies;
- DAG execution of `@workflow_tool` calls and wait tasks;
- deny-by-default `workflows.subagents` grants and stateless `sub_agent` tasks;
- fan-out/fan-in via `depends_on`;
@@ -515,8 +600,7 @@ v1 includes:
- fixed v1 guardrails for plan size, parallelism, wait duration, active
workflows per session, and status-list result count.
-v2 follow-up work includes enabling workflows for non-`main.agent.md`
-agents, sub-orchestrations and bounded nested agents, per-agent registry isolation,
-configurable caps, retry and timeout policies, HMAC-backed workflow
-ownership, blob-offloaded large outputs, an MCP Tasks bridge, richer error
-taxonomy, and storage hygiene.
+v2 follow-up work includes sub-orchestrations and bounded nested agents,
+configurable caps, retry and timeout policies, HMAC-backed workflow identity,
+blob-offloaded large outputs, an MCP Tasks bridge, richer error taxonomy, and
+storage hygiene.
diff --git a/eng/scripts/generate_config_reference.py b/eng/scripts/generate_config_reference.py
index bb275b04..be072a61 100644
--- a/eng/scripts/generate_config_reference.py
+++ b/eng/scripts/generate_config_reference.py
@@ -345,7 +345,7 @@ def generate_model_table(
}
WORKFLOW_CONFIG_DESCRIPTIONS = {
- "enabled": "Enable Dynamic Workflows for this agent. In v1, only `main.agent.md` is honored.",
+ "enabled": "Enable Dynamic Workflows for this agent. The agent must have a supported trigger, chat API, or MCP endpoint.",
"exclude": "Discovered `@workflow_tool` names to withhold from workflow plans.",
"subagents": "Independent, deny-by-default leaf-specialist grants. [Details](#agent-workflows-subagents)",
}
@@ -664,7 +664,8 @@ def generate_markdown() -> str:
"- `name` (always required)",
"- `description` (always required)",
"- `trigger` (required unless at least one `builtin_endpoints` value is enabled, "
- "or the agent is referenced only as an internal specialist via another agent's `subagents:`)",
+ "or the agent is referenced as an internal specialist via another agent's "
+ "`subagents` or `workflows.subagents`)",
"",
"**Global Configuration:**",
"- No required properties (entire file is optional)",
diff --git a/samples/README.md b/samples/README.md
index a44a3af2..c23a68cc 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -12,13 +12,14 @@ app deployable with [`azd up`](https://learn.microsoft.com/azure/developer/azure
| [daily-azure-report](daily-azure-report/) | Timer + HTTP | ✅ azure_rest | ✅ Office 365 Outlook | ✅ MS Learn + Office 365 Outlook | ✅ azure-resources | | ✅ |
| [workflow-incident-triage](workflow-incident-triage/) | HTTP | | | | | | ✅ |
| [workflow-queue-p0-report](workflow-queue-p0-report/) | Queue | ✅ workflow-safe | | | | | |
+| [per-agent-workflows](per-agent-workflows/) | HTTP | ✅ workflow-safe | | | | | ✅ |
+| [workflow-subagents-preview](workflow-subagents-preview/) | Queue | ✅ workflow-safe | | | ✅ | | |
| [secured-endpoints](secured-endpoints/) | HTTP + MCP | | | | | | |
-## Design previews
-
-- [workflow-subagents-preview](workflow-subagents-preview/) is a non-runnable,
- reviewer-facing preview of the proposed Dynamic Workflow Sub Agent authoring
- surface. It intentionally has no `host.json`.
+[`per-agent-workflows`](per-agent-workflows/) is the Engineering Operations Hub:
+two non-main workflow-enabled agents share one Durable engine while retaining
+separate policies. Run it locally with Azurite and use either agent's browser
+chat UI to start and observe an independent workflow.
## Run Locally (optional)
diff --git a/samples/outlook-reply-agent/src/OnNewEmail.agent.md b/samples/outlook-reply-agent/src/OnNewEmail.agent.md
index bbc9e42c..d43ef9d8 100644
--- a/samples/outlook-reply-agent/src/OnNewEmail.agent.md
+++ b/samples/outlook-reply-agent/src/OnNewEmail.agent.md
@@ -3,9 +3,7 @@ name: Outlook Reply Agent
description: Drafts a reply when new Office 365 Outlook email comes from the watched sender.
trigger:
- type: generic_trigger
- args:
- type: connectorTrigger
+ type: connector_trigger
---
You are an Outlook reply drafting assistant.
diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md
new file mode 100644
index 00000000..368575af
--- /dev/null
+++ b/samples/per-agent-workflows/README.md
@@ -0,0 +1,167 @@
+# Engineering Operations Hub
+
+A standalone Azure Functions sample proving that two non-main agents can own
+Dynamic Workflows independently in one app. The incident commander and release
+manager expose built-in debug chat and `chat_api` routes, share one Durable
+engine, and cannot see or invoke each other's workflow capabilities.
+
+All operational evidence is a deterministic local fake. No GitHub, monitoring,
+scanner, deployment, or other cloud API is called. A configured model provider
+is still required for the two workflow-enabled agents and their specialists.
+
+## Architecture
+
+```mermaid
+flowchart LR
+ U[Operator] --> IC[Incident Commander
/agents/incident_commander]
+ U --> RM[Release Manager
/agents/release_manager]
+ IC -->|incident policy| D[One Durable workflow engine]
+ RM -->|release policy| D
+ D --> IT[Incident-only fake tools]
+ D --> IA[Incident Evidence Analyst]
+ D --> RT[Release-only fake tools]
+ D --> RR[Release Risk Reviewer]
+```
+
+There is intentionally no `main.agent.md`. Each workflow-enabled agent has a distinct
+`workflows.exclude` set and one distinct `workflows.subagents` grant. Specialists
+are internal files without triggers or built-in endpoints.
+
+The root-versus-`agents/` placement is only an organizational convention in this
+sample. Roles come from configuration: the two root agents enable workflows and
+chat starters, while the internal files are referenced through
+`workflows.subagents`.
+
+## Workflow diagrams
+
+### Incident workflow
+
+```mermaid
+flowchart LR
+ L[get_incident_logs] --> A[incident_evidence_analyst]
+ M[get_incident_metrics] --> A
+ D[get_incident_deployments] --> A
+ L --> R[compile_incident_report]
+ M --> R
+ D --> R
+ A --> R
+```
+
+The terminal result is a structured report with marker
+`INCIDENT_REPORT_READY`, incident and service identity, severity, evidence,
+likely cause, rollback decision, recommended actions, and specialist analysis.
+
+### Release workflow
+
+```mermaid
+flowchart LR
+ P[get_release_pull_requests] --> A[release_risk_reviewer]
+ T[get_release_test_results] --> A
+ V[get_release_vulnerabilities] --> A
+ W[get_release_change_window] --> A
+ P --> D[compile_release_dossier]
+ T --> D
+ V --> D
+ W --> D
+ A --> D
+```
+
+The terminal result is a structured dossier with marker
+`RELEASE_DOSSIER_READY`, release and service identity, go/no-go decision,
+blocking findings, passed gates, required actions, and specialist analysis.
+
+## Prerequisites
+
+- Python 3.13+ with this repository installed using `pip install -e .[dev]`
+- Azure Functions Core Tools v4 (`func`)
+- Azurite
+- One model provider:
+ - Microsoft Foundry project endpoint and authenticated Azure identity;
+ - Azure OpenAI endpoint, deployment, API version, and credential; or
+ - OpenAI API key and chat model ID
+
+No model provider secret belongs in source control.
+
+`src/requirements.txt` keeps the repository's standard `-e ../../..` editable
+reference, which resolves to this checkout from the committed sample directory.
+
+## Configure and run manually
+
+From this sample root:
+
+```powershell
+Copy-Item src\local.settings.template.json src\local.settings.json
+```
+
+Fill in one provider configuration in `src/local.settings.json`. Start Azurite,
+activate the repository Python environment, then:
+
+```powershell
+Set-Location src
+func start
+```
+
+Open either debug UI:
+
+-
+-
+
+### Run the incident workflow in chat
+
+Open the Incident Commander UI, paste the following message, and send it:
+
+> Start exactly one incident workflow now for incident INC-4821 on checkout-api.
+> Use parallel task IDs incident_logs, incident_metrics, and
+> incident_deployments for the three incident evidence tools. Then use a
+> sub_agent task named incident_analysis with incident_evidence_analyst and
+> include all three whole results. Finish with incident_report using
+> compile_incident_report and pass the incident ID, service, all whole evidence
+> results, and the whole specialist result. Return the workflow ID without
+> polling.
+
+The agent immediately returns a workflow ID. The chat page then displays a live
+workflow card and updates it until the workflow completes.
+
+Expected terminal output: `runtime_status` is `Completed`; the
+`output.results.incident_report` object contains `INCIDENT_REPORT_READY`,
+`"severity": "SEV2"`, and `"decision": "ROLLBACK"`.
+
+### Run the release workflow in chat
+
+Open the Release Manager UI, paste the following message, and send it:
+
+> Start exactly one release-readiness workflow now for release REL-2026.08.11
+> on checkout-api. Use parallel task IDs release_prs, release_tests,
+> release_vulnerabilities, and release_window for the four release evidence
+> tools. Then use a sub_agent task named release_review with
+> release_risk_reviewer and include all four whole results. Finish with
+> release_dossier using compile_release_dossier and pass the release ID, service,
+> all whole evidence results, and the whole specialist result. Return the
+> workflow ID without polling.
+
+Expected terminal output: `runtime_status` is `Completed`; the
+`output.results.release_dossier` object contains `RELEASE_DOSSIER_READY` and
+`"decision": "NO_GO"` because the deterministic evidence includes an
+unexcepted critical vulnerability.
+
+## Optional DTS backend
+
+DTS still requires Azurite for the Functions host's own storage. To use DTS,
+start the emulator with ports of your choice, copy
+`host.dts.json` over `host.json`, set
+`DURABLE_TASK_SCHEDULER_CONNECTION_STRING`, and restart the Functions host.
+Restore the default committed `host.json` to return to Azure Storage.
+
+## Troubleshooting
+
+- **`func` not found:** install Azure Functions Core Tools v4 and reopen the shell.
+- **No model provider configured:** create `src/local.settings.json` and fill in
+ one supported provider.
+- **Foundry authentication fails:** run `az login` or configure the intended
+ workload identity. Never paste tokens into prompts or verifier output.
+- **Worker cannot import dependencies:** activate the same Python environment
+ used for `pip install -e .[dev]` before launching `func`.
+- **DTS provider not found:** remove stale extension-bundle caches and restart;
+ the DTS host variant requires extension bundle 4.32.0 or newer.
+- **A workflow times out:** inspect the Functions output and optional DTS
+ dashboard.
diff --git a/samples/per-agent-workflows/src/.funcignore b/samples/per-agent-workflows/src/.funcignore
new file mode 100644
index 00000000..5decac3c
--- /dev/null
+++ b/samples/per-agent-workflows/src/.funcignore
@@ -0,0 +1,6 @@
+.git*
+.venv/
+__pycache__/
+local.settings.json
+host.dts.json
+
diff --git a/samples/per-agent-workflows/src/agents/incident_evidence_analyst.agent.md b/samples/per-agent-workflows/src/agents/incident_evidence_analyst.agent.md
new file mode 100644
index 00000000..427820d5
--- /dev/null
+++ b/samples/per-agent-workflows/src/agents/incident_evidence_analyst.agent.md
@@ -0,0 +1,14 @@
+---
+name: Incident Evidence Analyst
+description: Correlates a bounded incident evidence package without collecting new data
+timeout: 180
+tools: false
+mcp: false
+skills: false
+---
+
+Analyze only the logs, metrics, and deployments included in the task. Return a
+concise correlation containing: likely cause, timing correlation, contradictory
+signals, confidence, and the safest immediate mitigation. Do not request tools
+or start another workflow.
+
diff --git a/samples/per-agent-workflows/src/agents/release_risk_reviewer.agent.md b/samples/per-agent-workflows/src/agents/release_risk_reviewer.agent.md
new file mode 100644
index 00000000..d8ee5daf
--- /dev/null
+++ b/samples/per-agent-workflows/src/agents/release_risk_reviewer.agent.md
@@ -0,0 +1,14 @@
+---
+name: Release Risk Reviewer
+description: Independently reviews a bounded release evidence package for blocking risk
+timeout: 180
+tools: false
+mcp: false
+skills: false
+---
+
+Review only the supplied pull-request, test, vulnerability, and change-window
+evidence. State the go/no-go recommendation, blockers, non-blocking concerns,
+and conditions required to reconsider. Do not request tools or start another
+workflow.
+
diff --git a/samples/per-agent-workflows/src/function_app.py b/samples/per-agent-workflows/src/function_app.py
new file mode 100644
index 00000000..736ad492
--- /dev/null
+++ b/samples/per-agent-workflows/src/function_app.py
@@ -0,0 +1,3 @@
+from azure_functions_agents import create_function_app
+
+app = create_function_app()
diff --git a/samples/per-agent-workflows/src/host.dts.json b/samples/per-agent-workflows/src/host.dts.json
new file mode 100644
index 00000000..a637d1e8
--- /dev/null
+++ b/samples/per-agent-workflows/src/host.dts.json
@@ -0,0 +1,25 @@
+{
+ "version": "2.0",
+ "telemetryMode": "OpenTelemetry",
+ "extensions": {
+ "http": {
+ "routePrefix": ""
+ },
+ "durableTask": {
+ "hubName": "%TASKHUB_NAME%",
+ "tracing": {
+ "distributedTracingEnabled": true,
+ "version": "V2"
+ },
+ "storageProvider": {
+ "type": "azureManaged",
+ "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
+ }
+ }
+ },
+ "extensionBundle": {
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
+ "version": "[4.32.0, 5.0.0)"
+ }
+}
+
diff --git a/samples/per-agent-workflows/src/host.json b/samples/per-agent-workflows/src/host.json
new file mode 100644
index 00000000..8ae48dfb
--- /dev/null
+++ b/samples/per-agent-workflows/src/host.json
@@ -0,0 +1,21 @@
+{
+ "version": "2.0",
+ "telemetryMode": "OpenTelemetry",
+ "extensions": {
+ "http": {
+ "routePrefix": ""
+ },
+ "durableTask": {
+ "hubName": "%TASKHUB_NAME%",
+ "tracing": {
+ "distributedTracingEnabled": true,
+ "version": "V2"
+ }
+ }
+ },
+ "extensionBundle": {
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
+ "version": "[4.32.0, 5.0.0)"
+ }
+}
+
diff --git a/samples/per-agent-workflows/src/incident_commander.agent.md b/samples/per-agent-workflows/src/incident_commander.agent.md
new file mode 100644
index 00000000..dd933c55
--- /dev/null
+++ b/samples/per-agent-workflows/src/incident_commander.agent.md
@@ -0,0 +1,38 @@
+---
+name: Incident Commander
+description: Investigates production incidents and produces an evidence-backed incident report
+timeout: 300
+tools: false
+mcp: false
+skills: false
+builtin_endpoints:
+ debug_chat_ui: true
+ chat_api: true
+workflows:
+ enabled: true
+ exclude:
+ - get_release_pull_requests
+ - get_release_test_results
+ - get_release_vulnerabilities
+ - get_release_change_window
+ - compile_release_dossier
+ subagents:
+ - agent: incident_evidence_analyst
+ when: Correlate logs, metrics, and deployment timing for one incident
+---
+
+You are the incident commander for the Engineering Operations Hub.
+
+For an incident workflow:
+
+1. Run `get_incident_logs`, `get_incident_metrics`, and
+ `get_incident_deployments` in parallel with the incident ID and service.
+2. Run `incident_evidence_analyst` after all three evidence tasks. Include the
+ complete evidence results in its self-contained task.
+3. Run `compile_incident_report` after the specialist. Pass every whole upstream
+ result with `${node.result}` values, plus the incident ID and service.
+4. Start the workflow and end the turn promptly with its workflow ID.
+
+Never use release-readiness tools. Do not invent live telemetry: this sample's
+local deterministic evidence is the authoritative demo data.
+
diff --git a/samples/per-agent-workflows/src/local.settings.template.json b/samples/per-agent-workflows/src/local.settings.template.json
new file mode 100644
index 00000000..2db7c957
--- /dev/null
+++ b/samples/per-agent-workflows/src/local.settings.template.json
@@ -0,0 +1,13 @@
+{
+ "IsEncrypted": false,
+ "Values": {
+ "FUNCTIONS_WORKER_RUNTIME": "python",
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;Authentication=None",
+ "TASKHUB_NAME": "engineeringopshub",
+ "AZURE_FUNCTIONS_AGENTS_PROVIDER": "foundry",
+ "FOUNDRY_PROJECT_ENDPOINT": "",
+ "FOUNDRY_MODEL": "gpt-5.4-mini"
+ }
+}
+
diff --git a/samples/per-agent-workflows/src/release_manager.agent.md b/samples/per-agent-workflows/src/release_manager.agent.md
new file mode 100644
index 00000000..a3a039a6
--- /dev/null
+++ b/samples/per-agent-workflows/src/release_manager.agent.md
@@ -0,0 +1,38 @@
+---
+name: Release Manager
+description: Evaluates release readiness and produces a structured go/no-go dossier
+timeout: 300
+tools: false
+mcp: false
+skills: false
+builtin_endpoints:
+ debug_chat_ui: true
+ chat_api: true
+workflows:
+ enabled: true
+ exclude:
+ - get_incident_logs
+ - get_incident_metrics
+ - get_incident_deployments
+ - compile_incident_report
+ subagents:
+ - agent: release_risk_reviewer
+ when: Independently assess release evidence and identify blocking risk
+---
+
+You are the release manager for the Engineering Operations Hub.
+
+For a release-readiness workflow:
+
+1. Run `get_release_pull_requests`, `get_release_test_results`,
+ `get_release_vulnerabilities`, and `get_release_change_window` in parallel
+ with the release ID and service.
+2. Run `release_risk_reviewer` after all evidence tasks. Include the complete
+ evidence results in its self-contained task.
+3. Run `compile_release_dossier` after the specialist. Pass every whole upstream
+ result with `${node.result}` values, plus the release ID and service.
+4. Start the workflow and end the turn promptly with its workflow ID.
+
+Never use incident-response tools. Treat a critical vulnerability without an
+approved exception as a no-go, even when tests and change-window checks pass.
+
diff --git a/samples/per-agent-workflows/src/requirements.txt b/samples/per-agent-workflows/src/requirements.txt
new file mode 100644
index 00000000..73d526eb
--- /dev/null
+++ b/samples/per-agent-workflows/src/requirements.txt
@@ -0,0 +1 @@
+-e ../../..[monitor]
diff --git a/samples/per-agent-workflows/src/tools/incident_tools.py b/samples/per-agent-workflows/src/tools/incident_tools.py
new file mode 100644
index 00000000..70380d29
--- /dev/null
+++ b/samples/per-agent-workflows/src/tools/incident_tools.py
@@ -0,0 +1,162 @@
+"""Deterministic incident evidence for the Engineering Operations Hub."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from azure_functions_agents import workflow_tool
+
+
+def _required(args: dict[str, Any], name: str) -> str:
+ value = args.get(name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{name} must be a non-empty string")
+ return value.strip()
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic production log evidence. Args: "
+ "{incident_id: str, service: str}. Returns an incident-scoped evidence record."
+ )
+)
+def get_incident_logs(args: dict[str, Any]) -> dict[str, Any]:
+ incident_id = _required(args, "incident_id")
+ service = _required(args, "service")
+ return {
+ "capability": "get_incident_logs",
+ "incident_id": incident_id,
+ "service": service,
+ "window": "2026-08-10T18:35:00Z/2026-08-10T19:05:00Z",
+ "error_count": 184,
+ "signature": "upstream checkout timeout after connection pool exhaustion",
+ "sample": [
+ "18:42:11Z ERROR checkout request timed out after 3000ms",
+ "18:42:11Z WARN sql connection pool at 100% (64/64)",
+ "18:42:12Z ERROR retry budget exhausted for payment authorization",
+ ],
+ }
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic service metrics. Args: {incident_id: str, service: str}. "
+ "Returns latency, errors, saturation, and the comparison baseline."
+ )
+)
+def get_incident_metrics(args: dict[str, Any]) -> dict[str, Any]:
+ incident_id = _required(args, "incident_id")
+ service = _required(args, "service")
+ return {
+ "capability": "get_incident_metrics",
+ "incident_id": incident_id,
+ "service": service,
+ "latency_p99_ms": 3280,
+ "error_rate_percent": 12.7,
+ "connection_pool_percent": 100,
+ "baseline_latency_p99_ms": 410,
+ "slo_breached": True,
+ }
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic deployment evidence. Args: "
+ "{incident_id: str, service: str}. Returns recent revisions and timing."
+ )
+)
+def get_incident_deployments(args: dict[str, Any]) -> dict[str, Any]:
+ incident_id = _required(args, "incident_id")
+ service = _required(args, "service")
+ return {
+ "capability": "get_incident_deployments",
+ "incident_id": incident_id,
+ "service": service,
+ "deployments": [
+ {
+ "revision": "checkout-api-2026.08.10.4",
+ "deployed_at": "2026-08-10T18:37:00Z",
+ "change": "lower SQL command timeout and raise checkout concurrency",
+ "actor": "release-pipeline",
+ },
+ {
+ "revision": "checkout-api-2026.08.09.2",
+ "deployed_at": "2026-08-09T16:10:00Z",
+ "change": "payment retry jitter",
+ "actor": "release-pipeline",
+ },
+ ],
+ }
+
+
+@workflow_tool(
+ description=(
+ "Compile the terminal structured incident report. Args: {incident_id, service, "
+ "logs: , metrics: , deployments: , "
+ "specialist_analysis: }. "
+ "Returns the INCIDENT_REPORT_READY report."
+ )
+)
+def compile_incident_report(args: dict[str, Any]) -> dict[str, Any]:
+ incident_id = _required(args, "incident_id")
+ service = _required(args, "service")
+ logs = args.get("logs")
+ metrics = args.get("metrics")
+ deployments = args.get("deployments")
+ specialist = args.get("specialist_analysis")
+ expected = (
+ (logs, "get_incident_logs"),
+ (metrics, "get_incident_metrics"),
+ (deployments, "get_incident_deployments"),
+ )
+ for evidence, capability in expected:
+ if not isinstance(evidence, dict) or evidence.get("capability") != capability:
+ raise ValueError(f"{capability} must be supplied as a whole result")
+ if (
+ evidence.get("incident_id") != incident_id
+ or evidence.get("service") != service
+ ):
+ raise ValueError(f"{capability} evidence identity does not match the report")
+ if (
+ not isinstance(specialist, dict)
+ or specialist.get("agent") != "incident_evidence_analyst"
+ or not isinstance(specialist.get("text"), str)
+ ):
+ raise ValueError("specialist_analysis must be the incident evidence analyst result")
+
+ return {
+ "marker": "INCIDENT_REPORT_READY",
+ "report_type": "incident",
+ "capability": "compile_incident_report",
+ "incident_id": incident_id,
+ "service": service,
+ "severity": "SEV2",
+ "decision": "ROLLBACK",
+ "likely_cause": (
+ "revision checkout-api-2026.08.10.4 increased concurrency while lowering "
+ "timeouts, exhausting the SQL connection pool"
+ ),
+ "evidence": [
+ f"{logs['error_count']} matching errors in the incident window",
+ (
+ f"p99 latency {metrics['latency_p99_ms']}ms versus "
+ f"{metrics['baseline_latency_p99_ms']}ms baseline"
+ ),
+ "symptoms began five minutes after checkout-api-2026.08.10.4",
+ ],
+ "recommended_actions": [
+ "roll back checkout-api-2026.08.10.4",
+ "cap checkout concurrency at the previous value",
+ "verify p99 latency and error rate for 15 minutes",
+ ],
+ "specialist": specialist,
+ }
+
+
+__all__ = [
+ "compile_incident_report",
+ "get_incident_deployments",
+ "get_incident_logs",
+ "get_incident_metrics",
+]
diff --git a/samples/per-agent-workflows/src/tools/release_tools.py b/samples/per-agent-workflows/src/tools/release_tools.py
new file mode 100644
index 00000000..1244b4ce
--- /dev/null
+++ b/samples/per-agent-workflows/src/tools/release_tools.py
@@ -0,0 +1,171 @@
+"""Deterministic release-readiness evidence for the Engineering Operations Hub."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from azure_functions_agents import workflow_tool
+
+
+def _required(args: dict[str, Any], name: str) -> str:
+ value = args.get(name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{name} must be a non-empty string")
+ return value.strip()
+
+
+def _identity(args: dict[str, Any]) -> tuple[str, str]:
+ return _required(args, "release_id"), _required(args, "service")
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic pull-request evidence. Args: "
+ "{release_id: str, service: str}. Returns approvals and merge state."
+ )
+)
+def get_release_pull_requests(args: dict[str, Any]) -> dict[str, Any]:
+ release_id, service = _identity(args)
+ return {
+ "capability": "get_release_pull_requests",
+ "release_id": release_id,
+ "service": service,
+ "pull_requests": [
+ {"number": 1842, "title": "Checkout timeout policy", "approvals": 2, "merged": True},
+ {"number": 1851, "title": "Payment retry telemetry", "approvals": 2, "merged": True},
+ ],
+ "unresolved_threads": 0,
+ }
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic test evidence. Args: {release_id: str, service: str}. "
+ "Returns suite totals, failures, and required-check status."
+ )
+)
+def get_release_test_results(args: dict[str, Any]) -> dict[str, Any]:
+ release_id, service = _identity(args)
+ return {
+ "capability": "get_release_test_results",
+ "release_id": release_id,
+ "service": service,
+ "passed": 1284,
+ "failed": 0,
+ "skipped": 7,
+ "required_checks": "passed",
+ "performance_regression_percent": 0.8,
+ }
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic vulnerability evidence. Args: "
+ "{release_id: str, service: str}. Returns scanner findings and policy status."
+ )
+)
+def get_release_vulnerabilities(args: dict[str, Any]) -> dict[str, Any]:
+ release_id, service = _identity(args)
+ return {
+ "capability": "get_release_vulnerabilities",
+ "release_id": release_id,
+ "service": service,
+ "findings": [
+ {
+ "id": "CVE-2026-41017",
+ "severity": "critical",
+ "component": "contoso-auth 3.7.1",
+ "fix_version": "3.7.3",
+ "exception": None,
+ }
+ ],
+ "policy_status": "blocked",
+ }
+
+
+@workflow_tool(
+ description=(
+ "Return deterministic change-window evidence. Args: "
+ "{release_id: str, service: str}. Returns window and staffing readiness."
+ )
+)
+def get_release_change_window(args: dict[str, Any]) -> dict[str, Any]:
+ release_id, service = _identity(args)
+ return {
+ "capability": "get_release_change_window",
+ "release_id": release_id,
+ "service": service,
+ "window_start": "2026-08-11T02:00:00Z",
+ "window_end": "2026-08-11T04:00:00Z",
+ "within_freeze": False,
+ "primary_oncall_confirmed": True,
+ "rollback_owner_confirmed": True,
+ }
+
+
+@workflow_tool(
+ description=(
+ "Compile the terminal go/no-go dossier. Args: {release_id, service, "
+ "pull_requests: , tests: , vulnerabilities: , change_window: , specialist_analysis: }. Returns RELEASE_DOSSIER_READY."
+ )
+)
+def compile_release_dossier(args: dict[str, Any]) -> dict[str, Any]:
+ release_id, service = _identity(args)
+ expected = (
+ (args.get("pull_requests"), "get_release_pull_requests"),
+ (args.get("tests"), "get_release_test_results"),
+ (args.get("vulnerabilities"), "get_release_vulnerabilities"),
+ (args.get("change_window"), "get_release_change_window"),
+ )
+ for evidence, capability in expected:
+ if not isinstance(evidence, dict) or evidence.get("capability") != capability:
+ raise ValueError(f"{capability} must be supplied as a whole result")
+ if (
+ evidence.get("release_id") != release_id
+ or evidence.get("service") != service
+ ):
+ raise ValueError(f"{capability} evidence identity does not match the dossier")
+ specialist = args.get("specialist_analysis")
+ if (
+ not isinstance(specialist, dict)
+ or specialist.get("agent") != "release_risk_reviewer"
+ or not isinstance(specialist.get("text"), str)
+ ):
+ raise ValueError("specialist_analysis must be the release risk reviewer result")
+
+ return {
+ "marker": "RELEASE_DOSSIER_READY",
+ "report_type": "release_readiness",
+ "capability": "compile_release_dossier",
+ "release_id": release_id,
+ "service": service,
+ "decision": "NO_GO",
+ "blocking_findings": [
+ "critical CVE-2026-41017 has no approved exception",
+ "contoso-auth must be upgraded from 3.7.1 to 3.7.3",
+ ],
+ "passed_gates": [
+ "all required pull requests merged with approvals",
+ "all required tests passed",
+ "change window and rollback staffing confirmed",
+ ],
+ "required_actions": [
+ "upgrade contoso-auth to 3.7.3",
+ "rerun vulnerability and regression suites",
+ "request a new go/no-go review",
+ ],
+ "specialist": specialist,
+ }
+
+
+__all__ = [
+ "compile_release_dossier",
+ "get_release_change_window",
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+]
diff --git a/samples/workflow-incident-triage/README.md b/samples/workflow-incident-triage/README.md
index ff559468..0c7635a9 100644
--- a/samples/workflow-incident-triage/README.md
+++ b/samples/workflow-incident-triage/README.md
@@ -150,7 +150,8 @@ Restart `func start` after any swap so the host reloads `host.json`.
`src/tools/incident_tools.py` defines four synthetic-but-realistic
handlers decorated with `@workflow_tool`. `create_function_app()`
discovers them from the normal `tools/` directory and registers them with
-the workflows engine when `main.agent.md` sets `workflows.enabled: true`.
+the app-wide workflow engine; this sample's workflow-enabled agent enables them
+in `main.agent.md`.
They are workflow-only tools because the sample does not also decorate
them with `@tool` and does not expose plain public normal-tool functions
from that module:
diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py
index 1c8b5386..359489aa 100644
--- a/src/azure_functions_agents/app.py
+++ b/src/azure_functions_agents/app.py
@@ -15,7 +15,7 @@
from .config.loader import load_agent_specs, load_global_config
from .config.merge import compose
from .config.paths import get_app_root, set_app_root
-from .config.schema import ResolvedAgent, WorkflowConfig
+from .config.schema import ResolvedAgent
from .config.validation import (
validate_resolved_agent,
validate_subagent_references,
@@ -28,7 +28,13 @@
from .registration.catalog import AgentCatalog, CatalogEntry, build_catalog
from .registration.endpoints import register_builtin_endpoints
from .registration.triggers import register_agent
-from .workflows import build_workflow_integration
+from .workflows.integration import (
+ build_workflow_agent_integration,
+ build_workflow_agent_policy_catalog,
+ build_workflow_handler_catalog,
+ register_workflow_runtime,
+ validate_workflow_agent_trigger,
+)
def _tool_name(tool: object) -> str:
@@ -58,10 +64,6 @@ def _builtin_endpoints_enabled(builtin_endpoints: Any) -> bool:
)
-def _workflows_requested(workflows: WorkflowConfig | None) -> bool:
- return workflows is not None and workflows.enabled
-
-
def _fail_on_duplicate_slugs(resolved_agents: list[ResolvedAgent]) -> set[str]:
"""Fail fast on colliding agent identity slugs and return the known-slug set.
@@ -154,16 +156,6 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp:
if resolved.workflows is not None:
referenced_slugs.update(ref.agent for ref in resolved.workflows.subagents)
- workflows_requested = any(
- resolved.is_main and _workflows_requested(resolved.workflows)
- for resolved in resolved_agents
- )
- app: func.FunctionApp = (
- df.DFApp(http_auth_level=func.AuthLevel.FUNCTION)
- if workflows_requested
- else func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
- )
-
# Collect indexing summary for structured logging
agents_summary: list[dict[str, Any]] = []
system_tools_used: set[str] = set()
@@ -192,6 +184,7 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp:
discovered_skills=skill_names,
is_referenced_as_subagent=resolved.slug in referenced_slugs,
)
+ validate_workflow_agent_trigger(resolved)
capabilities = build_capabilities(
resolved,
discovered_user_tools=user_tools,
@@ -203,37 +196,43 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp:
catalog_entries[resolved.slug] = CatalogEntry(resolved, capabilities)
catalog: AgentCatalog = build_catalog(catalog_entries)
+ workflow_handler_catalog = build_workflow_handler_catalog(workflow_tools)
+ workflow_agent_policies = build_workflow_agent_policy_catalog(
+ catalog,
+ workflow_handler_catalog,
+ )
+ app: func.FunctionApp = (
+ df.DFApp(http_auth_level=func.AuthLevel.FUNCTION)
+ if workflow_agent_policies
+ else func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
+ )
# --- Two-pass composition, pass 2 (FRD 0007 §4.2): mutate `app` --------------------
+ if workflow_agent_policies:
+ register_workflow_runtime(
+ app,
+ handler_catalog=workflow_handler_catalog,
+ catalog=catalog,
+ workflow_agent_policies=workflow_agent_policies,
+ )
+
for resolved in resolved_agents:
capabilities = catalog[resolved.slug].capabilities
workflows_enabled = False
workflow_system_addendum: str | None = None
trigger_workflow_system_addendum: str | None = None
- workflow_policy = None
- if resolved.is_main:
- workflow_integration = build_workflow_integration(
- app,
- resolved.metadata,
- workflow_tools=capabilities.filtered_workflow_tools,
- workflow_subagents=(
- resolved.workflows.subagents if resolved.workflows is not None else ()
- ),
- catalog=catalog,
+ workflow_policy = workflow_agent_policies.get(resolved.slug)
+ if workflow_policy is not None:
+ workflow_integration = build_workflow_agent_integration(
+ workflow_policy,
+ workflow_handler_catalog,
)
workflows_enabled = workflow_integration.enabled
workflow_system_addendum = workflow_integration.chat_system_addendum
trigger_workflow_system_addendum = (
workflow_integration.trigger_system_addendum
)
- workflow_policy = workflow_integration.plan_policy
- elif _workflows_requested(resolved.workflows):
- logger.warning(
- "workflows.enabled is only honored on main.agent.md; ignoring "
- "workflows for agent %s",
- resolved.name,
- )
capability_names = _serialize_capabilities_for_log(
user_tools=capabilities.filtered_user_tools,
diff --git a/src/azure_functions_agents/config/loader.py b/src/azure_functions_agents/config/loader.py
index 44866c85..a5d8542a 100644
--- a/src/azure_functions_agents/config/loader.py
+++ b/src/azure_functions_agents/config/loader.py
@@ -19,8 +19,6 @@
from azure_functions_agents.config.schema import AgentSpec, GlobalConfig
_FRONTMATTER_SCHEMA_LINK = "aka.ms/agents-front-matter-schema"
-
-
_FRONTMATTER_ACTION_ITEMS = (
"Fix YAML syntax between leading and trailing '---' delimiters.",
f"Validate required fields like `name`, `description`, and `trigger` against {_FRONTMATTER_SCHEMA_LINK}.",
diff --git a/src/azure_functions_agents/config/schema.py b/src/azure_functions_agents/config/schema.py
index 7f564f23..4fbe7346 100644
--- a/src/azure_functions_agents/config/schema.py
+++ b/src/azure_functions_agents/config/schema.py
@@ -160,11 +160,11 @@ class SubagentRef(_SubagentRefBase):
"""
class WorkflowSubagentRef(_SubagentRefBase):
- """A workflow owner's authorization grant for one leaf specialist."""
+ """A workflow-enabled agent's authorization grant for one leaf specialist."""
class WorkflowConfig(BaseModel):
- """Dynamic Workflow enablement and owner-specific capability grants."""
+ """Dynamic Workflow enablement and agent-specific capability grants."""
model_config = ConfigDict(extra="forbid", frozen=True)
diff --git a/src/azure_functions_agents/config/validation.py b/src/azure_functions_agents/config/validation.py
index d5d22743..74fcca67 100644
--- a/src/azure_functions_agents/config/validation.py
+++ b/src/azure_functions_agents/config/validation.py
@@ -91,7 +91,6 @@ def validate_resolved_agent(
"#trigger",
)
)
-
known_mcp = set(discovered_mcp_names)
for name in resolved.mcp_exclude_names:
if name not in known_mcp:
@@ -149,7 +148,7 @@ def validate_workflow_subagent_references(
*,
known_slugs: set[str],
) -> None:
- """Reject invalid owner-specific ``workflows.subagents`` grants."""
+ """Reject invalid agent-specific ``workflows.subagents`` grants."""
refs = resolved.workflows.subagents if resolved.workflows is not None else ()
_validate_references(
resolved,
diff --git a/src/azure_functions_agents/registration/_handlers.py b/src/azure_functions_agents/registration/_handlers.py
index 636b59a2..b1e0b308 100644
--- a/src/azure_functions_agents/registration/_handlers.py
+++ b/src/azure_functions_agents/registration/_handlers.py
@@ -288,6 +288,7 @@ async def _handle(trigger_data, durable_client: Any | None) -> None: # type: ig
system_addendum=workflow_system_addendum,
workflow_enabled=workflows_enabled,
workflow_durable_client=durable_client,
+ workflow_agent_slug=resolved.slug,
workflow_policy=workflow_policy,
agent_name=resolved.slug,
)
@@ -429,6 +430,7 @@ async def _handle(req: Request, durable_client: Any | None) -> Response:
system_addendum=workflow_system_addendum,
workflow_enabled=workflows_enabled,
workflow_durable_client=durable_client,
+ workflow_agent_slug=resolved.slug,
workflow_policy=workflow_policy,
agent_name=resolved.slug,
)
diff --git a/src/azure_functions_agents/registration/capabilities.py b/src/azure_functions_agents/registration/capabilities.py
index a69616fc..5c322b48 100644
--- a/src/azure_functions_agents/registration/capabilities.py
+++ b/src/azure_functions_agents/registration/capabilities.py
@@ -8,6 +8,7 @@
from typing import Any
from .._function_tool import WorkflowTool
+from .._logger import logger
from .._slug import delegate_tool_name
from ..config import ResolvedAgent
from ..discovery.mcp import MCPTool
@@ -79,6 +80,14 @@ def build_capabilities(
workflow_tools = list(discovered_workflow_tools or [])
if _workflows_enabled(resolved):
workflow_exclude_names = _workflow_exclude_names(resolved)
+ known_workflow_names = {tool.name for tool in workflow_tools}
+ unknown_workflow_names = workflow_exclude_names - known_workflow_names
+ if unknown_workflow_names:
+ logger.warning(
+ "%s: workflows.exclude contains unknown workflow tool name(s): %s",
+ resolved.source_file or "",
+ sorted(unknown_workflow_names),
+ )
filtered_workflow_tools = [
tool for tool in workflow_tools if tool.name not in workflow_exclude_names
]
diff --git a/src/azure_functions_agents/registration/endpoints.py b/src/azure_functions_agents/registration/endpoints.py
index c5d1a425..86162260 100644
--- a/src/azure_functions_agents/registration/endpoints.py
+++ b/src/azure_functions_agents/registration/endpoints.py
@@ -173,6 +173,7 @@ async def _run_builtin_agent(
system_addendum=workflow_system_addendum,
workflow_enabled=workflows_enabled,
workflow_durable_client=durable_client,
+ workflow_agent_slug=resolved.slug,
workflow_policy=workflow_policy,
agent_name=resolved.slug,
subagents=resolved.subagents,
@@ -208,6 +209,7 @@ def _run_builtin_agent_stream(
system_addendum=workflow_system_addendum,
workflow_enabled=workflows_enabled,
workflow_durable_client=durable_client,
+ workflow_agent_slug=resolved.slug,
workflow_policy=workflow_policy,
agent_name=resolved.slug,
# S1b: `_register_http_chat_stream`'s `handle_chat_stream` (unlike
@@ -531,6 +533,7 @@ def _register_workflow_status_endpoints(
app: func.FunctionApp,
*,
slug: str,
+ workflow_agent_slug: str,
base_function_name: str,
auth: EndpointAuthConfig,
) -> None:
@@ -552,9 +555,14 @@ async def list_session_workflows(req: Request, client: str) -> Response:
media_type="application/json",
)
try:
- envelopes = await fetch_session_workflows(client, session_id)
+ envelopes = await fetch_session_workflows(
+ client, workflow_agent_slug, session_id
+ )
except Exception:
- logger.exception("workflows list endpoint failed")
+ logger.exception(
+ "workflows list endpoint failed workflow_agent=%s",
+ workflow_agent_slug,
+ )
return Response(
json.dumps({"error": "failed to list workflows"}),
status_code=500,
@@ -586,9 +594,17 @@ async def get_session_workflow_status(req: Request, client: str) -> Response:
media_type="application/json",
)
try:
- envelope = await fetch_session_workflow_status(client, session_id, workflow_id)
+ envelope = await fetch_session_workflow_status(
+ client,
+ workflow_agent_slug,
+ session_id,
+ workflow_id,
+ )
except Exception:
- logger.exception("workflow status endpoint failed")
+ logger.exception(
+ "workflow status endpoint failed workflow_agent=%s",
+ workflow_agent_slug,
+ )
return Response(
json.dumps({"error": "failed to fetch workflow status"}),
status_code=500,
@@ -758,6 +774,7 @@ def register_builtin_endpoints(
_register_workflow_status_endpoints(
app,
slug=slug,
+ workflow_agent_slug=resolved.slug,
base_function_name=base_function_name,
auth=auth,
)
diff --git a/src/azure_functions_agents/runner.py b/src/azure_functions_agents/runner.py
index 28836810..81b1332c 100644
--- a/src/azure_functions_agents/runner.py
+++ b/src/azure_functions_agents/runner.py
@@ -408,6 +408,7 @@ def _build_role_agent(
system_addendum: str | None,
workflow_enabled: bool,
workflow_durable_client: Any | None,
+ workflow_agent_slug: str | None = None,
agent_name: str | None,
resolved_id: str | None,
history_provider: ContextProvider | None,
@@ -444,6 +445,7 @@ def _build_role_agent(
resolved_tools.extend(
build_workflow_tools(
session_id=resolved_id or "",
+ workflow_agent_slug=workflow_agent_slug or agent_name or "main",
agent_name=agent_name or "main",
durable_client=workflow_durable_client,
policy=workflow_policy,
@@ -506,6 +508,7 @@ def _build_delegated_agent(
system_addendum=None,
workflow_enabled=False,
workflow_durable_client=None,
+ workflow_agent_slug=None,
# The slug, not `resolved.name` (the display name) — this becomes
# the MAF span's `gen_ai.agent.name`, matching the `delegate_`
# tool name so a trace viewer can correlate the two directly.
@@ -779,6 +782,7 @@ async def _build_agent_session_history(
system_addendum: str | None,
workflow_enabled: bool,
workflow_durable_client: Any | None,
+ workflow_agent_slug: str | None,
agent_name: str | None,
web_request_tools: list[Any] | None = None,
subagents: list[SubagentRef] | None = None,
@@ -837,6 +841,7 @@ async def _build_agent_session_history(
system_addendum=system_addendum,
workflow_enabled=workflow_enabled,
workflow_durable_client=workflow_durable_client,
+ workflow_agent_slug=workflow_agent_slug,
agent_name=agent_name,
resolved_id=resolved_id,
history_provider=history_provider,
@@ -923,6 +928,7 @@ async def run_agent(
system_addendum: str | None = None,
workflow_enabled: bool = False,
workflow_durable_client: Any | None = None,
+ workflow_agent_slug: str | None = None,
agent_name: str | None = None,
web_request_tools: list[Any] | None = None,
subagents: list[SubagentRef] | None = None,
@@ -1007,6 +1013,7 @@ async def run_agent(
system_addendum=system_addendum,
workflow_enabled=workflow_enabled,
workflow_durable_client=workflow_durable_client,
+ workflow_agent_slug=workflow_agent_slug,
agent_name=agent_name,
web_request_tools=web_request_tools,
subagents=subagents,
@@ -1115,6 +1122,7 @@ async def run_agent_stream(
system_addendum: str | None = None,
workflow_enabled: bool = False,
workflow_durable_client: Any | None = None,
+ workflow_agent_slug: str | None = None,
agent_name: str | None = None,
display_name: str | None = None,
web_request_tools: list[Any] | None = None,
@@ -1182,6 +1190,7 @@ async def run_agent_stream(
system_addendum=system_addendum,
workflow_enabled=workflow_enabled,
workflow_durable_client=workflow_durable_client,
+ workflow_agent_slug=workflow_agent_slug,
agent_name=agent_name,
web_request_tools=web_request_tools,
subagents=subagents,
diff --git a/src/azure_functions_agents/workflows/context.py b/src/azure_functions_agents/workflows/context.py
index 81cc9332..ff1f2490 100644
--- a/src/azure_functions_agents/workflows/context.py
+++ b/src/azure_functions_agents/workflows/context.py
@@ -1,22 +1,17 @@
-"""Per-session workflow context registry + instance-ID ownership scheme.
+"""Per-workflow-agent-session context registry and instance-ID isolation scheme.
Two concerns live here:
-1. **Per-turn registry.** Workflow tool handlers need the Durable
- Functions ``client`` the Functions host injected into the chat
- handler via ``durable_client_input`` and the owning agent name.
- The current MAF integration builds workflow tools per agent session, so this
- registry is retained only for tests and backwards-compatible helper access.
- Concurrent turns on the same ``session_id`` are possible; to keep a
- late-arriving turn from evicting a newer registration,
- :func:`register_workflow_session` returns an opaque token and
- :func:`unregister_workflow_session` is a no-op unless that token
- still owns the slot.
-
-2. **Instance-ID ownership.** Every workflow started via
+1. **Compatibility registry.** Production workflow tools receive a request-local
+ context directly. The process-local registry remains for callers of the
+ original helper API. Its registration token is private bookkeeping and is
+ not part of workflow session state.
+
+2. **Instance-ID isolation.** Every workflow started via
``start_workflow`` receives an instance ID whose leading
- :data:`SESSION_PREFIX_LEN` hex characters are ``sha256(session_id)``.
- Ownership is enforced by prefix match on the workflow ID, which is
+ :data:`AGENT_SESSION_PREFIX_LEN` hex characters are a SHA-256 prefix
+ over the workflow agent slug and session ID.
+ Isolation is enforced by prefix match on the workflow ID, which is
stable across Durable's lifecycle and does not depend on the
orchestration input being preserved post-completion. Hashing keeps
the raw ``session_id`` out of Durable-visible metadata (defense in
@@ -29,57 +24,76 @@
import uuid
from dataclasses import dataclass
from threading import Lock
-from typing import Any
-SESSION_PREFIX_LEN = 12
+from azure.durable_functions import DurableOrchestrationClient
+
+AGENT_SESSION_PREFIX_LEN = 32
+# Compatibility alias retained for callers that imported the original constant.
+# Its value follows the current agent/session format, not the legacy 12-hex format.
+SESSION_PREFIX_LEN = AGENT_SESSION_PREFIX_LEN
-def session_instance_prefix(session_id: str) -> str:
- """Return the fixed-length hash prefix embedded in every workflow ID
- started by ``session_id``.
+def session_instance_prefix(workflow_agent_slug: str, session_id: str) -> str:
+ """Return the fixed-length workflow-agent/session prefix embedded in workflow IDs.
- Workflow ownership is enforced by comparing this prefix against the
+ Workflow isolation is enforced by comparing this prefix against the
Durable instance_id: any workflow whose ID does not start with the
- calling session's prefix is treated as nonexistent for that session.
+ calling workflow-agent/session prefix is treated as nonexistent.
Hashing keeps the raw ``session_id`` out of Durable-visible metadata.
"""
- return hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:SESSION_PREFIX_LEN]
+ digest = hashlib.sha256()
+ for value in (workflow_agent_slug, session_id):
+ encoded = value.encode("utf-8")
+ digest.update(len(encoded).to_bytes(8, byteorder="big"))
+ digest.update(encoded)
+ return digest.hexdigest()[:AGENT_SESSION_PREFIX_LEN]
-def new_workflow_instance_id(session_id: str) -> str:
- """Generate a fresh workflow instance ID for ``session_id``.
+def new_workflow_instance_id(workflow_agent_slug: str, session_id: str) -> str:
+ """Generate a fresh workflow instance ID for a workflow-agent/session pair.
- Shape: ``{12-hex-session-hash}-{32-hex-uuid}``. The leading prefix is
- reproducible (same session → same prefix) and is used for ownership
- checks; the uuid suffix keeps each workflow unique.
+ Shape: ``{32-hex-agent-session-hash}-{32-hex-uuid}``.
"""
- return f"{session_instance_prefix(session_id)}-{uuid.uuid4().hex}"
+ return f"{session_instance_prefix(workflow_agent_slug, session_id)}-{uuid.uuid4().hex}"
-def session_owns_workflow(session_id: str, workflow_id: str) -> bool:
- if not session_id or not workflow_id:
+def workflow_matches_agent_session(
+ workflow_agent_slug: str,
+ session_id: str,
+ workflow_id: str,
+) -> bool:
+ if not workflow_agent_slug or not session_id or not workflow_id:
return False
- return workflow_id.startswith(session_instance_prefix(session_id) + "-")
+ return workflow_id.startswith(
+ session_instance_prefix(workflow_agent_slug, session_id) + "-"
+ )
@dataclass(frozen=True)
class WorkflowSessionContext:
"""Per-in-flight-request state needed by workflow tools."""
+ workflow_agent_slug: str
session_id: str
agent_name: str
- durable_client: Any # azure.durable_functions.DurableOrchestrationClient
+ durable_client: DurableOrchestrationClient
+
+
+@dataclass(frozen=True)
+class _WorkflowSessionRegistration:
+ context: WorkflowSessionContext
token: str
-_registry: dict[str, WorkflowSessionContext] = {}
+_registry: dict[tuple[str, str], _WorkflowSessionRegistration] = {}
_lock = Lock()
def register_workflow_session(
+ workflow_agent_slug: str,
session_id: str,
agent_name: str,
- durable_client: Any,
+ durable_client: DurableOrchestrationClient,
) -> str:
"""Register the per-session context for the duration of a chat turn.
@@ -87,42 +101,56 @@ def register_workflow_session(
:func:`unregister_workflow_session` in its ``finally`` block.
"""
token = uuid.uuid4().hex
+ context = WorkflowSessionContext(
+ workflow_agent_slug=workflow_agent_slug,
+ session_id=session_id,
+ agent_name=agent_name,
+ durable_client=durable_client,
+ )
with _lock:
- _registry[session_id] = WorkflowSessionContext(
- session_id=session_id,
- agent_name=agent_name,
- durable_client=durable_client,
+ _registry[(workflow_agent_slug, session_id)] = _WorkflowSessionRegistration(
+ context=context,
token=token,
)
return token
-def unregister_workflow_session(session_id: str, token: str) -> None:
+def unregister_workflow_session(
+ workflow_agent_slug: str,
+ session_id: str,
+ token: str,
+) -> None:
"""Remove the row for ``session_id``, but only if ``token`` still owns it.
Safe to call multiple times and safe to call when a later turn has
already replaced our slot — in both cases this is a no-op.
"""
with _lock:
- existing = _registry.get(session_id)
+ key = (workflow_agent_slug, session_id)
+ existing = _registry.get(key)
if existing is not None and existing.token == token:
- _registry.pop(session_id, None)
+ _registry.pop(key, None)
-def get_workflow_session(session_id: str | None) -> WorkflowSessionContext | None:
- if not session_id:
+def get_workflow_session(
+ workflow_agent_slug: str | None,
+ session_id: str | None,
+) -> WorkflowSessionContext | None:
+ if not workflow_agent_slug or not session_id:
return None
with _lock:
- return _registry.get(session_id)
+ registration = _registry.get((workflow_agent_slug, session_id))
+ return registration.context if registration is not None else None
__all__ = [
+ "AGENT_SESSION_PREFIX_LEN",
"SESSION_PREFIX_LEN",
"WorkflowSessionContext",
"get_workflow_session",
"new_workflow_instance_id",
"register_workflow_session",
"session_instance_prefix",
- "session_owns_workflow",
"unregister_workflow_session",
+ "workflow_matches_agent_session",
]
diff --git a/src/azure_functions_agents/workflows/engine.py b/src/azure_functions_agents/workflows/engine.py
index 04d3d063..33705ab7 100644
--- a/src/azure_functions_agents/workflows/engine.py
+++ b/src/azure_functions_agents/workflows/engine.py
@@ -15,15 +15,15 @@
envelope (see :mod:`.tools`) translates that to ``runtime_status="Canceled"``
when the orchestrator's output indicates cancellation.
-What is intentionally still *not* here: retries / per-task timeouts and
-a per-agent workflow-safe tool registry (M3).
+What is intentionally still *not* here: retries and per-task timeouts.
"""
from __future__ import annotations
import asyncio
import json
-from typing import Any
+from collections.abc import Mapping
+from typing import Any, TypedDict
import azure.durable_functions as df
import azure.functions as func
@@ -41,6 +41,7 @@
TOOL_TASK_TYPE,
WAIT_TASK_TYPE,
TemplateResolutionError,
+ WorkflowPlanPolicy,
parse_iso8601_datetime,
parse_iso8601_duration,
resolve_template_value,
@@ -54,6 +55,25 @@
WORKFLOW_SAFE_ECHO_TOOL = ECHO_TOOL_NAME
+class _ActivityInputBase(TypedDict):
+ id: str
+ workflow_agent_slug: str
+ workflow_id: str
+
+
+class _ToolActivityInput(_ActivityInputBase):
+ tool: str
+ args: dict[str, Any]
+
+
+class _SubAgentActivityInput(_ActivityInputBase):
+ agent: str
+ task: str
+
+
+type _ActivityInput = _ToolActivityInput | _SubAgentActivityInput
+
+
def _run_echo(args: dict[str, Any]) -> dict[str, Any]:
"""Trivial workflow-safe tool used by unit tests.
@@ -104,6 +124,8 @@ def register_workflows(
app: func.FunctionApp,
*,
catalog: AgentCatalog | None = None,
+ handler_catalog: registry.WorkflowHandlerCatalog | None = None,
+ workflow_agent_policies: Mapping[str, WorkflowPlanPolicy] | None = None,
) -> None:
"""Register the workflow orchestrator + activities on ``app``.
@@ -113,23 +135,75 @@ def register_workflows(
"""
bp = df.Blueprint()
+ def require_workflow_agent_policy(
+ task: _ActivityInput,
+ ) -> tuple[str, WorkflowPlanPolicy]:
+ workflow_agent_slug = task["workflow_agent_slug"]
+ policy = (
+ workflow_agent_policies.get(workflow_agent_slug)
+ if workflow_agent_policies is not None
+ else None
+ )
+ if not workflow_agent_slug or policy is None:
+ logger.error(
+ "workflow activity agent policy miss: "
+ "workflow_id=%s node_id=%s workflow_agent=%s",
+ task["workflow_id"],
+ task["id"],
+ workflow_agent_slug or "",
+ )
+ raise RuntimeError(
+ f"task {task['id']!r}: workflow agent policy is not available"
+ )
+ return workflow_agent_slug, policy
+
@bp.activity_trigger(input_name="task") # type: ignore[untyped-decorator]
- def agents_workflow_run_tool(task) -> dict[str, Any]: # type: ignore[no-untyped-def]
+ def agents_workflow_run_tool(task: _ToolActivityInput) -> dict[str, Any]:
task_id = task["id"]
tool_name = task["tool"]
- args = task.get("args") or {}
- handler = registry.get_handler(tool_name)
- if handler is None:
+ args = task["args"]
+ workflow_agent_slug, policy = require_workflow_agent_policy(task)
+ workflow_id = task["workflow_id"]
+ if tool_name not in policy.allowed_tools:
+ logger.error(
+ "workflow tool authorization denied: "
+ "workflow_id=%s node_id=%s workflow_agent=%s tool=%s",
+ workflow_id,
+ task_id,
+ workflow_agent_slug,
+ tool_name,
+ )
+ raise RuntimeError(
+ f"task {task_id!r}: workflow tool {tool_name!r} is not authorized"
+ )
+ entry = (
+ handler_catalog.get(tool_name)
+ if handler_catalog is not None
+ else registry.get_entry(tool_name)
+ )
+ if entry is None:
raise ValueError(
f"task {task_id!r}: tool {tool_name!r} is not registered "
"in the workflow-safe tool registry"
)
- logger.info("workflow activity running: id=%s tool=%s", task_id, tool_name)
+ logger.info(
+ "workflow activity running: "
+ "workflow_id=%s workflow_agent=%s id=%s tool=%s",
+ workflow_id,
+ workflow_agent_slug,
+ task_id,
+ tool_name,
+ )
try:
- result = handler(args)
+ result = entry.handler(args)
except Exception:
logger.exception(
- "workflow activity failed: id=%s tool=%s", task_id, tool_name
+ "workflow activity failed: "
+ "workflow_id=%s workflow_agent=%s id=%s tool=%s",
+ workflow_id,
+ workflow_agent_slug,
+ task_id,
+ tool_name,
)
raise RuntimeError(
f"task {task_id!r}: workflow-safe tool failed"
@@ -142,15 +216,32 @@ def agents_workflow_run_tool(task) -> dict[str, Any]: # type: ignore[no-untyped
return {"id": task_id, "result": result}
@bp.activity_trigger(input_name="task") # type: ignore[untyped-decorator]
- async def agents_workflow_run_sub_agent(task) -> dict[str, Any]: # type: ignore[no-untyped-def]
- task_id = str(task["id"])
- agent_slug = str(task["agent"])
- workflow_id = str(task.get("workflow_id") or "")
+ async def agents_workflow_run_sub_agent(
+ task: _SubAgentActivityInput,
+ ) -> dict[str, Any]:
+ task_id = task["id"]
+ agent_slug = task["agent"]
+ workflow_id = task["workflow_id"]
+ workflow_agent_slug, policy = require_workflow_agent_policy(task)
+ if agent_slug not in policy.allowed_subagents:
+ logger.error(
+ "workflow sub-agent authorization denied: "
+ "workflow_id=%s node_id=%s workflow_agent=%s agent=%s",
+ workflow_id,
+ task_id,
+ workflow_agent_slug,
+ agent_slug,
+ )
+ raise RuntimeError(
+ f"task {task_id!r}: Workflow Sub Agent {agent_slug!r} is not authorized"
+ )
if catalog is None or agent_slug not in catalog:
logger.error(
- "workflow sub-agent catalog miss: workflow_id=%s node_id=%s agent=%s",
+ "workflow sub-agent catalog miss: "
+ "workflow_id=%s node_id=%s workflow_agent=%s agent=%s",
workflow_id,
task_id,
+ workflow_agent_slug,
agent_slug,
)
raise RuntimeError(
@@ -159,16 +250,18 @@ async def agents_workflow_run_sub_agent(task) -> dict[str, Any]: # type: ignore
entry = catalog[agent_slug]
logger.info(
- "workflow sub-agent activity running: workflow_id=%s node_id=%s agent=%s",
+ "workflow sub-agent activity running: "
+ "workflow_id=%s node_id=%s workflow_agent=%s agent=%s",
workflow_id,
task_id,
+ workflow_agent_slug,
agent_slug,
)
try:
text = await run_leaf_agent_task(
entry.resolved,
entry.capabilities,
- str(task["task"]),
+ task["task"],
timeout=entry.resolved.timeout,
execution_role="workflow_subagent",
)
@@ -227,6 +320,7 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any
"""
payload: dict[str, Any] = context.get_input() or {}
tasks: list[dict[str, Any]] = list(payload.get("tasks") or [])
+ workflow_agent_slug = str(payload.get("workflow_agent_slug") or "")
by_id: dict[str, dict[str, Any]] = {t["id"]: t for t in tasks}
deps: dict[str, set[str]] = {
@@ -283,6 +377,8 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any
"id": tid,
"tool": task["tool"],
"args": resolved_args,
+ "workflow_agent_slug": workflow_agent_slug,
+ "workflow_id": context.instance_id,
},
)
)
@@ -306,6 +402,7 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any
"agent": task["agent"],
"task": resolved_task,
"workflow_id": context.instance_id,
+ "workflow_agent_slug": workflow_agent_slug,
},
)
)
@@ -344,8 +441,9 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any
f"canceled at {len(results)}/{total} tasks done"
)
logger.info(
- "workflow canceled: instance=%s reason=%r",
+ "workflow canceled: instance=%s workflow_agent=%s reason=%r",
context.instance_id,
+ workflow_agent_slug,
reason,
)
return {
@@ -357,6 +455,11 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any
}
wave_results = wave_task.result
+ if isinstance(wave_results, BaseException):
+ for spec, task in zip(wave_specs, wave_tasks, strict=True):
+ if spec["type"] == WAIT_TASK_TYPE and not task.is_completed:
+ task.cancel()
+ raise wave_results
for spec, raw in zip(wave_specs, wave_results, strict=True):
tid = spec["id"]
if spec["type"] in {TOOL_TASK_TYPE, SUB_AGENT_TASK_TYPE}:
diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py
index 42f9497f..afc6e5be 100644
--- a/src/azure_functions_agents/workflows/integration.py
+++ b/src/azure_functions_agents/workflows/integration.py
@@ -8,25 +8,29 @@
describe short-lived starter behavior and terminal result sinks. Both cover
when to reach for a workflow and which tools the workflow can call.
-``build_workflow_integration`` is the one call the app factory makes
-to turn on workflows for the main agent: it registers the Durable
-engine on the app, registers the discovered ``@workflow_tool`` inventory,
-applies the optional ``workflows.exclude`` filter, stashes the effective
-tool set on the workflows registry for ``start_workflow`` to read, and
-returns the management tools plus both channel addenda.
+The app factory builds one complete handler catalog and one immutable
+workflow-agent-policy catalog, registers the Durable engine once, then builds each
+workflow-enabled agent's tools and addenda without mutating the app.
+``build_workflow_integration``
+retains the original direct-helper behavior for compatibility tests and callers.
"""
from __future__ import annotations
-from collections.abc import Iterator, Sequence
+from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any
import azure.functions as func
from azure_functions_agents._function_tool import WorkflowTool
from azure_functions_agents._logger import logger
-from azure_functions_agents.config.schema import WorkflowSubagentRef
+from azure_functions_agents.config.schema import (
+ TRIGGER_TYPES,
+ ResolvedAgent,
+ WorkflowSubagentRef,
+)
from azure_functions_agents.registration.catalog import AgentCatalog
from . import registry
@@ -34,6 +38,8 @@
from .schema import WorkflowPlanPolicy
from .tools import build_workflow_tools
+type WorkflowAgentPolicyCatalog = Mapping[str, WorkflowPlanPolicy]
+
# Whitelist of frontmatter keys we recognize under ``workflows``. Any
# other key is rejected at app start so typos (``enabld``, ``allow_tools``)
# surface immediately rather than silently degrading to defaults. New
@@ -70,13 +76,7 @@
"requires raw data; summarize the useful signal inside the workflow.\n\n"
)
-_CHAT_ADDENDUM = (
- "`start_workflow` is fire-and-forget. It returns a `workflow_id` immediately "
- "and the orchestration runs in the background. After it returns, briefly "
- "tell the user that work is in flight (include the `workflow_id`) and end "
- "your turn — **do not call `get_workflow_status` to wait for completion.** "
- "The chat client renders live per-task progress next to the conversation "
- "and will notify you when the workflow reaches a terminal state.\n\n"
+_CHAT_NOTIFICATION_ADDENDUM = (
"When a workflow you started reaches a terminal state, the chat client "
"injects a synthetic user message containing one or more "
"`` envelopes — one per finished workflow. Each "
@@ -99,7 +99,18 @@
"exists. If `get_workflow_status` happens to return a non-terminal "
"status (a brief race between the chat client and the management "
"API), tell the user the detailed result isn't available yet and end "
- "the turn — do not poll again.\n\n"
+ "the turn — do not poll again."
+)
+
+_CHAT_ADDENDUM = (
+ "`start_workflow` is fire-and-forget. It returns a `workflow_id` immediately "
+ "and the orchestration runs in the background. After it returns, briefly "
+ "tell the user that work is in flight (include the `workflow_id`) and end "
+ "your turn — **do not call `get_workflow_status` to wait for completion.** "
+ "The chat client renders live per-task progress next to the conversation "
+ "and will notify you when the workflow reaches a terminal state.\n\n"
+ + _CHAT_NOTIFICATION_ADDENDUM
+ + "\n\n"
"Outside of `` turns, only call "
"`get_workflow_status` "
"(or `list_workflows`) when the user explicitly asks about a previously-"
@@ -129,7 +140,6 @@
"response format permits it."
)
-
@dataclass(frozen=True)
class WorkflowIntegrationResult:
"""Workflow registration output for each invocation channel.
@@ -255,8 +265,11 @@ def _workflows_enabled(metadata: dict[str, Any]) -> bool:
return bool(block.get("enabled", False))
-def _register_workflow_tools(workflow_tools: Sequence[WorkflowTool]) -> frozenset[str]:
- effective: set[str] = set()
+def build_workflow_handler_catalog(
+ workflow_tools: Sequence[WorkflowTool],
+) -> registry.WorkflowHandlerCatalog:
+ """Build the immutable, unfiltered app-wide workflow handler catalog."""
+ entries: dict[str, registry.WorkflowToolEntry] = {}
for workflow_tool in workflow_tools:
if workflow_tool.handler is None:
logger.warning(
@@ -265,7 +278,7 @@ def _register_workflow_tools(workflow_tools: Sequence[WorkflowTool]) -> frozense
)
continue
try:
- registry.register_workflow_tool(
+ entry = registry.make_workflow_tool_entry(
workflow_tool.name,
workflow_tool.description,
workflow_tool.handler,
@@ -274,8 +287,35 @@ def _register_workflow_tools(workflow_tools: Sequence[WorkflowTool]) -> frozense
except ValueError as exc:
logger.warning("Skipping workflow tool %r: %s", workflow_tool.name, exc)
continue
- if workflow_tool.public:
- effective.add(workflow_tool.name)
+ if entry.name in entries:
+ logger.warning(
+ "Skipping workflow tool %r: workflow tool is already discovered",
+ entry.name,
+ )
+ continue
+ entries[entry.name] = entry
+ return registry.build_handler_catalog(entries)
+
+
+def _register_workflow_tools(
+ workflow_tools: Sequence[WorkflowTool],
+) -> frozenset[str]:
+ """Compatibility-only registration for direct helper callers."""
+ catalog = build_workflow_handler_catalog(workflow_tools)
+ effective: set[str] = set()
+ for entry in catalog.values():
+ try:
+ registry.register_workflow_tool(
+ entry.name,
+ entry.description,
+ entry.handler,
+ public=entry.public,
+ )
+ except ValueError as exc:
+ logger.warning("Skipping workflow tool %r: %s", entry.name, exc)
+ continue
+ if entry.public:
+ effective.add(entry.name)
return frozenset(effective)
@@ -296,7 +336,10 @@ def _apply_workflow_exclude(
return tuple(tool for tool in workflow_tools if tool.name not in excluded)
-def _build_tool_section(allowed_tools: frozenset[str]) -> str:
+def _build_tool_section(
+ allowed_tools: frozenset[str],
+ handler_catalog: registry.WorkflowHandlerCatalog | None = None,
+) -> str:
"""Return the dynamic workflow-tool section shared by both channels.
Lists each allowed tool's name and engine-owned description. This is the
@@ -318,7 +361,11 @@ def _build_tool_section(allowed_tools: frozenset[str]) -> str:
else:
lines = ["\n\n### Available workflow tools\n"]
for name in sorted(allowed_tools):
- entry = registry.get_entry(name)
+ entry = (
+ handler_catalog.get(name)
+ if handler_catalog is not None
+ else registry.get_entry(name)
+ )
description = entry.description if entry is not None else ""
lines.append(f"- `{name}` — {description}")
tool_section = "\n".join(lines)
@@ -345,12 +392,17 @@ def _build_subagent_section(policy: WorkflowPlanPolicy) -> str:
return "\n".join(lines)
-def _build_addendum(policy: WorkflowPlanPolicy, *, trigger_invocation: bool) -> str:
+def _build_addendum(
+ policy: WorkflowPlanPolicy,
+ *,
+ trigger_invocation: bool,
+ handler_catalog: registry.WorkflowHandlerCatalog | None = None,
+) -> str:
channel_addendum = _TRIGGER_ADDENDUM if trigger_invocation else _CHAT_ADDENDUM
return (
_SHARED_ADDENDUM
+ channel_addendum
- + _build_tool_section(policy.allowed_tools)
+ + _build_tool_section(policy.allowed_tools, handler_catalog)
+ _build_subagent_section(policy)
)
@@ -376,6 +428,86 @@ def _build_plan_policy(
)
+def validate_workflow_agent_trigger(resolved: ResolvedAgent) -> None:
+ """Reject unsupported declared triggers for a workflow-enabled agent."""
+ if (
+ resolved.workflows is None
+ or not resolved.workflows.enabled
+ or resolved.trigger is None
+ ):
+ return
+ trigger_type = str(resolved.trigger.type or "").strip()
+ if trigger_type not in TRIGGER_TYPES:
+ raise ValueError(
+ f"{resolved.source_file or ''}: field `trigger.type`: "
+ f"Unknown or unsupported trigger type `{trigger_type}`. "
+ "See docs/front-matter-spec.md#trigger."
+ )
+
+
+def build_workflow_agent_policy_catalog(
+ catalog: AgentCatalog,
+ handler_catalog: registry.WorkflowHandlerCatalog,
+) -> WorkflowAgentPolicyCatalog:
+ """Freeze one independent workflow policy per workflow-enabled agent."""
+ policies: dict[str, WorkflowPlanPolicy] = {}
+ for workflow_agent_slug, entry in catalog.items():
+ resolved = entry.resolved
+ if resolved.workflows is None or not resolved.workflows.enabled:
+ continue
+ allowed_tools = frozenset(
+ tool.name
+ for tool in entry.capabilities.filtered_workflow_tools
+ if (
+ (handler := handler_catalog.get(tool.name)) is not None
+ and handler.public
+ )
+ )
+ policies[workflow_agent_slug] = _build_plan_policy(
+ allowed_tools,
+ resolved.workflows.subagents,
+ catalog,
+ )
+ return MappingProxyType(policies)
+
+
+def build_workflow_agent_integration(
+ policy: WorkflowPlanPolicy,
+ handler_catalog: registry.WorkflowHandlerCatalog,
+) -> WorkflowIntegrationResult:
+ """Build one workflow-enabled agent's tools and prompt guidance without app mutation."""
+ return WorkflowIntegrationResult(
+ workflow_tools=build_workflow_tools(policy=policy),
+ chat_system_addendum=_build_addendum(
+ policy,
+ trigger_invocation=False,
+ handler_catalog=handler_catalog,
+ ),
+ trigger_system_addendum=_build_addendum(
+ policy,
+ trigger_invocation=True,
+ handler_catalog=handler_catalog,
+ ),
+ plan_policy=policy,
+ )
+
+
+def register_workflow_runtime(
+ app: func.FunctionApp,
+ *,
+ handler_catalog: registry.WorkflowHandlerCatalog,
+ catalog: AgentCatalog,
+ workflow_agent_policies: WorkflowAgentPolicyCatalog,
+) -> None:
+ """Register the app-wide Durable engine exactly once."""
+ register_workflows(
+ app,
+ catalog=catalog,
+ handler_catalog=handler_catalog,
+ workflow_agent_policies=workflow_agent_policies,
+ )
+
+
def build_workflow_integration(
app: func.FunctionApp,
metadata: dict[str, Any],
@@ -384,7 +516,7 @@ def build_workflow_integration(
workflow_subagents: Sequence[WorkflowSubagentRef] = (),
catalog: AgentCatalog | None = None,
) -> WorkflowIntegrationResult:
- """Enable workflows for the app if the main agent opted in.
+ """Compatibility helper that enables one agent's workflows on ``app``.
Returns a :class:`WorkflowIntegrationResult` containing management tools
plus chat and declared-trigger system addenda. The tools are empty and both
@@ -403,10 +535,16 @@ def build_workflow_integration(
# so this function is safe to call multiple times in test
# scenarios that toggle metadata.
return WorkflowIntegrationResult([], None, None, None)
- register_workflows(app, catalog=catalog)
filtered_workflow_tools = _apply_workflow_exclude(tuple(workflow_tools or ()), metadata)
+ handler_catalog = build_workflow_handler_catalog(filtered_workflow_tools)
effective = _register_workflow_tools(filtered_workflow_tools)
policy = _build_plan_policy(effective, workflow_subagents, catalog)
+ register_workflows(
+ app,
+ catalog=catalog,
+ handler_catalog=handler_catalog,
+ workflow_agent_policies=MappingProxyType({"main": policy}),
+ )
registry.set_app_config(effective)
logger.info(
"workflows enabled: %d tool(s) and %d Sub Agent(s) allowed (%s)",
@@ -414,15 +552,16 @@ def build_workflow_integration(
len(policy.allowed_subagents),
", ".join(sorted(effective)) or "",
)
- return WorkflowIntegrationResult(
- workflow_tools=build_workflow_tools(policy=policy),
- chat_system_addendum=_build_addendum(policy, trigger_invocation=False),
- trigger_system_addendum=_build_addendum(policy, trigger_invocation=True),
- plan_policy=policy,
- )
+ return build_workflow_agent_integration(policy, handler_catalog)
__all__ = [
+ "WorkflowAgentPolicyCatalog",
"WorkflowIntegrationResult",
+ "build_workflow_agent_integration",
+ "build_workflow_agent_policy_catalog",
+ "build_workflow_handler_catalog",
"build_workflow_integration",
+ "register_workflow_runtime",
+ "validate_workflow_agent_trigger",
]
diff --git a/src/azure_functions_agents/workflows/registry.py b/src/azure_functions_agents/workflows/registry.py
index 7b6d9b84..92ed3fca 100644
--- a/src/azure_functions_agents/workflows/registry.py
+++ b/src/azure_functions_agents/workflows/registry.py
@@ -13,10 +13,8 @@
- **Public**: included in the effective workflow tool set unless filtered.
Internal helpers like ``__echo`` are registered with ``public=False`` so
they don't leak into agent-visible plans by accident.
-- **Effective allowlist**: the set the running app actually permits in
- plans. Computed by :mod:`.integration` per-app and stashed via
- :func:`set_app_config`; read by ``start_workflow`` when validating
- a plan.
+- **Effective allowlist**: retained only as a compatibility fallback for direct
+ helper callers. Production app construction passes an explicit agent policy.
Reserved names (the LLM-facing workflow-management tools themselves)
can never be registered — workflow nodes must never reach back into
@@ -26,8 +24,9 @@
from __future__ import annotations
import inspect
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Any
@@ -49,6 +48,9 @@ class WorkflowToolEntry:
public: bool
+type WorkflowHandlerCatalog = Mapping[str, WorkflowToolEntry]
+
+
# Names of LLM-facing workflow-management tools — these can never be
# workflow node targets. Kept here (not in tools.py) to avoid pulling
# the agent-facing workflow tool layer into modules that don't otherwise need it.
@@ -67,20 +69,14 @@ class WorkflowToolEntry:
_APP_ALLOWLIST: frozenset[str] | None = None
-def register_workflow_tool(
+def make_workflow_tool_entry(
name: str,
description: str,
handler: Callable[[dict[str, Any]], Any],
*,
public: bool = True,
-) -> None:
- """Register a workflow-safe tool.
-
- Raises :class:`ValueError` on collision with an existing entry, on a
- name that collides with a reserved workflow-management tool, or on
- an obviously-wrong handler shape (async functions are rejected so
- the orchestrator's activity can stay synchronous in M1).
- """
+) -> WorkflowToolEntry:
+ """Validate and construct one workflow handler-catalog entry."""
if not isinstance(name, str) or not name:
raise ValueError("workflow tool name must be a non-empty string")
if name in RESERVED_TOOL_NAMES:
@@ -88,8 +84,6 @@ def register_workflow_tool(
f"workflow tool name {name!r} is reserved for the workflow "
"control plane and cannot be used as a workflow node target"
)
- if name in _REGISTRY:
- raise ValueError(f"workflow tool {name!r} is already registered")
if not callable(handler):
raise ValueError(
f"workflow tool {name!r}: handler must be a callable taking a "
@@ -100,8 +94,42 @@ def register_workflow_tool(
f"workflow tool {name!r}: async handlers are not supported; "
"register a synchronous wrapper instead"
)
- _REGISTRY[name] = WorkflowToolEntry(
- name=name, description=description, handler=handler, public=public
+ return WorkflowToolEntry(
+ name=name,
+ description=description,
+ handler=handler,
+ public=public,
+ )
+
+
+def build_handler_catalog(
+ entries: Mapping[str, WorkflowToolEntry],
+) -> WorkflowHandlerCatalog:
+ """Freeze a complete app-wide workflow handler inventory."""
+ return MappingProxyType(dict(entries))
+
+
+def register_workflow_tool(
+ name: str,
+ description: str,
+ handler: Callable[[dict[str, Any]], Any],
+ *,
+ public: bool = True,
+) -> None:
+ """Register a workflow-safe tool.
+
+ Raises :class:`ValueError` on collision with an existing entry, on a
+ name that collides with a reserved workflow-management tool, or on
+ an obviously-wrong handler shape (async functions are rejected so
+ the orchestrator's activity can stay synchronous in M1).
+ """
+ if name in _REGISTRY:
+ raise ValueError(f"workflow tool {name!r} is already registered")
+ _REGISTRY[name] = make_workflow_tool_entry(
+ name,
+ description,
+ handler,
+ public=public,
)
@@ -130,9 +158,7 @@ def set_app_config(allowed_tools: frozenset[str]) -> None:
:mod:`.integration`. ``start_workflow`` reads this when validating
submitted plans.
- M1 has exactly one main agent so a single module-level value is
- sufficient. Per-agent allowlists land with the M3 registry refactor
- — at which point this should be replaced with a per-agent lookup.
+ Production app construction does not use this fallback.
"""
global _APP_ALLOWLIST
_APP_ALLOWLIST = frozenset(allowed_tools)
@@ -159,11 +185,14 @@ def reset() -> None:
__all__ = [
"RESERVED_TOOL_NAMES",
+ "WorkflowHandlerCatalog",
"WorkflowToolEntry",
"all_registered_names",
+ "build_handler_catalog",
"get_app_config",
"get_entry",
"get_handler",
+ "make_workflow_tool_entry",
"public_tool_names",
"register_workflow_tool",
"reset",
diff --git a/src/azure_functions_agents/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py
index e1322521..d7e43bb0 100644
--- a/src/azure_functions_agents/workflows/schema.py
+++ b/src/azure_functions_agents/workflows/schema.py
@@ -55,7 +55,7 @@ class TemplateResolutionError(ValueError):
@dataclass(frozen=True)
class WorkflowPlanPolicy:
- """Immutable owner-specific authorization boundary for workflow plans."""
+ """Immutable per-agent authorization boundary for workflow plans."""
allowed_tools: frozenset[str]
allowed_subagents: frozenset[str]
@@ -118,7 +118,7 @@ def validate_plan(
) -> WorkflowPlan:
"""Validate and normalize a plan dict.
- ``policy`` is the immutable owner-specific authorization boundary used
+ ``policy`` is the immutable agent-specific authorization boundary used
by both prompt guidance and runtime validation. ``allowed_tools`` remains
as a compatibility-only input for callers predating sub-agent nodes.
@@ -263,7 +263,7 @@ def validate_plan(
if task.agent not in policy.allowed_subagents:
raise PlanValidationError(
f"task {task.id!r}: Sub Agent {task.agent!r} is not authorized "
- f"for this workflow owner. Allowed Sub Agents: "
+ f"for this workflow-enabled agent. Allowed Sub Agents: "
f"{sorted(policy.allowed_subagents)}"
)
diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py
index 1decefd9..a1a7f505 100644
--- a/src/azure_functions_agents/workflows/tools.py
+++ b/src/azure_functions_agents/workflows/tools.py
@@ -10,10 +10,9 @@
All five call the Durable client captured by the per-session MAF tool
wrappers built in ``build_workflow_tools``.
-Ownership is enforced by prefix-matching the Durable instance ID
-against ``sha256(session_id)[:12]``; a mismatch returns 404 (same shape
-as "not found") to avoid leaking existence of other sessions'
-workflows.
+Isolation is enforced by prefix-matching the Durable instance ID against a
+128-bit SHA-256 prefix for ``(workflow_agent_slug, session_id)``; a mismatch
+returns 404 (same shape as "not found") to avoid leaking another agent's workflows.
"""
from __future__ import annotations
@@ -21,6 +20,7 @@
import json
from typing import Annotated, Any, Literal
+from azure.durable_functions import DurableOrchestrationClient
from pydantic import BaseModel, ConfigDict, Field, field_validator
from azure_functions_agents._function_tool import tool as define_tool
@@ -30,7 +30,7 @@
from .context import (
WorkflowSessionContext,
new_workflow_instance_id,
- session_owns_workflow,
+ workflow_matches_agent_session,
)
from .engine import CANCEL_EVENT_NAME, ORCHESTRATOR_NAME
from .schema import (
@@ -212,10 +212,6 @@ def status_envelope(status: Any) -> dict[str, Any]:
}
-# Backwards-compatible alias for in-module call sites; do not export.
-_status_envelope = status_envelope
-
-
def _runtime_status_name(status: Any) -> str:
return getattr(status.runtime_status, "name", str(status.runtime_status))
@@ -233,9 +229,11 @@ def _is_active_status(status: Any) -> bool:
async def fetch_session_workflows(
- durable_client: Any, session_id: str
+ durable_client: DurableOrchestrationClient,
+ workflow_agent_slug: str,
+ session_id: str,
) -> list[dict[str, Any]]:
- """Return status envelopes for all workflows owned by ``session_id``.
+ """Return status envelopes for workflows matching the agent and session.
Shared between the ``list_workflows`` tool (LLM-facing) and the
``/agent/workflows`` HTTP endpoint (UI polling). See the note in
@@ -247,7 +245,9 @@ async def fetch_session_workflows(
envelopes: list[dict[str, Any]] = []
for status in statuses or []:
instance_id = getattr(status, "instance_id", None)
- if not instance_id or not session_owns_workflow(session_id, instance_id):
+ if not instance_id or not workflow_matches_agent_session(
+ workflow_agent_slug, session_id, instance_id
+ ):
continue
envelopes.append(status_envelope(status))
envelopes.sort(
@@ -257,14 +257,20 @@ async def fetch_session_workflows(
return envelopes[:MAX_WORKFLOW_STATUS_RESULTS]
-async def count_active_session_workflows(durable_client: Any, session_id: str) -> int:
+async def count_active_session_workflows(
+ durable_client: DurableOrchestrationClient,
+ workflow_agent_slug: str,
+ session_id: str,
+) -> int:
statuses = await durable_client.get_status_all()
active = 0
for status in statuses or []:
instance_id = getattr(status, "instance_id", None)
if (
instance_id
- and session_owns_workflow(session_id, instance_id)
+ and workflow_matches_agent_session(
+ workflow_agent_slug, session_id, instance_id
+ )
and _is_active_status(status)
):
active += 1
@@ -274,12 +280,16 @@ async def count_active_session_workflows(durable_client: Any, session_id: str) -
async def fetch_session_workflow_status(
- durable_client: Any, session_id: str, workflow_id: str
+ durable_client: DurableOrchestrationClient,
+ workflow_agent_slug: str,
+ session_id: str,
+ workflow_id: str,
) -> dict[str, Any] | None:
- """Return the status envelope for ``workflow_id`` if owned by
- ``session_id``; otherwise ``None`` (404 semantics).
+ """Return the status if it matches the workflow agent and session.
"""
- if not session_owns_workflow(session_id, workflow_id):
+ if not workflow_matches_agent_session(
+ workflow_agent_slug, session_id, workflow_id
+ ):
return None
status = await durable_client.get_status(workflow_id)
envelope = status_envelope(status)
@@ -370,18 +380,29 @@ async def start_workflow(
except PlanValidationError as exc:
return _error(str(exc))
- owner = {
+ workflow_agent = {
+ "workflow_agent_slug": session.workflow_agent_slug,
"session_id": session.session_id,
"agent_name": session.agent_name,
}
- instance_id = new_workflow_instance_id(session.session_id)
+ instance_id = new_workflow_instance_id(
+ session.workflow_agent_slug,
+ session.session_id,
+ )
try:
active_count = await count_active_session_workflows(
- session.durable_client, session.session_id
+ session.durable_client,
+ session.workflow_agent_slug,
+ session.session_id,
)
except Exception:
- logger.exception("start_workflow: client.get_status_all failed")
+ logger.exception(
+ "start_workflow: client.get_status_all failed "
+ "workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to start workflow")
if active_count >= MAX_ACTIVE_WORKFLOWS_PER_SESSION:
return _error(
@@ -396,11 +417,16 @@ async def start_workflow(
instance_id=instance_id,
client_input={
"tasks": plan_to_activity_inputs(plan),
- "owner": owner,
+ "workflow_agent_slug": session.workflow_agent_slug,
+ "workflow_agent": workflow_agent,
},
)
except Exception:
- logger.exception("start_workflow: client.start_new failed")
+ logger.exception(
+ "start_workflow: client.start_new failed workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to start workflow")
# Durable echoes back the instance ID we supplied; defend against SDK
@@ -411,7 +437,12 @@ async def start_workflow(
returned_id,
instance_id,
)
- logger.info("workflow started: id=%s owner=%s", instance_id, owner["session_id"])
+ logger.info(
+ "workflow started: id=%s workflow_agent=%s session=%s",
+ instance_id,
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return json.dumps({"workflow_id": instance_id})
@@ -422,10 +453,14 @@ async def get_workflow_status(
if session is None:
return _error(_NO_CLIENT_MESSAGE)
- # Ownership check via instance-ID prefix. Any workflow whose ID does
+ # Agent/session check via instance-ID prefix. Any workflow whose ID does
# not start with this session's hash is treated as nonexistent — same
# shape as "not found" so existence cannot be probed.
- if not session_owns_workflow(session.session_id, params.workflow_id):
+ if not workflow_matches_agent_session(
+ session.workflow_agent_slug,
+ session.session_id,
+ params.workflow_id,
+ ):
return _error(
f"workflow {params.workflow_id!r} not found",
status=_NOT_FOUND_ERROR_STATUS,
@@ -434,10 +469,15 @@ async def get_workflow_status(
try:
status = await session.durable_client.get_status(params.workflow_id)
except Exception:
- logger.exception("get_workflow_status: client.get_status failed")
+ logger.exception(
+ "get_workflow_status: client.get_status failed "
+ "workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to fetch workflow status")
- envelope = _status_envelope(status)
+ envelope = status_envelope(status)
if envelope["runtime_status"] == "not_found":
return _error(
f"workflow {params.workflow_id!r} not found",
@@ -455,10 +495,17 @@ async def list_workflows(
try:
envelopes = await fetch_session_workflows(
- session.durable_client, session.session_id
+ session.durable_client,
+ session.workflow_agent_slug,
+ session.session_id,
)
except Exception:
- logger.exception("list_workflows: fetch_session_workflows failed")
+ logger.exception(
+ "list_workflows: fetch_session_workflows failed "
+ "workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to list workflows")
return json.dumps({"workflows": envelopes})
@@ -471,7 +518,11 @@ async def terminate_workflow(
if session is None:
return _error(_NO_CLIENT_MESSAGE)
- if not session_owns_workflow(session.session_id, params.workflow_id):
+ if not workflow_matches_agent_session(
+ session.workflow_agent_slug,
+ session.session_id,
+ params.workflow_id,
+ ):
return _error(
f"workflow {params.workflow_id!r} not found",
status=_NOT_FOUND_ERROR_STATUS,
@@ -480,11 +531,20 @@ async def terminate_workflow(
try:
await session.durable_client.terminate(params.workflow_id, params.reason)
except Exception:
- logger.exception("terminate_workflow: client.terminate failed")
+ logger.exception(
+ "terminate_workflow: client.terminate failed "
+ "workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to terminate workflow")
logger.info(
- "workflow terminated: id=%s reason=%r", params.workflow_id, params.reason
+ "workflow terminated: id=%s workflow_agent=%s session=%s reason=%r",
+ params.workflow_id,
+ session.workflow_agent_slug,
+ session.session_id,
+ params.reason,
)
return json.dumps({"workflow_id": params.workflow_id, "terminated": True})
@@ -496,7 +556,11 @@ async def cancel_workflow(
if session is None:
return _error(_NO_CLIENT_MESSAGE)
- if not session_owns_workflow(session.session_id, params.workflow_id):
+ if not workflow_matches_agent_session(
+ session.workflow_agent_slug,
+ session.session_id,
+ params.workflow_id,
+ ):
return _error(
f"workflow {params.workflow_id!r} not found",
status=_NOT_FOUND_ERROR_STATUS,
@@ -507,12 +571,19 @@ async def cancel_workflow(
params.workflow_id, CANCEL_EVENT_NAME, params.reason
)
except Exception:
- logger.exception("cancel_workflow: client.raise_event failed")
+ logger.exception(
+ "cancel_workflow: client.raise_event failed "
+ "workflow_agent=%s session=%s",
+ session.workflow_agent_slug,
+ session.session_id,
+ )
return _error("failed to cancel workflow")
logger.info(
- "workflow cancel requested: id=%s reason=%r",
+ "workflow cancel requested: id=%s workflow_agent=%s session=%s reason=%r",
params.workflow_id,
+ session.workflow_agent_slug,
+ session.session_id,
params.reason,
)
return json.dumps(
@@ -521,29 +592,33 @@ async def cancel_workflow(
def _build_session(
+ workflow_agent_slug: str,
session_id: str | None,
agent_name: str,
- durable_client: Any | None,
+ durable_client: DurableOrchestrationClient | None,
) -> WorkflowSessionContext | None:
if not session_id or durable_client is None:
return None
return WorkflowSessionContext(
+ workflow_agent_slug=workflow_agent_slug,
session_id=session_id,
agent_name=agent_name,
durable_client=durable_client,
- token="",
)
def build_workflow_tools(
*,
session_id: str | None = None,
+ workflow_agent_slug: str = "main",
agent_name: str = "main",
- durable_client: Any | None = None,
+ durable_client: DurableOrchestrationClient | None = None,
policy: WorkflowPlanPolicy | None = None,
) -> list[Any]:
"""Return the list of workflow tool objects to inject for an agent."""
- session = _build_session(session_id, agent_name, durable_client)
+ session = _build_session(
+ workflow_agent_slug, session_id, agent_name, durable_client
+ )
@define_tool(
name="start_workflow",
diff --git a/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/incident_analyst.agent.md b/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/incident_analyst.agent.md
new file mode 100644
index 00000000..034ad8c1
--- /dev/null
+++ b/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/incident_analyst.agent.md
@@ -0,0 +1,6 @@
+---
+name: Incident Analyst
+description: Reviews incident evidence
+---
+Analyze incident evidence.
+
diff --git a/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/release_reviewer.agent.md b/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/release_reviewer.agent.md
new file mode 100644
index 00000000..6d2cacb4
--- /dev/null
+++ b/tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/release_reviewer.agent.md
@@ -0,0 +1,6 @@
+---
+name: Release Reviewer
+description: Reviews release evidence
+---
+Analyze release evidence.
+
diff --git a/tests/fixtures/config_scenarios/18_multi_owner_workflows/incident_commander.agent.md b/tests/fixtures/config_scenarios/18_multi_owner_workflows/incident_commander.agent.md
new file mode 100644
index 00000000..acfc9133
--- /dev/null
+++ b/tests/fixtures/config_scenarios/18_multi_owner_workflows/incident_commander.agent.md
@@ -0,0 +1,13 @@
+---
+name: Incident Commander
+description: Owns incident workflows
+builtin_endpoints:
+ chat_api: true
+workflows:
+ enabled: true
+ exclude: [release_evidence]
+ subagents:
+ - agent: incident_analyst
+---
+Handle incidents.
+
diff --git a/tests/fixtures/config_scenarios/18_multi_owner_workflows/release_manager.agent.md b/tests/fixtures/config_scenarios/18_multi_owner_workflows/release_manager.agent.md
new file mode 100644
index 00000000..35b55407
--- /dev/null
+++ b/tests/fixtures/config_scenarios/18_multi_owner_workflows/release_manager.agent.md
@@ -0,0 +1,13 @@
+---
+name: Release Manager
+description: Owns release workflows
+builtin_endpoints:
+ chat_api: true
+workflows:
+ enabled: true
+ exclude: [incident_evidence]
+ subagents:
+ - agent: release_reviewer
+---
+Handle releases.
+
diff --git a/tests/scripts/verify_per_agent_workflows.py b/tests/scripts/verify_per_agent_workflows.py
new file mode 100644
index 00000000..30de4052
--- /dev/null
+++ b/tests/scripts/verify_per_agent_workflows.py
@@ -0,0 +1,891 @@
+"""Run the Engineering Operations Hub workflow E2E verifier."""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import json
+import os
+import queue
+import re
+import shutil
+import signal
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+import uuid
+from collections import deque
+from collections.abc import Iterator, Mapping, Sequence
+from pathlib import Path
+from typing import Any, Literal, NamedTuple
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SAMPLE_ROOT = REPO_ROOT / "samples" / "per-agent-workflows"
+SAMPLE_SRC = SAMPLE_ROOT / "src"
+TASK_HUB = "engineeringopshub"
+SESSION_ID = "engineering-ops-shared-session"
+AZURITE_ACCOUNT = "devstoreaccount1"
+AZURITE_KEY = (
+ # Azurite's documented public emulator key, not a real credential.
+ "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/"
+ "K1SZFPTOtr/KBHBeksoGMGw=="
+)
+PROVIDER_KEYS = (
+ "AZURE_FUNCTIONS_AGENTS_PROVIDER",
+ "AZURE_FUNCTIONS_AGENTS_MODEL",
+ "FOUNDRY_PROJECT_ENDPOINT",
+ "FOUNDRY_MODEL",
+ "OPENAI_API_KEY",
+ "OPENAI_CHAT_MODEL_ID",
+ "AZURE_OPENAI_ENDPOINT",
+ "AZURE_OPENAI_API_KEY",
+ "AZURE_OPENAI_DEPLOYMENT",
+ "AZURE_OPENAI_API_VERSION",
+ "AZURE_CLIENT_ID",
+)
+READY_MARKERS = (
+ "worker process started and initialized",
+ "host started",
+ "application started. press ctrl+c to shut down",
+)
+FAILURE_MARKERS = (
+ "worker failed to index functions",
+ "failed to index functions",
+ "no job functions found",
+ "a host error has occurred",
+ "traceback (most recent call last)",
+ "unhandled exception",
+)
+TERMINAL_STATUSES = frozenset({"Completed", "Failed", "Terminated", "Canceled"})
+WORKFLOW_ID_RE = re.compile(
+ r"\b[0-9a-f]{32}-[0-9a-f]{32}\b",
+ re.IGNORECASE,
+)
+
+INCIDENT_PROMPT = (
+ "Start exactly one incident workflow now for incident INC-4821 on checkout-api. "
+ "Use parallel task IDs incident_logs, incident_metrics, and incident_deployments "
+ "for the three incident evidence tools. Then use a sub_agent task named "
+ "incident_analysis with incident_evidence_analyst and include all three whole "
+ "results. Finish with incident_report using compile_incident_report and pass "
+ "the incident ID, service, all whole evidence results, and the whole specialist "
+ "result. Return the workflow ID without polling."
+)
+RELEASE_PROMPT = (
+ "Start exactly one release-readiness workflow now for release REL-2026.08.11 "
+ "on checkout-api. Use parallel task IDs release_prs, release_tests, "
+ "release_vulnerabilities, and release_window for the four release evidence "
+ "tools. Then use a sub_agent task named release_review with release_risk_reviewer "
+ "and include all four whole results. Finish with release_dossier using "
+ "compile_release_dossier and pass the release ID, service, all whole evidence "
+ "results, and the whole specialist result. Return the workflow ID without polling."
+)
+
+WorkflowAgent = Literal["incident_commander", "release_manager"]
+
+
+class EmulatorCommands(NamedTuple):
+ azurite: list[str]
+ dts: list[str] | None
+
+
+WORKFLOW_AGENT_EXPECTATIONS: dict[str, dict[str, object]] = {
+ "incident_commander": {
+ "marker": "INCIDENT_REPORT_READY",
+ "report_type": "incident",
+ "identity_key": "incident_id",
+ "identity": "INC-4821",
+ "decision": "ROLLBACK",
+ "evidence": frozenset({
+ "get_incident_logs",
+ "get_incident_metrics",
+ "get_incident_deployments",
+ }),
+ "required": frozenset({
+ "get_incident_logs",
+ "get_incident_metrics",
+ "get_incident_deployments",
+ "incident_evidence_analyst",
+ "compile_incident_report",
+ }),
+ "allowed": frozenset({
+ "get_incident_logs",
+ "get_incident_metrics",
+ "get_incident_deployments",
+ "incident_evidence_analyst",
+ "compile_incident_report",
+ }),
+ },
+ "release_manager": {
+ "marker": "RELEASE_DOSSIER_READY",
+ "report_type": "release_readiness",
+ "identity_key": "release_id",
+ "identity": "REL-2026.08.11",
+ "decision": "NO_GO",
+ "evidence": frozenset({
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+ "get_release_change_window",
+ }),
+ "required": frozenset({
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+ "get_release_change_window",
+ "release_risk_reviewer",
+ "compile_release_dossier",
+ }),
+ "allowed": frozenset({
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+ "get_release_change_window",
+ "release_risk_reviewer",
+ "compile_release_dossier",
+ }),
+ },
+}
+
+
+def build_emulator_commands(run_id: str, backend: str) -> EmulatorCommands:
+ """Build uniquely named containers with Docker-assigned host ports."""
+ if backend not in {"storage", "dts"}:
+ raise ValueError(f"unsupported backend {backend!r}")
+ azurite_name = f"engineering-ops-azurite-{run_id}"
+ azurite = [
+ "docker",
+ "run",
+ "--detach",
+ "--rm",
+ "--name",
+ azurite_name,
+ "--publish",
+ "127.0.0.1::10000",
+ "--publish",
+ "127.0.0.1::10001",
+ "--publish",
+ "127.0.0.1::10002",
+ "mcr.microsoft.com/azure-storage/azurite:latest",
+ "azurite",
+ "--silent",
+ "--skipApiVersionCheck",
+ "--blobHost",
+ "0.0.0.0",
+ "--queueHost",
+ "0.0.0.0",
+ "--tableHost",
+ "0.0.0.0",
+ ]
+ dts = None
+ if backend == "dts":
+ dts = [
+ "docker",
+ "run",
+ "--detach",
+ "--rm",
+ "--name",
+ f"engineering-ops-dts-{run_id}",
+ "--env",
+ f"DTS_TASK_HUB_NAMES={TASK_HUB}",
+ "--publish",
+ "127.0.0.1::8080",
+ "--publish",
+ "127.0.0.1::8082",
+ "mcr.microsoft.com/dts/dts-emulator:latest",
+ ]
+ return EmulatorCommands(azurite=azurite, dts=dts)
+
+
+def extract_workflow_id(payload: object) -> str:
+ """Find a workflow ID in nested JSON, tool-result JSON strings, or prose."""
+ if isinstance(payload, Mapping):
+ direct = payload.get("workflow_id")
+ if isinstance(direct, str) and WORKFLOW_ID_RE.fullmatch(direct):
+ return direct
+ for value in payload.values():
+ with contextlib.suppress(RuntimeError):
+ return extract_workflow_id(value)
+ elif isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
+ for value in payload:
+ with contextlib.suppress(RuntimeError):
+ return extract_workflow_id(value)
+ elif isinstance(payload, str):
+ with contextlib.suppress(json.JSONDecodeError):
+ parsed = json.loads(payload)
+ if parsed != payload:
+ return extract_workflow_id(parsed)
+ match = WORKFLOW_ID_RE.search(payload)
+ if match:
+ return match.group(0)
+ raise RuntimeError("chat response did not contain a valid workflow ID")
+
+
+def _walk_values(value: object) -> Iterator[tuple[str, object]]:
+ if isinstance(value, Mapping):
+ for key, item in value.items():
+ yield str(key), item
+ yield from _walk_values(item)
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
+ for item in value:
+ yield from _walk_values(item)
+
+
+def validate_terminal_result(
+ workflow_agent: WorkflowAgent,
+ envelope: Mapping[str, object],
+) -> None:
+ """Validate terminal success, deterministic output, and agent capabilities."""
+ if envelope.get("runtime_status") != "Completed":
+ raise RuntimeError(
+ f"{workflow_agent} workflow ended as {envelope.get('runtime_status')!r}: "
+ f"{envelope.get('output')!r}"
+ )
+ output = envelope.get("output")
+ results = output.get("results") if isinstance(output, Mapping) else None
+ if not isinstance(results, Mapping):
+ raise RuntimeError(f"{workflow_agent} workflow output has no results object")
+
+ expected = WORKFLOW_AGENT_EXPECTATIONS[workflow_agent]
+ marker = expected["marker"]
+ report = next(
+ (
+ value
+ for value in results.values()
+ if isinstance(value, Mapping) and value.get("marker") == marker
+ ),
+ None,
+ )
+ if not isinstance(report, Mapping):
+ raise RuntimeError(
+ f"{workflow_agent} output is missing terminal marker {marker}"
+ )
+ for key, value in (
+ ("report_type", expected["report_type"]),
+ (str(expected["identity_key"]), expected["identity"]),
+ ("service", "checkout-api"),
+ ("decision", expected["decision"]),
+ ):
+ if report.get(key) != value:
+ raise RuntimeError(
+ f"{workflow_agent} terminal report has invalid {key!r}"
+ )
+
+ known = set().union(
+ *(
+ set(item["allowed"])
+ for item in WORKFLOW_AGENT_EXPECTATIONS.values()
+ ) # type: ignore[arg-type]
+ )
+ used = {
+ value
+ for key, value in _walk_values(results)
+ if key in {"capability", "agent"} and isinstance(value, str) and value in known
+ }
+ allowed = set(expected["allowed"]) # type: ignore[arg-type]
+ unauthorized = used - allowed
+ if unauthorized:
+ raise RuntimeError(
+ f"{workflow_agent} used unauthorized capabilities: "
+ f"{sorted(unauthorized)!r}"
+ )
+ missing = set(expected["required"]) - used # type: ignore[arg-type]
+ if missing:
+ raise RuntimeError(
+ f"{workflow_agent} did not use required capabilities: {sorted(missing)!r}"
+ )
+
+ identity_key = str(expected["identity_key"])
+ expected_identity = expected["identity"]
+ evidence_capabilities = set(expected["evidence"]) # type: ignore[arg-type]
+ for result in results.values():
+ if not isinstance(result, Mapping):
+ continue
+ capability = result.get("capability")
+ if capability not in evidence_capabilities:
+ continue
+ if (
+ result.get(identity_key) != expected_identity
+ or result.get("service") != "checkout-api"
+ ):
+ raise RuntimeError(
+ f"{workflow_agent} evidence {capability!r} has an invalid "
+ "identity or service"
+ )
+
+
+def validate_workflow_agent_list(
+ payload: Mapping[str, object],
+ own_workflow_id: str,
+ other_workflow_id: str,
+) -> None:
+ workflows = payload.get("workflows")
+ if not isinstance(workflows, list):
+ raise RuntimeError("workflow list response has no workflows array")
+ ids = {
+ item.get("workflow_id")
+ for item in workflows
+ if isinstance(item, Mapping) and isinstance(item.get("workflow_id"), str)
+ }
+ if other_workflow_id in ids:
+ raise RuntimeError(
+ "workflow-agent list exposed another agent's workflow"
+ )
+ if own_workflow_id not in ids:
+ raise RuntimeError("workflow-agent list did not include its own workflow")
+
+
+def _run(
+ command: Sequence[str],
+ *,
+ timeout: float = 120,
+) -> subprocess.CompletedProcess[str]:
+ try:
+ return subprocess.run(
+ command,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError as exc:
+ raise RuntimeError(f"required executable {command[0]!r} was not found on PATH") from exc
+ except subprocess.CalledProcessError as exc:
+ detail = (exc.stderr or exc.stdout or "").strip()
+ raise RuntimeError(f"{command[0]} command failed: {detail[-2000:]}") from exc
+
+
+def _container_name(command: Sequence[str]) -> str:
+ return command[command.index("--name") + 1]
+
+
+def _mapped_port(container: str, container_port: int) -> int:
+ output = _run(
+ ["docker", "port", container, f"{container_port}/tcp"],
+ timeout=30,
+ ).stdout.strip()
+ try:
+ return int(output.rsplit(":", 1)[1])
+ except (IndexError, ValueError) as exc:
+ raise RuntimeError(
+ f"could not determine mapped port {container_port} for {container}"
+ ) from exc
+
+
+def _wait_for_port(port: int, *, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ with socket.socket() as sock:
+ sock.settimeout(0.5)
+ if sock.connect_ex(("127.0.0.1", port)) == 0:
+ return
+ time.sleep(0.25)
+ raise RuntimeError(f"service on localhost:{port} was not ready within {timeout:.0f}s")
+
+
+def _azurite_connection(blob_port: int, queue_port: int, table_port: int) -> str:
+ return (
+ "DefaultEndpointsProtocol=http;"
+ f"AccountName={AZURITE_ACCOUNT};"
+ f"AccountKey={AZURITE_KEY};"
+ f"BlobEndpoint=http://127.0.0.1:{blob_port}/{AZURITE_ACCOUNT};"
+ f"QueueEndpoint=http://127.0.0.1:{queue_port}/{AZURITE_ACCOUNT};"
+ f"TableEndpoint=http://127.0.0.1:{table_port}/{AZURITE_ACCOUNT};"
+ )
+
+
+def _provider_values() -> dict[str, str]:
+ settings_path = SAMPLE_SRC / "local.settings.json"
+ if not settings_path.exists():
+ settings_path = SAMPLE_SRC / "local.settings.template.json"
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
+ raw_values = data.get("Values")
+ if not isinstance(raw_values, dict):
+ raise RuntimeError(f"{settings_path.name} must contain a Values object")
+ values = {str(key): str(value) for key, value in raw_values.items()}
+ for key in PROVIDER_KEYS:
+ env_value = (os.environ.get(key) or "").strip()
+ if env_value:
+ values[key] = env_value
+
+ configured = {
+ "foundry": bool(values.get("FOUNDRY_PROJECT_ENDPOINT", "").strip()),
+ "azure_openai": bool(values.get("AZURE_OPENAI_ENDPOINT", "").strip()),
+ "openai": bool(values.get("OPENAI_API_KEY", "").strip()),
+ }
+ requested_from_environment = (
+ os.environ.get("AZURE_FUNCTIONS_AGENTS_PROVIDER") or ""
+ ).strip()
+ requested = requested_from_environment or values.get(
+ "AZURE_FUNCTIONS_AGENTS_PROVIDER", ""
+ ).strip()
+ if requested and requested not in configured:
+ raise RuntimeError(
+ "AZURE_FUNCTIONS_AGENTS_PROVIDER must be foundry, azure_openai, or openai"
+ )
+ if requested_from_environment or (requested and configured[requested]):
+ selected = [requested]
+ else:
+ selected = [
+ provider for provider, is_configured in configured.items() if is_configured
+ ]
+ if not selected:
+ raise RuntimeError(
+ "no model provider is configured; set Foundry, Azure OpenAI, or OpenAI "
+ "values in src/local.settings.json or the current environment"
+ )
+ if len(selected) > 1:
+ raise RuntimeError(
+ "multiple model providers are configured; populate settings for exactly "
+ "one of Foundry, Azure OpenAI, or OpenAI"
+ )
+
+ provider = selected[0]
+ required = {
+ "foundry": ("FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"),
+ "azure_openai": (
+ "AZURE_OPENAI_ENDPOINT",
+ "AZURE_OPENAI_DEPLOYMENT",
+ "AZURE_OPENAI_API_VERSION",
+ ),
+ "openai": ("OPENAI_API_KEY", "OPENAI_CHAT_MODEL_ID"),
+ }
+ missing = [key for key in required[provider] if not values.get(key, "").strip()]
+ if missing:
+ raise RuntimeError(
+ f"{', '.join(missing)} must be configured for provider {provider}"
+ )
+ values["AZURE_FUNCTIONS_AGENTS_PROVIDER"] = provider
+ return values
+
+
+def build_host_environment() -> dict[str, str]:
+ """Pin the Functions worker to this checkout while preserving caller paths."""
+ environment = os.environ.copy()
+ checkout_src = str((REPO_ROOT / "src").resolve())
+ existing = environment.get("PYTHONPATH", "")
+ environment["PYTHONPATH"] = (
+ f"{checkout_src}{os.pathsep}{existing}" if existing else checkout_src
+ )
+ return environment
+
+
+def prepare_host_config(app_dir: Path, backend: str) -> None:
+ """Select the requested backend without depending on local sample edits."""
+ host_path = app_dir / "host.json"
+ if backend == "dts":
+ shutil.copyfile(app_dir / "host.dts.json", host_path)
+ return
+
+ host_config = json.loads(host_path.read_text(encoding="utf-8"))
+ extensions = host_config.get("extensions")
+ durable_task = extensions.get("durableTask") if isinstance(extensions, dict) else None
+ if isinstance(durable_task, dict):
+ durable_task.pop("storageProvider", None)
+ host_path.write_text(
+ json.dumps(host_config, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+@contextlib.contextmanager
+def _temporary_app(
+ *,
+ backend: str,
+ storage_connection: str,
+ dts_port: int | None,
+) -> Iterator[Path]:
+ with tempfile.TemporaryDirectory(prefix=".verify-work-", dir=SAMPLE_ROOT) as temp:
+ app_dir = Path(temp) / "src"
+ shutil.copytree(
+ SAMPLE_SRC,
+ app_dir,
+ ignore=shutil.ignore_patterns(".venv", "local.settings.json", "__pycache__"),
+ )
+ prepare_host_config(app_dir, backend)
+ values = _provider_values()
+ values.update({
+ "FUNCTIONS_WORKER_RUNTIME": "python",
+ "AzureWebJobsStorage": storage_connection,
+ "TASKHUB_NAME": TASK_HUB,
+ })
+ if dts_port is not None:
+ values["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = (
+ f"Endpoint=http://127.0.0.1:{dts_port};Authentication=None"
+ )
+ (app_dir / "local.settings.json").write_text(
+ json.dumps({"IsEncrypted": False, "Values": values}, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ yield app_dir
+
+
+class _FunctionHost:
+ def __init__(self, app_dir: Path) -> None:
+ func = shutil.which("func")
+ if func is None:
+ raise RuntimeError("required executable 'func' was not found on PATH")
+ self.port = _free_port()
+ self._lines: deque[str] = deque(maxlen=300)
+ self._queue: queue.Queue[str | None] = queue.Queue()
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
+ self._process = subprocess.Popen(
+ [func, "start", "--port", str(self.port)],
+ cwd=app_dir,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ env=build_host_environment(),
+ creationflags=creationflags,
+ start_new_session=os.name != "nt",
+ )
+ self._reader = threading.Thread(target=self._read, daemon=True)
+ self._reader.start()
+
+ @property
+ def base_url(self) -> str:
+ return f"http://127.0.0.1:{self.port}"
+
+ def _read(self) -> None:
+ assert self._process.stdout is not None
+ for line in self._process.stdout:
+ self._queue.put(line)
+ self._queue.put(None)
+
+ def wait_ready(self, *, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ ready_at: float | None = None
+ while time.monotonic() < deadline:
+ if ready_at is not None and time.monotonic() - ready_at >= 2:
+ return
+ try:
+ line = self._queue.get(timeout=0.5)
+ except queue.Empty:
+ if self._process.poll() is not None:
+ break
+ continue
+ if line is None:
+ break
+ self._lines.append(line)
+ lowered = line.lower()
+ if any(marker in lowered for marker in FAILURE_MARKERS):
+ raise RuntimeError(f"Functions host startup failed:\n{self.output_tail()}")
+ if ready_at is None and any(marker in lowered for marker in READY_MARKERS):
+ ready_at = time.monotonic()
+ raise RuntimeError(
+ f"Functions host was not ready within {timeout:.0f}s:\n{self.output_tail()}"
+ )
+
+ def output_tail(self) -> str:
+ while True:
+ try:
+ line = self._queue.get_nowait()
+ except queue.Empty:
+ break
+ if line is not None:
+ self._lines.append(line)
+ return "".join(self._lines)
+
+ def stop(self) -> None:
+ if self._process.poll() is None:
+ with contextlib.suppress(OSError):
+ if os.name == "nt":
+ self._process.send_signal(signal.CTRL_BREAK_EVENT)
+ else:
+ os.killpg(self._process.pid, signal.SIGTERM)
+ try:
+ self._process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ with contextlib.suppress(OSError):
+ if os.name == "nt":
+ self._process.kill()
+ else:
+ os.killpg(self._process.pid, signal.SIGKILL)
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ self._process.wait(timeout=5)
+ self._reader.join(timeout=5)
+
+
+def _free_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+@contextlib.contextmanager
+def _running_host(app_dir: Path, *, timeout: float) -> Iterator[_FunctionHost]:
+ host = _FunctionHost(app_dir)
+ try:
+ host.wait_ready(timeout=timeout)
+ yield host
+ finally:
+ host.stop()
+
+
+def _request_json(
+ method: str,
+ url: str,
+ *,
+ timeout: float,
+ payload: Mapping[str, object] | None = None,
+) -> tuple[int, dict[str, Any]]:
+ data = json.dumps(payload).encode() if payload is not None else None
+ request = Request(
+ url,
+ data=data,
+ method=method,
+ headers={
+ "Content-Type": "application/json",
+ "x-ms-session-id": SESSION_ID,
+ },
+ )
+ try:
+ with urlopen(request, timeout=timeout) as response:
+ status = response.status
+ body = response.read()
+ except HTTPError as exc:
+ status = exc.code
+ body = exc.read()
+ except (TimeoutError, URLError) as exc:
+ raise RuntimeError(f"{method} {url} failed: {exc}") from exc
+ try:
+ decoded = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RuntimeError(f"{method} {url} returned non-JSON status {status}") from exc
+ if not isinstance(decoded, dict):
+ raise RuntimeError(f"{method} {url} returned a non-object JSON response")
+ return status, decoded
+
+
+def _start_workflow_agent(
+ host: _FunctionHost,
+ workflow_agent: WorkflowAgent,
+ prompt: str,
+ *,
+ timeout: float,
+) -> str:
+ status, payload = _request_json(
+ "POST",
+ f"{host.base_url}/agents/{workflow_agent}/chat",
+ payload={"prompt": prompt},
+ timeout=timeout,
+ )
+ if status != 200:
+ raise RuntimeError(
+ f"{workflow_agent} chat returned HTTP {status}: {payload!r}"
+ )
+ try:
+ return extract_workflow_id(payload)
+ except RuntimeError as exc:
+ raise RuntimeError(
+ f"{workflow_agent} chat response had no workflow ID: {payload!r}"
+ ) from exc
+
+
+def _poll_workflow_agent(
+ host: _FunctionHost,
+ workflow_agent: WorkflowAgent,
+ workflow_id: str,
+ *,
+ timeout: float,
+) -> dict[str, Any]:
+ deadline = time.monotonic() + timeout
+ url = (
+ f"{host.base_url}/agents/{workflow_agent}/workflow-status?"
+ f"{urlencode({'workflow_id': workflow_id})}"
+ )
+ last_status = "not observed"
+ while time.monotonic() < deadline:
+ status, payload = _request_json("GET", url, timeout=min(30, timeout))
+ if status == 200:
+ last_status = str(payload.get("runtime_status"))
+ if last_status in TERMINAL_STATUSES:
+ return payload
+ elif status != 404:
+ raise RuntimeError(
+ f"{workflow_agent} status route returned HTTP {status}: {payload!r}"
+ )
+ time.sleep(2)
+ raise RuntimeError(
+ f"{workflow_agent} workflow {workflow_id} did not finish within {timeout:.0f}s "
+ f"(last status: {last_status})"
+ )
+
+
+def _assert_http_isolation(
+ host: _FunctionHost,
+ workflow_agent: WorkflowAgent,
+ own_id: str,
+ other_id: str,
+ *,
+ timeout: float,
+) -> None:
+ status_url = (
+ f"{host.base_url}/agents/{workflow_agent}/workflow-status?"
+ f"{urlencode({'workflow_id': other_id})}"
+ )
+ status, _ = _request_json("GET", status_url, timeout=timeout)
+ if status != 404:
+ raise RuntimeError(
+ f"{workflow_agent} status route exposed another agent with HTTP {status}"
+ )
+ status, payload = _request_json(
+ "GET",
+ f"{host.base_url}/agents/{workflow_agent}/workflows",
+ timeout=timeout,
+ )
+ if status != 200:
+ raise RuntimeError(
+ f"{workflow_agent} list route returned HTTP {status}: {payload!r}"
+ )
+ validate_workflow_agent_list(payload, own_id, other_id)
+
+
+def verify(*, backend: str, timeout: float, keep_services: bool) -> None:
+ """Run both workflow agents with one session and prove result and route isolation."""
+ if shutil.which("docker") is None:
+ raise RuntimeError("required executable 'docker' was not found on PATH")
+ if shutil.which("func") is None:
+ raise RuntimeError("required executable 'func' was not found on PATH")
+ _provider_values()
+ _run(["docker", "info"], timeout=30)
+
+ run_id = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
+ commands = build_emulator_commands(run_id, backend)
+ created: list[str] = []
+ dashboard_port: int | None = None
+ try:
+ services = [("Azurite", commands.azurite)]
+ if commands.dts is not None:
+ services.append(("DTS", commands.dts))
+ for label, command in services:
+ print(f"Starting isolated {label}...")
+ _run(command, timeout=180)
+ created.append(_container_name(command))
+
+ azurite_name = _container_name(commands.azurite)
+ blob_port = _mapped_port(azurite_name, 10000)
+ queue_port = _mapped_port(azurite_name, 10001)
+ table_port = _mapped_port(azurite_name, 10002)
+ for port in (blob_port, queue_port, table_port):
+ _wait_for_port(port, timeout=timeout)
+
+ dts_port = None
+ if commands.dts is not None:
+ dts_name = _container_name(commands.dts)
+ dts_port = _mapped_port(dts_name, 8080)
+ dashboard_port = _mapped_port(dts_name, 8082)
+ _wait_for_port(dts_port, timeout=timeout)
+
+ storage = _azurite_connection(blob_port, queue_port, table_port)
+ with _temporary_app(
+ backend=backend,
+ storage_connection=storage,
+ dts_port=dts_port,
+ ) as app_dir:
+ print("Starting Functions host...")
+ with _running_host(app_dir, timeout=timeout) as host:
+ print("Starting incident and release workflows with one shared session...")
+ try:
+ incident_id = _start_workflow_agent(
+ host, "incident_commander", INCIDENT_PROMPT, timeout=timeout
+ )
+ release_id = _start_workflow_agent(
+ host, "release_manager", RELEASE_PROMPT, timeout=timeout
+ )
+ except RuntimeError as exc:
+ raise RuntimeError(
+ f"{exc}\nFunctions host output:\n{host.output_tail()[-4000:]}"
+ ) from exc
+
+ incident = _poll_workflow_agent(
+ host, "incident_commander", incident_id, timeout=timeout
+ )
+ release = _poll_workflow_agent(
+ host, "release_manager", release_id, timeout=timeout
+ )
+ validate_terminal_result("incident_commander", incident)
+ validate_terminal_result("release_manager", release)
+ _assert_http_isolation(
+ host,
+ "incident_commander",
+ incident_id,
+ release_id,
+ timeout=30,
+ )
+ _assert_http_isolation(
+ host,
+ "release_manager",
+ release_id,
+ incident_id,
+ timeout=30,
+ )
+ lowered = host.output_tail().lower()
+ if any(marker in lowered for marker in FAILURE_MARKERS):
+ raise RuntimeError(
+ f"Functions host reported a failure:\n{host.output_tail()}"
+ )
+
+ dashboard = (
+ f" DTS dashboard: http://127.0.0.1:{dashboard_port}."
+ if dashboard_port is not None
+ else ""
+ )
+ print(
+ "PASS: both workflow-agent workflows completed with isolated "
+ "capabilities, cross-agent status returned 404, and lists remained private."
+ f"{dashboard}"
+ )
+ finally:
+ if keep_services and created:
+ print(f"Keeping emulator containers: {', '.join(created)}")
+ else:
+ for name in reversed(created):
+ with contextlib.suppress(RuntimeError):
+ _run(["docker", "rm", "--force", name], timeout=30)
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--backend",
+ choices=("storage", "dts"),
+ default="storage",
+ help="Durable backend to verify (default: storage).",
+ )
+ parser.add_argument(
+ "--timeout",
+ type=float,
+ default=300,
+ help="Seconds allowed for startup and each workflow (default: 300).",
+ )
+ parser.add_argument(
+ "--keep-services",
+ action="store_true",
+ help="Keep uniquely named emulator containers for debugging.",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = _parse_args()
+ try:
+ verify(
+ backend=args.backend,
+ timeout=args.timeout,
+ keep_services=args.keep_services,
+ )
+ except (KeyboardInterrupt, RuntimeError, ValueError) as exc:
+ print(f"FAIL: {exc}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py
index 00d97f18..cb446825 100644
--- a/tests/test_app_routes.py
+++ b/tests/test_app_routes.py
@@ -44,7 +44,10 @@ class _WorkflowRequest:
def __init__(self) -> None:
self.headers = {"x-ms-session-id": self.session_id}
self.query_params = {
- "workflow_id": workflow_context.new_workflow_instance_id(self.session_id)
+ "workflow_id": workflow_context.new_workflow_instance_id(
+ "main",
+ self.session_id,
+ )
}
@@ -98,9 +101,7 @@ def test_bare_agent_md_with_workflows_creates_durable_app(tmp_path: Path):
assert isinstance(function_app, df.DFApp)
-def test_non_main_workflows_enabled_warns_and_does_not_enable_durable(
- tmp_path: Path, caplog: pytest.LogCaptureFixture
-):
+def test_non_main_trigger_workflows_enable_durable(tmp_path: Path):
_write_main_agent(tmp_path)
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
@@ -114,11 +115,17 @@ def test_non_main_workflows_enabled_warns_and_does_not_enable_durable(
function_app = app_module.create_function_app(app_root=tmp_path)
- assert not isinstance(function_app, df.DFApp)
- assert any(
- "workflows.enabled is only honored on main.agent.md" in record.message
- for record in caplog.records
- )
+ assert isinstance(function_app, df.DFApp)
+ trigger_bindings = [
+ [binding.get_dict_repr()["type"] for binding in builder._function._bindings]
+ for builder in function_app._function_builders
+ if any(
+ binding.get_dict_repr()["type"] == "timerTrigger"
+ for binding in builder._function._bindings
+ )
+ ]
+ assert len(trigger_bindings) == 1
+ assert "durableClient" in trigger_bindings[0]
def test_non_workflow_routes_do_not_register_durable_client_binding(tmp_path: Path):
@@ -266,7 +273,7 @@ async def fail_fetch(*args, **kwargs):
assert body == {"error": "failed to list workflows"}
assert secret_message not in response.body.decode()
assert any(
- record.message == "workflows list endpoint failed"
+ record.message.startswith("workflows list endpoint failed")
and record.exc_info
and secret_message in str(record.exc_info[1])
for record in caplog.records
@@ -297,7 +304,7 @@ async def fail_fetch(*args, **kwargs):
assert body == {"error": "failed to fetch workflow status"}
assert secret_message not in response.body.decode()
assert any(
- record.message == "workflow status endpoint failed"
+ record.message.startswith("workflow status endpoint failed")
and record.exc_info
and secret_message in str(record.exc_info[1])
for record in caplog.records
diff --git a/tests/test_config_fixtures.py b/tests/test_config_fixtures.py
index 43b014bb..57e57bb4 100644
--- a/tests/test_config_fixtures.py
+++ b/tests/test_config_fixtures.py
@@ -759,3 +759,36 @@ def test_dynamic_workflow_subagents_fixture() -> None:
discovered_skills=[],
is_referenced_as_subagent=True,
)
+
+
+# ---------------------------------------------------------------------------
+# 18 — multiple workflow-enabled agents with distinct policies
+# ---------------------------------------------------------------------------
+
+
+def test_multi_owner_workflows_fixture() -> None:
+ fixture = FIXTURES_ROOT / "18_multi_owner_workflows"
+ specs = load_agent_specs(fixture, strict=True)
+ resolved = [compose(spec, load_global_config(fixture)) for spec in specs]
+ by_slug = {agent.slug: agent for agent in resolved}
+
+ assert set(by_slug) == {
+ "incident_commander",
+ "release_manager",
+ "incident_analyst",
+ "release_reviewer",
+ }
+ assert not any(agent.is_main for agent in resolved)
+
+ incident = by_slug["incident_commander"]
+ release = by_slug["release_manager"]
+ assert incident.workflows is not None
+ assert incident.workflows.exclude == ("release_evidence",)
+ assert [ref.agent for ref in incident.workflows.subagents] == ["incident_analyst"]
+ assert release.workflows is not None
+ assert release.workflows.exclude == ("incident_evidence",)
+ assert [ref.agent for ref in release.workflows.subagents] == ["release_reviewer"]
+
+ known_slugs = set(by_slug)
+ validate_workflow_subagent_references(incident, known_slugs=known_slugs)
+ validate_workflow_subagent_references(release, known_slugs=known_slugs)
diff --git a/tests/test_outlook_reply_sample.py b/tests/test_outlook_reply_sample.py
new file mode 100644
index 00000000..2ae93498
--- /dev/null
+++ b/tests/test_outlook_reply_sample.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+from azure_functions_agents.config.loader import load_agent_specs
+from azure_functions_agents.config.schema import TRIGGER_TYPES
+
+SAMPLE_SRC = (
+ Path(__file__).resolve().parents[1] / "samples" / "outlook-reply-agent" / "src"
+)
+
+
+def test_outlook_reply_sample_uses_supported_connector_trigger() -> None:
+ [agent] = load_agent_specs(SAMPLE_SRC, strict=True)
+
+ assert agent.trigger is not None
+ assert agent.trigger.type == "connector_trigger"
+ assert agent.trigger.type in TRIGGER_TYPES
+ assert agent.trigger.args == {}
diff --git a/tests/test_per_agent_workflows.py b/tests/test_per_agent_workflows.py
new file mode 100644
index 00000000..7a37f548
--- /dev/null
+++ b/tests/test_per_agent_workflows.py
@@ -0,0 +1,519 @@
+from __future__ import annotations
+
+from types import MappingProxyType
+from typing import Any
+
+import azure.durable_functions as df
+import azure.functions as func
+import pytest
+
+from azure_functions_agents._function_tool import WorkflowTool
+from azure_functions_agents.app import create_function_app
+from azure_functions_agents.config.schema import (
+ BuiltinEndpointsConfig,
+ ResolvedAgent,
+ ToolsFilter,
+ TriggerSpec,
+ WorkflowConfig,
+ WorkflowSubagentRef,
+)
+from azure_functions_agents.registration.capabilities import AgentCapabilities
+from azure_functions_agents.registration.catalog import CatalogEntry, build_catalog
+from azure_functions_agents.workflows import context, engine, integration, schema, tools
+
+
+def _write_agent(tmp_path, filename: str, frontmatter: str) -> None:
+ (tmp_path / filename).write_text(
+ f"---\n{frontmatter.strip()}\n---\nAssist the user.\n",
+ encoding="utf-8",
+ )
+
+
+def _function_names(app: Any) -> list[str]:
+ return [function.get_function_name() for function in app.get_functions()]
+
+
+def _registered_function(app: Any, name: str) -> Any:
+ for builder in app._function_builders:
+ function = builder._function
+ if function._name == name:
+ return function._func
+ raise AssertionError(f"function {name!r} was not registered")
+
+
+def test_non_main_workflow_agent_without_main_creates_dfapp(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "incident.agent.md",
+ """
+name: Incident
+description: Triage incidents.
+builtin_endpoints:
+ chat_api: true
+workflows:
+ enabled: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert isinstance(app, df.DFApp)
+ names = _function_names(app)
+ assert names.count(engine.ORCHESTRATOR_NAME) == 1
+ assert names.count("agents_workflow_run_tool") == 1
+ assert names.count(engine.SUB_AGENT_ACTIVITY_NAME) == 1
+ assert "agent_incident_builtin_chat" in names
+
+
+def test_non_workflow_app_remains_plain_function_app(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "assistant.agent.md",
+ """
+name: Assistant
+description: Handles chat without workflows.
+builtin_endpoints:
+ chat_api: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert isinstance(app, func.FunctionApp)
+ assert not isinstance(app, df.DFApp)
+ assert engine.ORCHESTRATOR_NAME not in _function_names(app)
+
+
+@pytest.mark.parametrize("chat_api", ['"true"', "1"])
+def test_workflow_agent_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None:
+ _write_agent(
+ tmp_path,
+ "incident.agent.md",
+ f"""
+name: Incident
+description: Triage incidents.
+builtin_endpoints:
+ chat_api: {chat_api}
+workflows:
+ enabled: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert isinstance(app, df.DFApp)
+ assert "agent_incident_builtin_chat" in _function_names(app)
+
+
+def test_multiple_workflow_agents_register_one_durable_blueprint(tmp_path) -> None:
+ for slug in ("incident", "release"):
+ _write_agent(
+ tmp_path,
+ f"{slug}.agent.md",
+ f"""
+name: {slug.title()}
+description: Handle {slug}.
+builtin_endpoints:
+ chat_api: true
+workflows:
+ enabled: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+ names = _function_names(app)
+
+ assert names.count(engine.ORCHESTRATOR_NAME) == 1
+ assert names.count("agents_workflow_run_tool") == 1
+ assert names.count(engine.SUB_AGENT_ACTIVITY_NAME) == 1
+ assert "agent_incident_builtin_workflows" in names
+ assert "agent_release_builtin_workflows" in names
+
+
+def test_shared_workflow_subagent_registers_one_durable_activity(tmp_path) -> None:
+ for slug in ("incident", "release"):
+ _write_agent(
+ tmp_path,
+ f"{slug}.agent.md",
+ f"""
+name: {slug.title()}
+description: Handle {slug}.
+builtin_endpoints:
+ chat_api: true
+workflows:
+ enabled: true
+ subagents:
+ - agent: analyst
+""",
+ )
+ _write_agent(
+ tmp_path,
+ "analyst.agent.md",
+ """
+name: Analyst
+description: Analyze one bounded task.
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert _function_names(app).count(engine.SUB_AGENT_ACTIVITY_NAME) == 1
+
+
+def test_mcp_only_workflow_agent_is_supported(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "mcp_owner.agent.md",
+ """
+name: MCP Owner
+description: Starts workflows over MCP.
+builtin_endpoints:
+ mcp: true
+workflows:
+ enabled: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert isinstance(app, df.DFApp)
+ names = _function_names(app)
+ assert "agent_mcp_owner_builtin_mcp" in names
+ assert names.count(engine.ORCHESTRATOR_NAME) == 1
+
+
+def test_unknown_trigger_workflow_agent_fails_composition(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "unknown.agent.md",
+ """
+name: Unknown Trigger
+description: Must not create an inert workflow-enabled agent.
+trigger:
+ type: imaginary_trigger
+workflows:
+ enabled: true
+""",
+ )
+
+ with pytest.raises(ValueError, match=r"trigger\.type.*imaginary_trigger"):
+ create_function_app(tmp_path)
+
+
+def test_workflow_agent_preserves_actionable_trigger_alias_diagnostic(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "route.agent.md",
+ """
+name: Route Alias
+description: Uses the wrong trigger name.
+trigger:
+ type: route
+workflows:
+ enabled: true
+""",
+ )
+
+ with pytest.raises(ValueError, match=r"Use `http_trigger`"):
+ create_function_app(tmp_path)
+
+
+def test_callable_non_trigger_decorator_fails_workflow_agent_composition(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "binding.agent.md",
+ """
+name: Binding
+description: An input binding cannot start an agent.
+trigger:
+ type: blob_input
+workflows:
+ enabled: true
+""",
+ )
+
+ with pytest.raises(ValueError, match=r"trigger\.type.*blob_input"):
+ create_function_app(tmp_path)
+
+
+def test_internal_agent_can_enable_workflows_when_referenced_as_subagent(tmp_path) -> None:
+ _write_agent(
+ tmp_path,
+ "coordinator.agent.md",
+ """
+name: Coordinator
+description: Invokes the internal workflow agent.
+builtin_endpoints:
+ chat_api: true
+subagents:
+ - agent: internal
+""",
+ )
+ _write_agent(
+ tmp_path,
+ "internal.agent.md",
+ """
+name: Internal
+description: Owns workflows without a direct invocation surface.
+workflows:
+ enabled: true
+""",
+ )
+
+ app = create_function_app(tmp_path)
+
+ assert isinstance(app, df.DFApp)
+ assert _function_names(app).count(engine.ORCHESTRATOR_NAME) == 1
+
+
+def _resolved(
+ slug: str,
+ *,
+ tools_enabled: tuple[str, ...] = (),
+ subagents: tuple[str, ...] = (),
+) -> tuple[ResolvedAgent, AgentCapabilities]:
+ workflow_tools = [
+ WorkflowTool(name, f"{name} description", lambda args, name=name: {name: args})
+ for name in tools_enabled
+ ]
+ resolved = ResolvedAgent(
+ name=slug,
+ slug=slug,
+ description=f"{slug} description",
+ trigger=TriggerSpec(type="timer_trigger", args={"schedule": "0 * * * * *"}),
+ instructions=f"{slug} instructions",
+ is_main=slug == "main",
+ builtin_endpoints=BuiltinEndpointsConfig(),
+ model=None,
+ timeout=30,
+ enabled_mcp_names=[],
+ enabled_skills_names=[],
+ tool_filter=ToolsFilter(),
+ workflows=WorkflowConfig(
+ enabled=True,
+ subagents=tuple(WorkflowSubagentRef(agent=agent) for agent in subagents),
+ ),
+ sandbox_config=None,
+ input_schema=None,
+ response_schema=None,
+ response_example=None,
+ source_file=f"{slug}.agent.md",
+ )
+ return resolved, AgentCapabilities(filtered_workflow_tools=workflow_tools)
+
+
+def test_agent_policy_catalog_is_immutable_and_keeps_agent_grants_independent() -> None:
+ owner_a, capabilities_a = _resolved(
+ "agent_a",
+ tools_enabled=("shared",),
+ subagents=("specialist_a",),
+ )
+ agent_b, capabilities_b = _resolved(
+ "owner_b",
+ tools_enabled=("shared", "only_b"),
+ subagents=("specialist_b",),
+ )
+ specialist_a, specialist_capabilities_a = _resolved("specialist_a")
+ specialist_a.workflows = None
+ specialist_b, specialist_capabilities_b = _resolved("specialist_b")
+ specialist_b.workflows = None
+ catalog = build_catalog(
+ {
+ "owner_a": CatalogEntry(owner_a, capabilities_a),
+ "owner_b": CatalogEntry( agent_b, capabilities_b),
+ "specialist_a": CatalogEntry(specialist_a, specialist_capabilities_a),
+ "specialist_b": CatalogEntry(specialist_b, specialist_capabilities_b),
+ }
+ )
+ handlers = integration.build_workflow_handler_catalog(
+ [
+ WorkflowTool("shared", "shared description", lambda args: args),
+ WorkflowTool("only_b", "only B description", lambda args: args),
+ ]
+ )
+
+ policies = integration.build_workflow_agent_policy_catalog(catalog, handlers)
+
+ assert isinstance(policies, MappingProxyType)
+ assert policies["owner_a"].allowed_tools == frozenset({"shared"})
+ assert policies["owner_b"].allowed_tools == frozenset({"shared", "only_b"})
+ assert policies["owner_a"].allowed_subagents == frozenset({"specialist_a"})
+ assert policies["owner_b"].allowed_subagents == frozenset({"specialist_b"})
+ with pytest.raises(TypeError):
+ policies["new"] = schema.WorkflowPlanPolicy(frozenset()) # type: ignore[index]
+
+
+def test_owner_addenda_render_only_owner_specific_tools_and_subagents() -> None:
+ owner_a, capabilities_a = _resolved(
+ "owner_a",
+ tools_enabled=("tool_a",),
+ subagents=("specialist_a",),
+ )
+ owner_b, capabilities_b = _resolved(
+ "owner_b",
+ tools_enabled=("tool_b",),
+ subagents=("specialist_b",),
+ )
+ specialist_a, specialist_capabilities_a = _resolved("specialist_a")
+ specialist_a.workflows = None
+ specialist_b, specialist_capabilities_b = _resolved("specialist_b")
+ specialist_b.workflows = None
+ catalog = build_catalog(
+ {
+ "owner_a": CatalogEntry(owner_a, capabilities_a),
+ "owner_b": CatalogEntry(owner_b, capabilities_b),
+ "specialist_a": CatalogEntry(
+ specialist_a,
+ specialist_capabilities_a,
+ ),
+ "specialist_b": CatalogEntry(
+ specialist_b,
+ specialist_capabilities_b,
+ ),
+ }
+ )
+ handlers = integration.build_workflow_handler_catalog(
+ [
+ WorkflowTool("tool_a", "Tool A", lambda args: args),
+ WorkflowTool("tool_b", "Tool B", lambda args: args),
+ ]
+ )
+ policies = integration.build_workflow_agent_policy_catalog(catalog, handlers)
+
+ agent_a_integration = integration.build_workflow_agent_integration(
+ policies["owner_a"],
+ handlers,
+ )
+ agent_b_integration = integration.build_workflow_agent_integration(
+ policies["owner_b"],
+ handlers,
+ )
+
+ for addendum in (
+ agent_a_integration.chat_system_addendum,
+ agent_a_integration.trigger_system_addendum,
+ ):
+ assert addendum is not None
+ assert "`tool_a`" in addendum
+ assert "`specialist_a`" in addendum
+ assert "`tool_b`" not in addendum
+ assert "`specialist_b`" not in addendum
+ for addendum in (
+ agent_b_integration.chat_system_addendum,
+ agent_b_integration.trigger_system_addendum,
+ ):
+ assert addendum is not None
+ assert "`tool_b`" in addendum
+ assert "`specialist_b`" in addendum
+ assert "`tool_a`" not in addendum
+ assert "`specialist_a`" not in addendum
+
+
+def test_agent_and_session_identity_uses_distinct_128_bit_prefixes() -> None:
+ first = context.new_workflow_instance_id("agent_a", "same-session")
+ second = context.new_workflow_instance_id("agent_b", "same-session")
+
+ first_prefix = first.split("-", 1)[0]
+ second_prefix = second.split("-", 1)[0]
+ assert len(first_prefix) == 32
+ assert len(second_prefix) == 32
+ assert first_prefix != second_prefix
+ assert context.session_instance_prefix("a", "bc") != context.session_instance_prefix(
+ "ab", "c"
+ )
+ assert context.workflow_matches_agent_session("agent_a", "same-session", first)
+ assert not context.workflow_matches_agent_session("agent_b", "same-session", first)
+ assert not context.workflow_matches_agent_session(
+ "owner_a",
+ "same-session",
+ "0123456789ab-00000000000000000000000000000000",
+ )
+
+
+class _StatusClient:
+ def __init__(self, statuses: list[Any]) -> None:
+ self.statuses = statuses
+ self.status_by_id = {status.instance_id: status for status in statuses}
+ self.terminated: list[str] = []
+ self.canceled: list[str] = []
+
+ async def get_status_all(self) -> list[Any]:
+ return self.statuses
+
+ async def get_status(self, workflow_id: str) -> Any:
+ return self.status_by_id.get(workflow_id)
+
+ async def terminate(self, workflow_id: str, reason: str) -> None:
+ self.terminated.append(workflow_id)
+
+ async def raise_event(self, workflow_id: str, event: str, reason: str) -> None:
+ self.canceled.append(workflow_id)
+
+
+class _Status:
+ def __init__(self, instance_id: str) -> None:
+ self.instance_id = instance_id
+ self.runtime_status = "Running"
+ self.custom_status = None
+ self.output = None
+ self.created_time = None
+ self.last_updated_time = None
+
+
+@pytest.mark.asyncio
+async def test_same_session_cross_agent_management_is_not_found() -> None:
+ workflow_id = context.new_workflow_instance_id("agent_a", "same-session")
+ client = _StatusClient([_Status(workflow_id)])
+ agent_b = context.WorkflowSessionContext(
+ workflow_agent_slug="agent_b",
+ session_id="same-session",
+ agent_name="Agent B",
+ durable_client=client,
+ )
+
+ assert await tools.fetch_session_workflows(client, "agent_b", "same-session") == []
+ assert (
+ await tools.fetch_session_workflow_status(
+ client, "agent_b", "same-session", workflow_id
+ )
+ is None
+ )
+ status = await tools.get_workflow_status(
+ tools.GetWorkflowStatusParams(workflow_id=workflow_id), agent_b
+ )
+ cancel = await tools.cancel_workflow(
+ tools.CancelWorkflowParams(workflow_id=workflow_id), agent_b
+ )
+ terminate = await tools.terminate_workflow(
+ tools.TerminateWorkflowParams(workflow_id=workflow_id), agent_b
+ )
+
+ assert '"status": 404' in status
+ assert '"status": 404' in cancel
+ assert '"status": 404' in terminate
+ assert client.canceled == []
+ assert client.terminated == []
+
+
+@pytest.mark.asyncio
+async def test_active_count_is_isolated_by_agent_under_shared_session() -> None:
+ client = _StatusClient(
+ [_Status(context.new_workflow_instance_id("agent_a", "same-session"))]
+ )
+
+ assert (
+ await tools.count_active_session_workflows(
+ client,
+ "agent_a",
+ "same-session",
+ )
+ == 1
+ )
+ assert (
+ await tools.count_active_session_workflows(
+ client,
+ "agent_b",
+ "same-session",
+ )
+ == 0
+ )
diff --git a/tests/test_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py
new file mode 100644
index 00000000..e973e7e2
--- /dev/null
+++ b/tests/test_per_agent_workflows_sample.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import azure.durable_functions as df
+import pytest
+
+from azure_functions_agents.app import create_function_app
+from azure_functions_agents.config.loader import load_agent_specs
+from azure_functions_agents.discovery.tools import (
+ clear_tool_discovery_cache,
+ discover_project_tools,
+)
+
+SAMPLE_ROOT = Path(__file__).resolve().parents[1] / "samples" / "per-agent-workflows"
+SAMPLE_SRC = SAMPLE_ROOT / "src"
+
+INCIDENT_ONLY = {
+ "get_incident_logs",
+ "get_incident_metrics",
+ "get_incident_deployments",
+ "compile_incident_report",
+}
+RELEASE_ONLY = {
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+ "get_release_change_window",
+ "compile_release_dossier",
+}
+
+
+def _workflow_tools() -> dict[str, Any]:
+ clear_tool_discovery_cache()
+ return {
+ tool.name: tool.handler
+ for tool in discover_project_tools(SAMPLE_SRC).workflow_tools
+ }
+
+
+def test_sample_has_two_non_main_workflow_agents_with_distinct_policies() -> None:
+ specs = load_agent_specs(SAMPLE_SRC, strict=True)
+ by_slug = {Path(spec.source_file).name.removesuffix(".agent.md"): spec for spec in specs}
+
+ assert not any(spec.is_main for spec in specs)
+ assert set(by_slug) == {
+ "incident_commander",
+ "release_manager",
+ "incident_evidence_analyst",
+ "release_risk_reviewer",
+ }
+
+ incident = by_slug["incident_commander"]
+ release = by_slug["release_manager"]
+ assert incident.builtin_endpoints is not None
+ assert incident.builtin_endpoints.debug_chat_ui is True
+ assert incident.builtin_endpoints.chat_api is True
+ assert release.builtin_endpoints is not None
+ assert release.builtin_endpoints.debug_chat_ui is True
+ assert release.builtin_endpoints.chat_api is True
+
+ assert incident.workflows is not None
+ assert incident.workflows.enabled is True
+ assert set(incident.workflows.exclude) == RELEASE_ONLY
+ assert [ref.agent for ref in incident.workflows.subagents] == [
+ "incident_evidence_analyst"
+ ]
+
+ assert release.workflows is not None
+ assert release.workflows.enabled is True
+ assert set(release.workflows.exclude) == INCIDENT_ONLY
+ assert [ref.agent for ref in release.workflows.subagents] == [
+ "release_risk_reviewer"
+ ]
+
+
+def test_sample_registers_each_owner_and_one_durable_engine() -> None:
+ app = create_function_app(SAMPLE_SRC)
+ names = [function.get_function_name() for function in app.get_functions()]
+
+ assert isinstance(app, df.DFApp)
+ assert names.count("agents_workflow_orchestrator") == 1
+ assert names.count("agents_workflow_run_tool") == 1
+ assert names.count("agents_workflow_run_sub_agent") == 1
+ for owner in ("incident_commander", "release_manager"):
+ assert f"agent_{owner}_builtin_chat" in names
+ assert f"agent_{owner}_builtin_workflows" in names
+ assert f"agent_{owner}_builtin_workflow_status" in names
+
+
+def test_incident_fake_tools_are_deterministic_and_build_structured_report() -> None:
+ tools = _workflow_tools()
+ request = {"incident_id": "INC-4821", "service": "checkout-api"}
+
+ logs = tools["get_incident_logs"](request)
+ metrics = tools["get_incident_metrics"](request)
+ deployments = tools["get_incident_deployments"](request)
+ assert tools["get_incident_logs"](request) == logs
+ assert tools["get_incident_metrics"](request) == metrics
+ assert tools["get_incident_deployments"](request) == deployments
+
+ report = tools["compile_incident_report"](
+ {
+ **request,
+ "logs": logs,
+ "metrics": metrics,
+ "deployments": deployments,
+ "specialist_analysis": {
+ "agent": "incident_evidence_analyst",
+ "text": "The deployment and saturation evidence correlate.",
+ },
+ }
+ )
+ assert report["marker"] == "INCIDENT_REPORT_READY"
+ assert report["incident_id"] == "INC-4821"
+ assert report["service"] == "checkout-api"
+ assert report["severity"] == "SEV2"
+ assert report["decision"] == "ROLLBACK"
+ assert report["specialist"]["agent"] == "incident_evidence_analyst"
+
+ for field, value in (
+ ("incident_id", "INC-WRONG"),
+ ("service", "inventory-api"),
+ ):
+ wrong_logs = {**logs, field: value}
+ with pytest.raises(ValueError, match=r"get_incident_logs.*identity"):
+ tools["compile_incident_report"](
+ {
+ **request,
+ "logs": wrong_logs,
+ "metrics": metrics,
+ "deployments": deployments,
+ "specialist_analysis": {
+ "agent": "incident_evidence_analyst",
+ "text": "The deployment and saturation evidence correlate.",
+ },
+ }
+ )
+
+
+def test_release_fake_tools_are_deterministic_and_build_go_no_go_dossier() -> None:
+ tools = _workflow_tools()
+ request = {"release_id": "REL-2026.08.11", "service": "checkout-api"}
+
+ pull_requests = tools["get_release_pull_requests"](request)
+ tests = tools["get_release_test_results"](request)
+ vulnerabilities = tools["get_release_vulnerabilities"](request)
+ change_window = tools["get_release_change_window"](request)
+ assert tools["get_release_pull_requests"](request) == pull_requests
+ assert tools["get_release_test_results"](request) == tests
+ assert tools["get_release_vulnerabilities"](request) == vulnerabilities
+ assert tools["get_release_change_window"](request) == change_window
+
+ dossier = tools["compile_release_dossier"](
+ {
+ **request,
+ "pull_requests": pull_requests,
+ "tests": tests,
+ "vulnerabilities": vulnerabilities,
+ "change_window": change_window,
+ "specialist_analysis": {
+ "agent": "release_risk_reviewer",
+ "text": "The open critical vulnerability is a release blocker.",
+ },
+ }
+ )
+ assert dossier["marker"] == "RELEASE_DOSSIER_READY"
+ assert dossier["release_id"] == "REL-2026.08.11"
+ assert dossier["service"] == "checkout-api"
+ assert dossier["decision"] == "NO_GO"
+ assert dossier["specialist"]["agent"] == "release_risk_reviewer"
+
+ for field, value in (
+ ("release_id", "REL-WRONG"),
+ ("service", "inventory-api"),
+ ):
+ wrong_tests = {**tests, field: value}
+ with pytest.raises(ValueError, match=r"get_release_test_results.*identity"):
+ tools["compile_release_dossier"](
+ {
+ **request,
+ "pull_requests": pull_requests,
+ "tests": wrong_tests,
+ "vulnerabilities": vulnerabilities,
+ "change_window": change_window,
+ "specialist_analysis": {
+ "agent": "release_risk_reviewer",
+ "text": "The open critical vulnerability is a release blocker.",
+ },
+ }
+ )
+
+
+def test_sample_runtime_files_and_readme_are_complete() -> None:
+ for name in (
+ ".funcignore",
+ "function_app.py",
+ "host.json",
+ "host.dts.json",
+ "local.settings.template.json",
+ "requirements.txt",
+ ):
+ assert (SAMPLE_SRC / name).is_file()
+ assert not (SAMPLE_SRC / "main.agent.md").exists()
+ readme = (SAMPLE_ROOT / "README.md").read_text(encoding="utf-8")
+ for required in (
+ "Engineering Operations Hub",
+ "Architecture",
+ "Incident workflow",
+ "Release workflow",
+ "INCIDENT_REPORT_READY",
+ "RELEASE_DOSSIER_READY",
+ "Troubleshooting",
+ ):
+ assert required in readme
+ assert "Python 3.13+" in readme
+
+ requirements = (SAMPLE_SRC / "requirements.txt").read_text(encoding="utf-8")
+ assert requirements.strip() == "-e ../../..[monitor]"
+
+ settings = json.loads(
+ (SAMPLE_SRC / "local.settings.template.json").read_text(encoding="utf-8")
+ )
+ assert settings["Values"]["AzureWebJobsStorage"] == "UseDevelopmentStorage=true"
+ assert settings["Values"]["TASKHUB_NAME"] == "engineeringopshub"
diff --git a/tests/test_per_agent_workflows_verify.py b/tests/test_per_agent_workflows_verify.py
new file mode 100644
index 00000000..6c0433f8
--- /dev/null
+++ b/tests/test_per_agent_workflows_verify.py
@@ -0,0 +1,391 @@
+"""Pure tests for the per-agent workflow sample verifier."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+VERIFY_SCRIPT = REPO_ROOT / "tests" / "scripts" / "verify_per_agent_workflows.py"
+
+
+def _load_verify_module() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("per_agent_workflows_verify", VERIFY_SCRIPT)
+ assert spec is not None
+ assert spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_build_emulator_commands_is_isolated_and_backend_specific() -> None:
+ verify = _load_verify_module()
+
+ storage = verify.build_emulator_commands("test-run", "storage")
+ dts = verify.build_emulator_commands("test-run", "dts")
+
+ assert "engineering-ops-azurite-test-run" in storage.azurite
+ assert storage.dts is None
+ assert "127.0.0.1::10000" in storage.azurite
+ assert dts.dts is not None
+ assert "engineering-ops-dts-test-run" in dts.dts
+ assert "DTS_TASK_HUB_NAMES=engineeringopshub" in dts.dts
+ assert "127.0.0.1::8080" in dts.dts
+ assert "127.0.0.1::8082" in dts.dts
+
+
+def test_host_environment_prepends_current_checkout_without_dropping_pythonpath(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ verify = _load_verify_module()
+ existing = os.pathsep.join(("first-existing", "second-existing"))
+ monkeypatch.setenv("PYTHONPATH", existing)
+
+ environment = verify.build_host_environment()
+
+ paths = environment["PYTHONPATH"].split(os.pathsep)
+ assert Path(paths[0]).resolve() == (verify.REPO_ROOT / "src").resolve()
+ assert paths[1:] == ["first-existing", "second-existing"]
+
+
+def _clear_provider_environment(
+ monkeypatch: pytest.MonkeyPatch,
+ verify: ModuleType,
+) -> None:
+ for key in verify.PROVIDER_KEYS:
+ monkeypatch.delenv(key, raising=False)
+
+
+def _use_template_settings(
+ monkeypatch: pytest.MonkeyPatch,
+ verify: ModuleType,
+ tmp_path: Path,
+) -> None:
+ template = verify.SAMPLE_SRC / "local.settings.template.json"
+ (tmp_path / template.name).write_text(
+ template.read_text(encoding="utf-8"),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(verify, "SAMPLE_SRC", tmp_path)
+
+
+@pytest.mark.parametrize(
+ ("values", "provider"),
+ [
+ (
+ {
+ "FOUNDRY_PROJECT_ENDPOINT": "https://example.test/foundry",
+ "FOUNDRY_MODEL": "foundry-model",
+ },
+ "foundry",
+ ),
+ (
+ {
+ "AZURE_OPENAI_ENDPOINT": "https://example.test/openai",
+ "AZURE_OPENAI_DEPLOYMENT": "azure-deployment",
+ "AZURE_OPENAI_API_VERSION": "2026-01-01",
+ },
+ "azure_openai",
+ ),
+ (
+ {
+ "OPENAI_API_KEY": "not-a-real-secret",
+ "OPENAI_CHAT_MODEL_ID": "openai-model",
+ },
+ "openai",
+ ),
+ ],
+)
+def test_provider_values_select_environment_provider_over_template_default(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ values: dict[str, str],
+ provider: str,
+) -> None:
+ verify = _load_verify_module()
+ _clear_provider_environment(monkeypatch, verify)
+ _use_template_settings(monkeypatch, verify, tmp_path)
+ for key, value in values.items():
+ monkeypatch.setenv(key, value)
+
+ resolved = verify._provider_values()
+
+ assert resolved["AZURE_FUNCTIONS_AGENTS_PROVIDER"] == provider
+
+
+def test_provider_values_honors_explicit_provider_when_multiple_are_configured(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ verify = _load_verify_module()
+ _clear_provider_environment(monkeypatch, verify)
+ _use_template_settings(monkeypatch, verify, tmp_path)
+ monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_PROVIDER", "foundry")
+ monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://example.test/foundry")
+ monkeypatch.setenv("FOUNDRY_MODEL", "foundry-model")
+ monkeypatch.setenv("OPENAI_API_KEY", "not-a-real-secret")
+ monkeypatch.setenv("OPENAI_CHAT_MODEL_ID", "openai-model")
+
+ resolved = verify._provider_values()
+
+ assert resolved["AZURE_FUNCTIONS_AGENTS_PROVIDER"] == "foundry"
+
+
+@pytest.mark.parametrize(
+ ("missing", "message"),
+ [
+ ("AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_DEPLOYMENT"),
+ ("AZURE_OPENAI_API_VERSION", "AZURE_OPENAI_API_VERSION"),
+ ],
+)
+def test_provider_values_requires_complete_azure_openai_configuration(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ missing: str,
+ message: str,
+) -> None:
+ verify = _load_verify_module()
+ _clear_provider_environment(monkeypatch, verify)
+ _use_template_settings(monkeypatch, verify, tmp_path)
+ values = {
+ "AZURE_OPENAI_ENDPOINT": "https://example.test/openai",
+ "AZURE_OPENAI_DEPLOYMENT": "azure-deployment",
+ "AZURE_OPENAI_API_VERSION": "2026-01-01",
+ }
+ values.pop(missing)
+ for key, value in values.items():
+ monkeypatch.setenv(key, value)
+
+ with pytest.raises(RuntimeError, match=message):
+ verify._provider_values()
+
+
+def test_provider_values_requires_openai_model(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ verify = _load_verify_module()
+ _clear_provider_environment(monkeypatch, verify)
+ _use_template_settings(monkeypatch, verify, tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "not-a-real-secret")
+
+ with pytest.raises(RuntimeError, match="OPENAI_CHAT_MODEL_ID"):
+ verify._provider_values()
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"tool_calls": [{"result": '{"workflow_id":"%s"}'}]},
+ {"response": "Started workflow `%s`."},
+ {"nested": [{"workflow_id": "%s"}]},
+ ],
+)
+def test_extract_workflow_id_handles_nested_and_text_responses(
+ payload: dict[str, object],
+) -> None:
+ verify = _load_verify_module()
+ workflow_id = "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc"
+ rendered = str(payload).replace("%s", workflow_id)
+
+ assert verify.extract_workflow_id(rendered) == workflow_id
+
+
+def test_extract_workflow_id_rejects_legacy_hyphenated_uuid_suffix() -> None:
+ verify = _load_verify_module()
+
+ with pytest.raises(RuntimeError, match="valid workflow ID"):
+ verify.extract_workflow_id(
+ "0123456789abcdef0123456789abcdef-12345678-1234-1234-1234-123456789abc"
+ )
+
+
+def test_validate_terminal_result_checks_marker_structure_and_capabilities() -> None:
+ verify = _load_verify_module()
+ workflow_id = "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc"
+ envelope = {
+ "workflow_id": workflow_id,
+ "runtime_status": "Completed",
+ "output": {
+ "results": {
+ "logs": {
+ "capability": "get_incident_logs",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "metrics": {
+ "capability": "get_incident_metrics",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "deployments": {
+ "capability": "get_incident_deployments",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "analysis": {
+ "agent": "incident_evidence_analyst",
+ "text": "correlated",
+ },
+ "report": {
+ "marker": "INCIDENT_REPORT_READY",
+ "report_type": "incident",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ "decision": "ROLLBACK",
+ "capability": "compile_incident_report",
+ },
+ }
+ },
+ }
+
+ verify.validate_terminal_result("incident_commander", envelope)
+
+ envelope["output"]["results"]["wrong"] = { # type: ignore[index]
+ "capability": "get_release_vulnerabilities"
+ }
+ with pytest.raises(RuntimeError, match="unauthorized capabilities"):
+ verify.validate_terminal_result("incident_commander", envelope)
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("incident_id", "INC-WRONG"),
+ ("service", "inventory-api"),
+ ],
+)
+def test_validate_terminal_result_rejects_incident_evidence_identity_mismatch(
+ field: str,
+ value: str,
+) -> None:
+ verify = _load_verify_module()
+ envelope = {
+ "runtime_status": "Completed",
+ "output": {
+ "results": {
+ "logs": {
+ "capability": "get_incident_logs",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "metrics": {
+ "capability": "get_incident_metrics",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "deployments": {
+ "capability": "get_incident_deployments",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ },
+ "analysis": {"agent": "incident_evidence_analyst", "text": "correlated"},
+ "report": {
+ "marker": "INCIDENT_REPORT_READY",
+ "report_type": "incident",
+ "incident_id": "INC-4821",
+ "service": "checkout-api",
+ "decision": "ROLLBACK",
+ "capability": "compile_incident_report",
+ },
+ }
+ },
+ }
+ envelope["output"]["results"]["metrics"][field] = value # type: ignore[index]
+
+ with pytest.raises(RuntimeError, match=r"evidence.*identity"):
+ verify.validate_terminal_result("incident_commander", envelope)
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("release_id", "REL-WRONG"),
+ ("service", "inventory-api"),
+ ],
+)
+def test_validate_terminal_result_rejects_release_evidence_identity_mismatch(
+ field: str,
+ value: str,
+) -> None:
+ verify = _load_verify_module()
+ evidence_names = (
+ "get_release_pull_requests",
+ "get_release_test_results",
+ "get_release_vulnerabilities",
+ "get_release_change_window",
+ )
+ results = {
+ name: {
+ "capability": name,
+ "release_id": "REL-2026.08.11",
+ "service": "checkout-api",
+ }
+ for name in evidence_names
+ }
+ results["review"] = {"agent": "release_risk_reviewer", "text": "blocked"}
+ results["dossier"] = {
+ "marker": "RELEASE_DOSSIER_READY",
+ "report_type": "release_readiness",
+ "release_id": "REL-2026.08.11",
+ "service": "checkout-api",
+ "decision": "NO_GO",
+ "capability": "compile_release_dossier",
+ }
+ results["get_release_vulnerabilities"][field] = value
+
+ envelope = {
+ "runtime_status": "Completed",
+ "output": {"results": results},
+ }
+ with pytest.raises(RuntimeError, match=r"evidence.*identity"):
+ verify.validate_terminal_result("release_manager", envelope)
+
+
+def test_validate_workflow_agent_list_rejects_cross_agent_exposure() -> None:
+ verify = _load_verify_module()
+ incident_id = (
+ "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc"
+ )
+ release_id = (
+ "fedcba9876543210fedcba9876543210-12345678123412341234123456789abc"
+ )
+
+ verify.validate_workflow_agent_list(
+ {"workflows": [{"workflow_id": incident_id}]},
+ incident_id,
+ release_id,
+ )
+ with pytest.raises(RuntimeError, match="exposed another agent"):
+ verify.validate_workflow_agent_list(
+ {"workflows": [{"workflow_id": incident_id}, {"workflow_id": release_id}]},
+ incident_id,
+ release_id,
+ )
+
+
+def test_prepare_host_config_removes_local_dts_provider_for_storage(tmp_path) -> None:
+ verify = _load_verify_module()
+ host_config = {
+ "version": "2.0",
+ "extensions": {
+ "durableTask": {
+ "hubName": "%TASKHUB_NAME%",
+ "storageProvider": {
+ "type": "azureManaged",
+ "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
+ },
+ }
+ },
+ }
+ (tmp_path / "host.json").write_text(json.dumps(host_config), encoding="utf-8")
+
+ verify.prepare_host_config(tmp_path, "storage")
+
+ prepared = json.loads((tmp_path / "host.json").read_text(encoding="utf-8"))
+ assert "storageProvider" not in prepared["extensions"]["durableTask"]
diff --git a/tests/test_registration_capabilities.py b/tests/test_registration_capabilities.py
index 446f4c69..265940b2 100644
--- a/tests/test_registration_capabilities.py
+++ b/tests/test_registration_capabilities.py
@@ -99,6 +99,24 @@ def test_build_capabilities_filters_user_tools_by_exclude_name() -> None:
assert [t.name for t in capabilities.filtered_user_tools] == ["keep"]
+def test_build_capabilities_warns_for_unknown_workflow_exclude(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ resolved = _resolved()
+ resolved.workflows = SimpleNamespace(enabled=True, exclude=["missing"])
+
+ capabilities = build_capabilities(
+ resolved,
+ discovered_user_tools=[],
+ discovered_workflow_tools=[_named_tool("known")],
+ discovered_mcp_tools={},
+ discovered_skills={},
+ )
+
+ assert [tool.name for tool in capabilities.filtered_workflow_tools] == ["known"]
+ assert "unknown workflow tool name(s): ['missing']" in caplog.text
+
+
def test_build_capabilities_tools_disabled_returns_empty_user_tools() -> None:
capabilities = build_capabilities(
_resolved(tools_disabled=True),
diff --git a/tests/test_registration_endpoints.py b/tests/test_registration_endpoints.py
index 022fadda..7bcdb6fb 100644
--- a/tests/test_registration_endpoints.py
+++ b/tests/test_registration_endpoints.py
@@ -27,6 +27,10 @@
register_builtin_endpoints,
)
from azure_functions_agents.runner import _SESSION_ID_PATTERN
+from azure_functions_agents.workflows.context import (
+ new_workflow_instance_id,
+ session_instance_prefix,
+)
class FakeFunctionApp:
@@ -311,6 +315,7 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any:
# 0007 §4.3) -- matches round 2's B2 fix for delegated specialists.
assert calls["run_agent"]["agent_name"] == resolved.slug
assert calls["run_agent"]["agent_name"] != resolved.name
+ assert calls["run_agent"]["workflow_agent_slug"] == resolved.slug
def test_run_builtin_agent_stream_generates_session_id_before_building_sandbox_tools(
@@ -357,6 +362,7 @@ def fake_run_agent_stream(*args: Any, **kwargs: Any) -> str:
assert result == "stream"
# S1: same contract as the non-streaming builtin-agent test above.
assert calls["run_agent_stream"]["agent_name"] == resolved.slug
+ assert calls["run_agent_stream"]["workflow_agent_slug"] == resolved.slug
assert calls["run_agent_stream"]["agent_name"] != resolved.name
@@ -883,6 +889,7 @@ async def fake_run_builtin_agent(prompt: str, **kwargs: Any) -> Any:
assert response.status_code == 200
assert run_calls["kwargs"]["workflows_enabled"] is True
assert run_calls["kwargs"]["durable_client"] is mock_client
+ assert run_calls["kwargs"]["workflow_policy"] is None
def test_workflows_disabled_does_not_pass_client_to_run_builtin_agent(
@@ -1263,3 +1270,120 @@ def test_entra_workflow_endpoints_without_identity_are_unauthorized(
route = next(route for route in app.routes if route["route"] == name)
response = asyncio.run(route["handler"](DummyRequest({}), client=object()))
assert response.status_code == 401
+
+
+def test_workflow_status_endpoint_hides_same_session_other_owner(
+ tmp_path: Path,
+) -> None:
+ app = FakeFunctionApp()
+ resolved = _chat_api_agent(tmp_path, EndpointAuthConfig())
+ register_builtin_endpoints(
+ app,
+ resolved,
+ AgentCapabilities(),
+ slug="owner_b",
+ workflows_enabled=True,
+ )
+ route = next(
+ route
+ for route in app.routes
+ if route["route"] == "agents/owner_b/workflow-status"
+ )
+ request = DummyRequest({}, headers={"x-ms-session-id": "same-session"})
+ request.query_params = {
+ "workflow_id": new_workflow_instance_id("owner_a", "same-session")
+ }
+
+ response = asyncio.run(route["handler"](request, client=object()))
+
+ assert response.status_code == 404
+
+
+def test_workflow_list_endpoint_hides_same_session_other_owner(
+ tmp_path: Path,
+) -> None:
+ class _Client:
+ async def get_status_all(self) -> list[Any]:
+ workflow_id = new_workflow_instance_id("owner_a", "same-session")
+ return [
+ SimpleNamespace(
+ instance_id=workflow_id,
+ runtime_status="Running",
+ custom_status=None,
+ output=None,
+ created_time=None,
+ last_updated_time=None,
+ )
+ ]
+
+ app = FakeFunctionApp()
+ resolved = _chat_api_agent(tmp_path, EndpointAuthConfig())
+ register_builtin_endpoints(
+ app,
+ resolved,
+ AgentCapabilities(),
+ slug="owner_b",
+ workflows_enabled=True,
+ )
+ route = next(
+ route
+ for route in app.routes
+ if route["route"] == "agents/owner_b/workflows"
+ )
+ request = DummyRequest({}, headers={"x-ms-session-id": "same-session"})
+
+ response = asyncio.run(route["handler"](request, client=_Client()))
+
+ assert response.status_code == 200
+ assert json.loads(response.body) == {"workflows": []}
+
+
+def test_workflow_list_endpoint_uses_resolved_workflow_agent_slug_not_route_slug(
+ tmp_path: Path,
+) -> None:
+ class _Client:
+ async def get_status_all(self) -> list[Any]:
+ workflow_id = new_workflow_instance_id(
+ "canonical-owner",
+ "same-session",
+ )
+ return [
+ SimpleNamespace(
+ instance_id=workflow_id,
+ runtime_status="Running",
+ custom_status=None,
+ output=None,
+ created_time=None,
+ last_updated_time=None,
+ )
+ ]
+
+ app = FakeFunctionApp()
+ resolved = _resolved_agent(
+ name="Owner",
+ slug="canonical-owner",
+ is_main=False,
+ builtin_endpoints=BuiltinEndpointsConfig(chat_api=True),
+ source_file=tmp_path / "route-owner.agent.md",
+ )
+ register_builtin_endpoints(
+ app,
+ resolved,
+ AgentCapabilities(),
+ slug="route-owner",
+ workflows_enabled=True,
+ )
+ route = next(
+ route
+ for route in app.routes
+ if route["route"] == "agents/route-owner/workflows"
+ )
+ request = DummyRequest({}, headers={"x-ms-session-id": "same-session"})
+
+ response = asyncio.run(route["handler"](request, client=_Client()))
+
+ assert response.status_code == 200
+ [workflow] = json.loads(response.body)["workflows"]
+ assert workflow["workflow_id"].startswith(
+ session_instance_prefix("canonical-owner", "same-session")
+ )
diff --git a/tests/test_registration_handlers.py b/tests/test_registration_handlers.py
index 9c8f980f..a8bc1532 100644
--- a/tests/test_registration_handlers.py
+++ b/tests/test_registration_handlers.py
@@ -619,6 +619,38 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any:
assert captured["agent_name"] != resolved.name
+def test_non_http_workflow_handler_threads_workflow_agent_slug(
+ monkeypatch: Any,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any:
+ captured.update(kwargs)
+ return SimpleNamespace(
+ content="ok",
+ session_id=kwargs["session_id"],
+ tool_calls=[],
+ )
+
+ monkeypatch.setattr(
+ "azure_functions_agents.registration._handlers._run_agent",
+ fake_run_agent,
+ )
+ resolved = _resolved_agent(response_schema=None, slug="queue-owner")
+ handler = make_agent_handler(
+ resolved,
+ "queue_trigger",
+ AgentCapabilities(),
+ workflows_enabled=True,
+ )
+ durable_client = object()
+
+ asyncio.run(handler({"message": "hello"}, client=durable_client))
+
+ assert captured["workflow_agent_slug"] == "queue-owner"
+ assert captured["workflow_durable_client"] is durable_client
+
+
def test_http_handler_passes_resolved_slug_not_display_name_as_agent_name(
monkeypatch: Any,
) -> None:
@@ -643,6 +675,39 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any:
assert captured["agent_name"] != resolved.name
+def test_http_workflow_handler_threads_workflow_agent_slug(
+ monkeypatch: Any,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any:
+ captured.update(kwargs)
+ return SimpleNamespace(content="ok", session_id=kwargs["session_id"])
+
+ monkeypatch.setattr(
+ "azure_functions_agents.registration._handlers._run_agent",
+ fake_run_agent,
+ )
+ resolved = _resolved_agent(response_schema=None, slug="http-owner")
+ handler = make_http_agent_handler(
+ resolved,
+ AgentCapabilities(),
+ workflows_enabled=True,
+ )
+ durable_client = object()
+
+ response = asyncio.run(
+ handler(
+ DummyRequest({"hello": "world"}),
+ client=durable_client,
+ )
+ )
+
+ assert response.status_code == 200
+ assert captured["workflow_agent_slug"] == "http-owner"
+ assert captured["workflow_durable_client"] is durable_client
+
+
def _principal_header(claims: list[dict[str, str]], *, auth_typ: str = "aad") -> str:
payload = json.dumps({"auth_typ": auth_typ, "claims": claims})
return base64.b64encode(payload.encode("utf-8")).decode("ascii")
diff --git a/tests/test_registration_triggers.py b/tests/test_registration_triggers.py
index 5bfc4d2a..9e853459 100644
--- a/tests/test_registration_triggers.py
+++ b/tests/test_registration_triggers.py
@@ -11,6 +11,7 @@
from azure_functions_agents.config.loader import load_agent_specs
from azure_functions_agents.config.merge import compose
from azure_functions_agents.config.schema import (
+ TRIGGER_TYPES,
BuiltinEndpointsConfig,
GlobalConfig,
ResolvedAgent,
@@ -28,6 +29,17 @@
)
+@pytest.mark.parametrize("trigger_type", sorted(TRIGGER_TYPES))
+def test_documented_trigger_types_have_sdk_decorators(trigger_type: str) -> None:
+ decorator_name = "route" if trigger_type == "http_trigger" else trigger_type
+ supported = callable(getattr(func.FunctionApp, decorator_name, None))
+ if trigger_type == "connector_trigger":
+ supported = supported or callable(
+ getattr(func.FunctionApp, "generic_trigger", None)
+ )
+ assert supported
+
+
class FakeFunctionApp:
def __init__(self, *, function_name_error: Exception | None = None) -> None:
self.function_names: list[str] = []
diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py
index 6640f047..290d8cdd 100644
--- a/tests/test_workflow_engine.py
+++ b/tests/test_workflow_engine.py
@@ -5,6 +5,7 @@
import pytest
+from azure_functions_agents._function_tool import WorkflowTool
from azure_functions_agents.config.schema import (
BuiltinEndpointsConfig,
ResolvedAgent,
@@ -12,8 +13,12 @@
)
from azure_functions_agents.registration.capabilities import AgentCapabilities
from azure_functions_agents.registration.catalog import CatalogEntry, build_catalog
-from azure_functions_agents.workflows import engine
-from azure_functions_agents.workflows.schema import SUB_AGENT_TASK_TYPE
+from azure_functions_agents.workflows import engine, integration
+from azure_functions_agents.workflows.schema import (
+ SUB_AGENT_TASK_TYPE,
+ TOOL_TASK_TYPE,
+ WorkflowPlanPolicy,
+)
class _FakeApp:
@@ -56,9 +61,20 @@ def _catalog(*slugs: str):
)
-def _registered_function(name: str, *, catalog=None) -> Callable[..., Any]:
+def _registered_function(
+ name: str,
+ *,
+ catalog=None,
+ workflow_agent_policies=None,
+ handler_catalog=None,
+) -> Callable[..., Any]:
app = _FakeApp()
- engine.register_workflows(app, catalog=catalog)
+ engine.register_workflows(
+ app,
+ catalog=catalog,
+ workflow_agent_policies=workflow_agent_policies,
+ handler_catalog=handler_catalog,
+ )
[blueprint] = app.blueprints
for builder in blueprint._function_builders:
function = builder._function
@@ -92,6 +108,12 @@ async def run_leaf(
activity = _registered_function(
engine.SUB_AGENT_ACTIVITY_NAME,
catalog=_catalog("pr_status_analyst"),
+ workflow_agent_policies={
+ "coordinator": WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset({"pr_status_analyst"}),
+ )
+ },
)
result = await activity(
@@ -100,6 +122,7 @@ async def run_leaf(
"agent": "pr_status_analyst",
"task": "Analyze PR 117.",
"workflow_id": "workflow-1",
+ "workflow_agent_slug": "coordinator",
}
)
@@ -125,6 +148,12 @@ async def test_sub_agent_activity_fails_closed_on_catalog_miss() -> None:
activity = _registered_function(
engine.SUB_AGENT_ACTIVITY_NAME,
catalog=_catalog("known"),
+ workflow_agent_policies={
+ "coordinator": WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset({"missing"}),
+ )
+ },
)
with pytest.raises(RuntimeError, match="not available"):
@@ -134,6 +163,55 @@ async def test_sub_agent_activity_fails_closed_on_catalog_miss() -> None:
"agent": "missing",
"task": "Analyze PR 117.",
"workflow_id": "workflow-1",
+ "workflow_agent_slug": "coordinator",
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_sub_agent_activity_rejects_revoked_owner_grant() -> None:
+ activity = _registered_function(
+ engine.SUB_AGENT_ACTIVITY_NAME,
+ catalog=_catalog("pr_status_analyst"),
+ workflow_agent_policies={
+ "coordinator": WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset(),
+ )
+ },
+ )
+
+ with pytest.raises(RuntimeError, match="not authorized"):
+ await activity(
+ {
+ "id": "analyze_pr",
+ "agent": "pr_status_analyst",
+ "task": "Analyze PR 117.",
+ "workflow_id": "workflow-1",
+ "workflow_agent_slug": "coordinator",
+ }
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("workflow_agent_policies", [None, {}])
+async def test_sub_agent_activity_missing_agent_policy_fails_closed(
+ workflow_agent_policies,
+) -> None:
+ activity = _registered_function(
+ engine.SUB_AGENT_ACTIVITY_NAME,
+ catalog=_catalog("pr_status_analyst"),
+ workflow_agent_policies=workflow_agent_policies,
+ )
+
+ with pytest.raises(RuntimeError, match="agent policy"):
+ await activity(
+ {
+ "id": "analyze_pr",
+ "agent": "pr_status_analyst",
+ "task": "Analyze PR 117.",
+ "workflow_id": "workflow-1",
+ "workflow_agent_slug": "missing",
}
)
@@ -151,6 +229,12 @@ async def fail(*args: Any, **kwargs: Any) -> str:
activity = _registered_function(
engine.SUB_AGENT_ACTIVITY_NAME,
catalog=_catalog("pr_status_analyst"),
+ workflow_agent_policies={
+ "coordinator": WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset({"pr_status_analyst"}),
+ )
+ },
)
with pytest.raises(RuntimeError) as exc_info:
@@ -160,6 +244,7 @@ async def fail(*args: Any, **kwargs: Any) -> str:
"agent": "pr_status_analyst",
"task": "Analyze PR 117.",
"workflow_id": "workflow-1",
+ "workflow_agent_slug": "coordinator",
}
)
@@ -187,7 +272,7 @@ def __init__(
result_for: Callable[[str, dict[str, Any]], dict[str, Any]],
) -> None:
self.instance_id = "workflow-parent"
- self._input = {"tasks": tasks}
+ self._input = {"workflow_agent_slug": "coordinator", "tasks": tasks}
self._result_for = result_for
self.calls: list[tuple[str, dict[str, Any]]] = []
self.last_wave = _Task([])
@@ -229,6 +314,30 @@ def _run_orchestrator(
return stop.value
+def test_orchestrator_preserves_activity_failure() -> None:
+ class _FailedWaveContext(_FakeOrchestrationContext):
+ def task_all(self, tasks: list[_Task]) -> _Task:
+ self.last_wave = _Task(RuntimeError("activity authorization failed"))
+ return self.last_wave
+
+ context = _FailedWaveContext(
+ [
+ {
+ "id": "publish",
+ "type": TOOL_TASK_TYPE,
+ "tool": "publish",
+ "args": {},
+ "depends_on": [],
+ }
+ ],
+ lambda name, payload: {"id": payload["id"], "result": {"ok": True}},
+ )
+ orchestrator = _registered_function(engine.ORCHESTRATOR_NAME)
+
+ with pytest.raises(RuntimeError, match="activity authorization failed"):
+ _run_orchestrator(orchestrator, context)
+
+
def test_orchestrator_fans_out_sub_agents_and_reduces_templated_results() -> None:
tasks = [
{
@@ -295,9 +404,110 @@ def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]:
payload["workflow_id"] == "workflow-parent"
for _, payload in context.calls
)
+ assert all(
+ payload["workflow_agent_slug"] == "coordinator"
+ for _, payload in context.calls
+ )
assert context.statuses == [
"0/3 tasks done, running=analyze_117,analyze_118",
"2/3 tasks done, next=report",
"2/3 tasks done, running=report",
"3/3 tasks done",
]
+
+
+def test_orchestrator_threads_workflow_agent_slug_to_tool_activity() -> None:
+ tasks = [
+ {
+ "id": "publish",
+ "type": TOOL_TASK_TYPE,
+ "tool": "publish",
+ "args": {},
+ "depends_on": [],
+ }
+ ]
+ context = _FakeOrchestrationContext(
+ tasks,
+ lambda name, payload: {"id": payload["id"], "result": {"ok": True}},
+ )
+ orchestrator = _registered_function(engine.ORCHESTRATOR_NAME)
+
+ _run_orchestrator(orchestrator, context)
+
+ assert context.calls == [
+ (
+ "agents_workflow_run_tool",
+ {
+ "id": "publish",
+ "tool": "publish",
+ "args": {},
+ "workflow_agent_slug": "coordinator",
+ "workflow_id": "workflow-parent",
+ },
+ )
+ ]
+
+
+def test_tool_activity_reauthorizes_current_agent_policy() -> None:
+ handler_catalog = integration.build_workflow_handler_catalog(
+ [WorkflowTool("publish", "Publish", lambda args: {"published": args})]
+ )
+ allowed = _registered_function(
+ "agents_workflow_run_tool",
+ handler_catalog=handler_catalog,
+ workflow_agent_policies={
+ "workflow-agent": WorkflowPlanPolicy(
+ allowed_tools=frozenset({"publish"}),
+ allowed_subagents=frozenset(),
+ )
+ },
+ )
+ revoked = _registered_function(
+ "agents_workflow_run_tool",
+ handler_catalog=handler_catalog,
+ workflow_agent_policies={
+ "workflow-agent": WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset(),
+ )
+ },
+ )
+ payload = {
+ "id": "publish",
+ "tool": "publish",
+ "args": {"value": 1},
+ "workflow_agent_slug": "workflow-agent",
+ "workflow_id": "workflow-1",
+ }
+
+ assert allowed(payload) == {
+ "id": "publish",
+ "result": {"published": {"value": 1}},
+ }
+ with pytest.raises(RuntimeError, match="not authorized"):
+ revoked(payload)
+
+
+@pytest.mark.parametrize("workflow_agent_policies", [None, {}])
+def test_tool_activity_missing_agent_policy_fails_closed(
+ workflow_agent_policies,
+) -> None:
+ handler_catalog = integration.build_workflow_handler_catalog(
+ [WorkflowTool("publish", "Publish", lambda args: args)]
+ )
+ activity = _registered_function(
+ "agents_workflow_run_tool",
+ handler_catalog=handler_catalog,
+ workflow_agent_policies=workflow_agent_policies,
+ )
+
+ with pytest.raises(RuntimeError, match="agent policy"):
+ activity(
+ {
+ "id": "publish",
+ "tool": "publish",
+ "args": {},
+ "workflow_agent_slug": "missing",
+ "workflow_id": "workflow-1",
+ }
+ )
diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py
index ccb8c295..fba5397d 100644
--- a/tests/test_workflow_registry.py
+++ b/tests/test_workflow_registry.py
@@ -93,32 +93,31 @@ class _CappedDurableClient:
def __init__(self, statuses):
self.statuses = list(statuses)
self.started = False
+ self.start_kwargs = None
async def get_status_all(self, *args, **kwargs):
return self.statuses
async def start_new(self, *args, **kwargs):
self.started = True
+ self.start_kwargs = kwargs
return kwargs["instance_id"]
@pytest.fixture
def failing_workflow_session():
- session_id = "session-1"
- token = context.register_workflow_session(
- session_id,
- "test-agent",
- _FailingDurableClient(),
- )
- try:
- yield session_id
- finally:
- context.unregister_workflow_session(session_id, token)
+ return "session-1"
-def _registered_blueprint_function(name):
+def _registered_blueprint_function(
+ name,
+ *,
+ workflow_agent_policies=None,
+):
app = _FakeApp()
- engine.register_workflows(app)
+ engine.register_workflows(
+ app, workflow_agent_policies=workflow_agent_policies
+ )
[blueprint] = app.blueprints
for builder in blueprint._function_builders:
function = builder._function
@@ -136,6 +135,34 @@ def test_register_workflow_tool_rejects_collision():
registry.register_workflow_tool("alpha", "alpha tool again", _noop)
+def test_compatibility_session_registry_does_not_expose_or_confuse_registration_token():
+ first_client = object()
+ second_client = object()
+ first_token = context.register_workflow_session(
+ "workflow-agent",
+ "session",
+ "Workflow Agent",
+ first_client,
+ )
+ second_token = context.register_workflow_session(
+ "workflow-agent",
+ "session",
+ "Workflow Agent",
+ second_client,
+ )
+
+ registered = context.get_workflow_session("workflow-agent", "session")
+ assert registered is not None
+ assert registered.durable_client is second_client
+ assert not hasattr(registered, "token")
+
+ context.unregister_workflow_session("workflow-agent", "session", first_token)
+ assert context.get_workflow_session("workflow-agent", "session") is registered
+
+ context.unregister_workflow_session("workflow-agent", "session", second_token)
+ assert context.get_workflow_session("workflow-agent", "session") is None
+
+
def test_register_workflow_tool_rejects_reserved_name():
for reserved in registry.RESERVED_TOOL_NAMES:
with pytest.raises(ValueError, match="reserved"):
@@ -533,15 +560,35 @@ def exploding_tool(args):
raise RuntimeError(secret_message)
registry.register_workflow_tool("exploding", "Always fails.", exploding_tool)
- activity = _registered_blueprint_function("agents_workflow_run_tool")
+ activity = _registered_blueprint_function(
+ "agents_workflow_run_tool",
+ workflow_agent_policies={
+ "test-agent": schema.WorkflowPlanPolicy(
+ allowed_tools=frozenset({"exploding"}),
+ allowed_subagents=frozenset(),
+ )
+ },
+ )
with pytest.raises(RuntimeError) as excinfo:
- activity({"id": "explode", "tool": "exploding", "args": {}})
+ activity(
+ {
+ "id": "explode",
+ "tool": "exploding",
+ "args": {},
+ "workflow_agent_slug": "test-agent",
+ "workflow_id": "workflow-1",
+ }
+ )
assert str(excinfo.value) == "task 'explode': workflow-safe tool failed"
assert secret_message not in str(excinfo.value)
assert any(
- record.message == "workflow activity failed: id=explode tool=exploding"
+ record.message
+ == (
+ "workflow activity failed: workflow_id=workflow-1 "
+ "workflow_agent=test-agent id=explode tool=exploding"
+ )
and record.exc_info
and secret_message in str(record.exc_info[1])
for record in caplog.records
@@ -603,12 +650,15 @@ async def test_workflow_tools_log_durable_exceptions_without_returning_details(
failing_workflow_session, caplog, call_tool, expected_error, expected_log
):
registry.set_app_config(frozenset())
- workflow_id = context.new_workflow_instance_id(failing_workflow_session)
+ workflow_id = context.new_workflow_instance_id(
+ "test-agent",
+ failing_workflow_session,
+ )
session = context.WorkflowSessionContext(
+ workflow_agent_slug="test-agent",
session_id=failing_workflow_session,
agent_name="test-agent",
durable_client=_FailingDurableClient(),
- token="",
)
text_result = await call_tool(workflow_id, session)
@@ -616,7 +666,7 @@ async def test_workflow_tools_log_durable_exceptions_without_returning_details(
assert text_result == json.dumps({"error": expected_error})
assert _FailingDurableClient.secret not in text_result
assert any(
- record.message == expected_log
+ record.message.startswith(expected_log)
and record.exc_info
and _FailingDurableClient.secret in str(record.exc_info[1])
for record in caplog.records
@@ -628,7 +678,7 @@ async def test_start_workflow_rejects_new_workflow_when_session_active_cap_reach
session_id = "session-1"
statuses = [
_FakeStatus(
- context.new_workflow_instance_id(session_id),
+ context.new_workflow_instance_id("test-agent", session_id),
"Running",
updated_seconds=i,
)
@@ -636,10 +686,10 @@ async def test_start_workflow_rejects_new_workflow_when_session_active_cap_reach
]
client = _CappedDurableClient(statuses)
session = context.WorkflowSessionContext(
+ workflow_agent_slug="test-agent",
session_id=session_id,
agent_name="test-agent",
durable_client=client,
- token="",
)
registry.set_app_config(frozenset())
result = await tools.start_workflow(
@@ -664,10 +714,10 @@ async def get_status_all(self):
raise AssertionError("authorization must fail before Durable scheduling")
session = context.WorkflowSessionContext(
+ workflow_agent_slug="coordinator",
session_id="session-1",
agent_name="coordinator",
durable_client=_UnexpectedClient(),
- token="",
)
params = tools.StartWorkflowParams(
tasks=[
@@ -689,6 +739,37 @@ async def get_status_all(self):
assert "not authorized" in json.loads(result)["error"]
+@pytest.mark.asyncio
+async def test_start_workflow_threads_workflow_agent_slug_into_durable_input() -> None:
+ client = _CappedDurableClient([])
+ session = context.WorkflowSessionContext(
+ workflow_agent_slug="incident",
+ session_id="session-1",
+ agent_name="Incident",
+ durable_client=client,
+ )
+ policy = schema.WorkflowPlanPolicy(
+ allowed_tools=frozenset(),
+ allowed_subagents=frozenset(),
+ )
+
+ result = await tools.start_workflow(
+ tools.StartWorkflowParams(
+ tasks=[{"id": "pause", "type": "wait", "duration": "PT1S"}]
+ ),
+ session,
+ policy=policy,
+ )
+
+ assert "workflow_id" in json.loads(result)
+ assert client.start_kwargs["client_input"]["workflow_agent_slug"] == "incident"
+ assert client.start_kwargs["client_input"]["workflow_agent"] == {
+ "workflow_agent_slug": "incident",
+ "session_id": "session-1",
+ "agent_name": "Incident",
+ }
+
+
def test_start_workflow_params_survive_framework_default_materialization() -> None:
original = tools.StartWorkflowParams(
tasks=[
@@ -724,7 +805,7 @@ async def test_fetch_session_workflows_returns_newest_session_workflows_up_to_v1
other_session_id = "session-2"
statuses = [
_FakeStatus(
- context.new_workflow_instance_id(session_id),
+ context.new_workflow_instance_id("test-agent", session_id),
"Completed",
updated_seconds=i,
)
@@ -732,7 +813,7 @@ async def test_fetch_session_workflows_returns_newest_session_workflows_up_to_v1
]
statuses.extend(
_FakeStatus(
- context.new_workflow_instance_id(other_session_id),
+ context.new_workflow_instance_id("test-agent", other_session_id),
"Completed",
updated_seconds=100 + i,
)
@@ -740,7 +821,11 @@ async def test_fetch_session_workflows_returns_newest_session_workflows_up_to_v1
)
client = _CappedDurableClient(statuses)
- envelopes = await tools.fetch_session_workflows(client, session_id)
+ envelopes = await tools.fetch_session_workflows(
+ client,
+ "test-agent",
+ session_id,
+ )
assert len(envelopes) == 25
assert [env["last_updated_time"] for env in envelopes] == sorted(