From 2375aeb1cba42e75bed8741177e9600fcc1c7c73 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 16:23:31 -0700 Subject: [PATCH 01/18] docs: propose per-agent Dynamic Workflows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0009-per-agent-dynamic-workflows.md | 465 ++++++++++++++++++ docs/frds/README.md | 1 + 2 files changed, 466 insertions(+) create mode 100644 docs/frds/0009-per-agent-dynamic-workflows.md diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md new file mode 100644 index 00000000..18cbb5f2 --- /dev/null +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -0,0 +1,465 @@ +--- +frd: 0009 +title: Per-agent Dynamic Workflows +status: Draft +author: TsuyoshiUshio +created: 2026-08-10 +updated: 2026-08-10 +issues: + - "Azure/azure-functions-agents-runtime#109" + - "Azure/azure-functions-bucees-planning#1274" + - "Azure/azure-functions-bucees-planning#1275" +pull_requests: [] +branch: tsuyoshiushio-per-agent-dynamic-workflows +--- + +# FRD 0009 — Per-agent Dynamic Workflows + +## 1. Summary + +Allow any eligible `*.agent.md` agent, rather than only `main.agent.md`, to own +Dynamic Workflows independently. One Function App will register one Durable +engine and complete workflow handler inventory, while every workflow-enabled +agent receives an immutable owner-specific policy, prompt guidance, management +tools, and workflow ownership namespace keyed by its canonical +`ResolvedAgent.slug`. + +This change preserves the existing `workflows.enabled`, `workflows.exclude`, and +`workflows.subagents` authoring surface. It changes workflow identity and +management from session-only ownership to `(owner_slug, session_id)` ownership, +which cryptographically namespaces two agents receiving the same session ID so +they cannot see or control each other's workflows through application surfaces. + +## 2. Motivation / problem + +The runtime already supports Dynamic Workflow DAGs containing `tool`, `wait`, +and stateless leaf `sub_agent` tasks. Workflow-enabled agents can start those +plans from built-in chat, MCP, HTTP triggers, and non-interactive +Markdown-declared triggers. The runtime also already has: + +- a canonical, app-wide unique `ResolvedAgent.slug`; +- an immutable `AgentCatalog`; +- owner-shaped `WorkflowPlanPolicy` values containing workflow tool and + Workflow Sub Agent grants; and +- per-agent routes such as `/agents/{slug}/workflows`. + +Despite those foundations, `app.py` still honors `workflows.enabled: true` only +when `resolved.is_main` is true. A non-main agent receives a warning and no +workflow integration. + +Removing only that `is_main` check would be incorrect: + +- `build_workflow_integration()` currently registers the Durable blueprint, so + calling it for multiple agents would register the same Functions repeatedly; +- the workflow registry stores one process-global effective tool allowlist, so + one agent's `workflows.exclude` could affect another agent; +- workflow IDs are namespaced only by `session_id`, so two agents using the same + caller-provided session ID can pass each other's ownership-prefix checks; and +- Activities dispatch through shared handler and Agent catalogs without + rechecking the workflow owner's policy. + +The feature therefore requires an architectural separation between app-wide +execution inventory and per-owner authorization. It also needs a runnable sample +that proves the behavior and isolation rather than showing only a frontmatter +snippet. + +## 3. Goals / Non-goals + +**Goals** + +- Honor `workflows.enabled: true` on every eligible discovered agent. +- Keep `main.agent.md` working as an ordinary owner with slug `main`. +- Use `ResolvedAgent.slug` as the stable workflow owner identity on chat, MCP, + HTTP trigger, and non-interactive trigger paths. +- Create a `df.DFApp` when any eligible agent enables workflows. +- Register the Durable orchestrator and Activities exactly once per Function App. +- Register one complete, unfiltered workflow handler inventory so one owner's + exclusions never unregister another owner's tools. +- Build one immutable `WorkflowPlanPolicy` per enabled owner. +- Use the same owner policy for prompt guidance, start-time plan validation, and + defense-in-depth Activity authorization. +- Isolate workflow IDs, active-workflow limits, list, status, cancel, terminate, + and HTTP polling by `(owner_slug, session_id)`. +- Preserve non-existence semantics for cross-owner access so a caller cannot + probe whether another owner has a workflow. +- Preserve the asynchronous trigger starter contract: the initiating Function + ends after the agent turn while Durable execution continues. +- Add a runnable, one-command-verifiable sample with multiple non-main workflow + owners and no `main.agent.md`. + +**Non-goals** + +- New workflow frontmatter keys or a positive workflow-tool allowlist. +- Changes to the `tool`, `wait`, or `sub_agent` DAG schemas. +- Stateful or nested Workflow Sub Agents. +- Cross-app workflow invocation or ownership. +- Per-node retry, timeout, human approval, or compensation policy. +- An application-level index or reconnect API for workflows started by + non-HTTP triggers with generated session IDs. +- Changing chat history, MAF session, runner lock, sandbox session, or general + `x-ms-session-id` semantics outside Dynamic Workflows. +- Application-level management compatibility for legacy session-only workflow + IDs. + +## 4. Proposed design + +### 4.1 Pipeline alignment + +| Pipeline stage | Module(s) | Change | +| --- | --- | --- | +| discover | `discovery/tools.py` | No behavior change. Continue returning one app-wide inventory of explicit `@workflow_tool` declarations. Discovery remains read-only and applies no owner policy. | +| translate | `config/schema.py`, `config/merge.py`, `config/validation.py`, `registration/capabilities.py` | Reuse `WorkflowConfig`, canonical `ResolvedAgent.slug`, validated Workflow Sub Agent references, and each agent's workflow tools after `workflows.exclude`. Validate that an enabled owner has a usable starter surface. No schema change is expected. | +| compose (pass 1) | `app.py`, `registration/catalog.py`, `workflows/integration.py` | After app-wide slug and reference validation, freeze the existing `AgentCatalog` and a new slug-keyed workflow owner-policy catalog. This pass remains side-effect-free and does not mutate a `FunctionApp`. | +| register (pass 2) | `app.py`, `workflows/integration.py`, `workflows/registry.py`, `workflows/engine.py`, `registration/endpoints.py`, `registration/triggers.py` | Create a `DFApp` when the policy catalog is non-empty. Register the complete handler inventory and Durable blueprint once, then thread each owner's policy and channel addendum into only that owner's surfaces. | +| execute | `runner.py`, `workflows/tools.py`, `workflows/context.py`, `workflows/engine.py`, `registration/_handlers.py` | Capture owner slug, session ID, Durable client, and explicit policy in workflow tool closures. Namespace management by owner plus session and reauthorize capability-bearing Activities before dispatch. | + +This extends the existing two-pass composition model. Registration consumes +typed, validated, immutable objects and does not re-parse frontmatter. + +### 4.2 Authoring and eligible starter surfaces + +No new authoring syntax is introduced. Any descriptively named agent can opt in: + +```yaml +--- +name: Incident Triage Assistant +description: Investigates production incidents. +builtin_endpoints: + debug_chat_ui: true + chat_api: true +workflows: + enabled: true + exclude: + - expensive_diagnostics + subagents: + - agent: log_analyst + when: Analyze one bounded set of logs +--- +``` + +An enabled owner must have at least one invocation channel that can run the +plan-authoring agent with a Durable client: + +- built-in `chat_api`; +- built-in MCP; or +- any supported Markdown-declared trigger. + +`debug_chat_ui` alone is not a starter because it is only a page surface. A +triggerless internal specialist referenced only through `subagents` or +`workflows.subagents` also has no starter surface. + +The proposed behavior for `workflows.enabled: true` without a usable starter is +to fail composition with an actionable error. Silently ignoring the setting or +warning and disabling it would leave an apparently valid but inert owner. This +choice remains an architecture-review/sign-off item. + +If one agent exposes multiple channels, every channel uses the same owner policy. +Chat and MCP receive chat-specific guidance; Markdown-declared triggers receive +trigger-specific guidance. Authorization does not vary by channel. + +### 4.3 Stable owner identity and workflow IDs + +`ResolvedAgent.slug` is the sole owner identity. It is already: + +- derived during composition from the normalized source filename; +- guaranteed unique app-wide; +- the key of `AgentCatalog`; +- the built-in endpoint route identity; and +- the identity used by delegation and Workflow Sub Agent references. + +Workflow code must not derive or allocate a second owner identity. Configured +display name remains metadata only. + +Every invocation constructs an owner key from `(resolved.slug, session_id)`. +Instance IDs use SHA-256 over an unambiguous, length-delimited encoding of both +values, followed by the existing random UUID suffix: + +```text +{32-hex-owner-and-session-hash-prefix}-{uuid} +``` + +The raw slug and session ID remain absent from Durable-visible instance IDs. +This feature increases the ownership prefix from 12 hex characters (48 bits) to +32 hex characters (128 bits). A 48-bit truncated digest is insufficient for a +multi-owner authorization boundary at scale; 128 bits makes accidental or +chosen collision impractical while keeping IDs comfortably within Durable +limits. Ownership is still digest-based rather than literal owner-key storage, +so the guarantee is bounded by the collision resistance of the truncated +SHA-256 digest. + +All workflow management paths require both owner-key components: + +- workflow management tool closures capture the owner slug and resolved session; +- polling endpoint closures capture their route's owner slug and read the + request session; +- active count, list, status, cancel, and terminate helpers compare the + owner-scoped prefix; and +- a mismatched owner or session returns the same not-found/empty result as an + unknown workflow. + +### 4.4 App-wide execution catalogs + +The app owns two complete, read-only execution inventories: + +1. the existing `AgentCatalog`, used by Workflow Sub Agent Activities; and +2. a workflow handler catalog containing every valid discovered + `@workflow_tool` handler and its metadata. + +These catalogs answer what exists, not what a particular owner may invoke. +Owner A excluding tool X must not unregister X when owner B allows it. An agent +being present in `AgentCatalog` similarly does not grant Workflow Sub Agent +access. + +The Durable blueprint closes over the Agent catalog, workflow handler catalog, +and owner-policy catalog and is registered once. The singleton app allowlist +must no longer be an authorization source in production. Compatibility helpers +may remain temporarily for focused tests or external callers, but normal app +construction and execution always pass an explicit owner policy. + +### 4.5 Immutable owner-policy catalog + +Pass 1 constructs an immutable mapping: + +```text +owner slug -> WorkflowPlanPolicy( + allowed_tools=frozenset(...), + allowed_subagents=frozenset(...), + subagent_guidance=((slug, guidance), ...), +) +``` + +`allowed_tools` is the owner's set of public workflow tools after its existing +`workflows.exclude` filter. `allowed_subagents` and `subagent_guidance` come from +the owner's independent, deny-by-default `workflows.subagents` grants and the +immutable `AgentCatalog`. + +The same policy value: + +- generates the owner's chat and trigger prompt addenda; +- is captured by the owner's `start_workflow` closure; +- validates every authored `tool` and `sub_agent` node before Durable start; and +- is available to Activity dispatch for defense-in-depth authorization. + +### 4.6 One-time Durable registration + +After pass 1, `app.py` creates: + +- a `df.DFApp` when at least one owner policy exists; or +- a plain `func.FunctionApp` otherwise. + +Before individual agent registration, one app-level workflow registration step: + +- registers every compatible handler from the unfiltered workflow-tool + inventory; and +- registers one Durable blueprint containing the orchestrator, tool Activity, + and Workflow Sub Agent Activity. + +Individual agent registration then looks up `owner_policies[resolved.slug]`. +When present, it threads enabled state, explicit policy, owner slug, and the +appropriate addendum into `register_agent()` and +`register_builtin_endpoints()`. When absent, existing non-workflow handler +signatures and bindings remain unchanged. + +`build_workflow_integration()` will be split or reshaped so a pure per-owner +integration builder cannot accidentally register app-wide Functions. The +one-time registration function is the only workflow layer that mutates the +`DFApp`. + +### 4.7 Plan validation and Activity authorization + +`start_workflow` validates the complete authored plan with the captured owner +policy before starting Durable. The Durable input includes `owner_slug` with the +existing owner/session audit metadata and normalized tasks. + +Subject to explicit human ratification of Decision #8, each capability-bearing +Activity checks the currently deployed owner policy immediately before +shared-catalog dispatch: + +- tool Activity requires `task.tool in policy.allowed_tools`; +- Workflow Sub Agent Activity requires + `task.agent in policy.allowed_subagents`; and +- a missing owner policy, handler, or Agent catalog entry fails closed with a + non-sensitive error and correlated owner/workflow/node telemetry. + +The orchestrator passes `owner_slug` in each tool and Workflow Sub Agent Activity +payload. It performs no mutable policy lookup during replay. `wait` tasks have no +capability dispatch and retain their existing validated bounds. + +Activity checks intentionally use policy from the currently deployed app. If a +deployment removes an owner, disables workflows, or tightens a grant, a pending +node using the removed capability fails closed. Persisting an old policy snapshot +as indefinitely authoritative would make policy revocation ineffective. + +This reauthorization and fail-closed revocation behavior is provisional while +the FRD is `Draft`; implementation must not begin until Decision #8 is ratified. + +Direct Durable orchestration starts remain privileged control-plane operations. +The application-level owner boundary protects starts and management through +agent surfaces; it is not an authentication boundary against an actor already +authorized to start arbitrary Durable instances. + +### 4.8 Trigger ownership + +HTTP triggers use the caller-provided `x-ms-session-id` or the existing generated +session behavior. Non-HTTP triggers generate a fresh invocation session ID. In +both cases, the workflow owner is `(resolved.slug, invocation_session_id)`. + +The initial trigger Function remains short-lived and never polls for terminal +workflow state. Non-HTTP trigger workflows do not gain a new application-level +owner index or reconnect API. Applications should deliver final output through a +workflow task, while operators use Durable Functions or DTS tooling. + +### 4.9 Compatibility and migration + +This feature contains one intentional breaking change within the experimental +Dynamic Workflows surface. + +Existing workflow IDs use a session-only hash prefix. New IDs use an +owner-plus-session prefix. No application-level legacy fallback is proposed: + +- pre-upgrade instances continue running in Durable; +- new agent tools and polling endpoints cannot list, inspect, cancel, or + terminate those legacy IDs; +- operators can still inspect or control them through Durable/DTS; and +- deployments requiring continued agent-level management should drain or + terminate active workflows before upgrading. + +The rest of the public surface remains compatible: + +- `main.agent.md` remains a valid workflow owner with slug `main`; +- `workflows.enabled`, `workflows.exclude`, and `workflows.subagents` do not + change; +- task schemas and workflow management tool names do not change; +- Durable orchestrator and Activity names do not change; +- built-in route shapes remain `/agents/{slug}/...`; and +- non-workflow session behavior does not change. + +### 4.10 Runnable sample + +Add `samples/per-agent-workflows/` as a standalone Azure Functions app with no +`main.agent.md`. It contains two descriptively named agents, for example: + +- `incident_triage.agent.md`, with chat endpoints and one set of workflow tool + and Sub Agent grants; and +- `release_readiness.agent.md`, with chat endpoints and a different set of + grants. + +The tools use deterministic synthetic data so verification requires no external +service token. The sample includes: + +- clear architecture and workflow-shape diagrams; +- one manual prompt for each agent; +- expected workflow outputs and polling routes; +- Azure Storage and DTS local instructions; and +- `scripts/verify.py`, which defaults to the Azure Storage backend with isolated + Azurite, supports `--backend dts` for a DTS run, starts the Functions host from + a temporary app copy, and performs end-to-end assertions. + +The verifier deliberately uses the same `x-ms-session-id` for both agents. It +starts one workflow through each agent, verifies both reach a terminal state, +checks that each used only its own capabilities, and verifies that each owner's +status route returns 404 for the other owner's workflow ID. This makes the main +behavioral and security property directly observable for the exercised owner +pair. The README states the prerequisites explicitly: Docker (for isolated +Azurite and optional DTS), Functions Core Tools, and model-provider +authentication. + +## 5. Decisions log + +| # | Decision | Options considered | Choice | Decided by | Date | +| - | -------- | ------------------ | ------ | ---------- | ---- | +| 1 | FRD number | 0008 from current `main` / include open and draft PR reservations | Use 0009 because open PRs #111 and #121 both reserve 0008 | Agent | 2026-08-10 | +| 2 | Workflow owner identity | Display name / source path / endpoint-specific name / canonical slug | Use app-wide unique `ResolvedAgent.slug` on every channel | Agent | 2026-08-10 | +| 3 | Workflow ownership scope | Session only / owner only / `(owner_slug, session_id)` | Use `(owner_slug, session_id)` so equal session IDs across agents remain isolated | Human | 2026-08-10 | +| 4 | Existing workflow IDs | Dual-format fallback / migration map / no application fallback | Accept the experimental breaking change, preserve Durable/DTS operator access, and document drain guidance | Human | 2026-08-10 | +| 5 | Durable registration lifetime | Once per owner / once per app | Register the Durable engine exactly once per app | Agent | 2026-08-10 | +| 6 | Workflow handler inventory | First owner's filtered tools / union of owner tools / complete discovered catalog | Register the complete compatible handler catalog once and authorize separately per owner | Agent | 2026-08-10 | +| 7 | Owner policy representation | Mutable process global / request-time reconstruction / immutable slug-keyed catalog | Build immutable `WorkflowPlanPolicy` values during side-effect-free composition | Agent | 2026-08-10 | +| 8 | Activity authorization | Trust start-time validation / persist start-time policy / reauthorize deployed policy | Reauthorize tool and Sub Agent Activities against current deployed owner policy; pending nodes fail closed after restrictive changes | Agent; pending human sign-off | 2026-08-10 | +| 9 | Enabled owner without starter | Warn and disable / silently ignore / fail composition | Propose fail-fast composition because inert workflow configuration is misleading | Agent; pending human sign-off | 2026-08-10 | +| 10 | Non-HTTP trigger management | Add owner index / shared synthetic session / generated non-discoverable invocation session | Propose generated sessions with no new application index; use Durable/DTS for operator management | Agent; pending human sign-off | 2026-08-10 | +| 11 | Authoring schema | Add owner/config fields / reuse current workflow config | Reuse existing fields; owner identity is runtime-derived | Agent | 2026-08-10 | +| 12 | Sample proof | Extend a main-agent sample / documentation only / dedicated multi-owner sample | Add a runnable sample with two non-main owners and same-session isolation verification | Human | 2026-08-10 | +| 13 | Ownership digest width | Retain 48-bit prefix / store literal owner data / expand digest | Propose a 128-bit truncated SHA-256 prefix over a length-delimited owner/session encoding; avoids exposing raw identity while making collisions impractical | Agent; pending human sign-off | 2026-08-10 | + +## 6. Test plan + +- [ ] Unit: composition and owner-policy catalog + - any eligible non-main agent can enable workflows; + - an app with only non-main workflow owners is a `df.DFApp`; + - `main.agent.md` remains supported; + - `debug_chat_ui`-only and endpoint-less enabled owners follow the finalized + eligibility decision; + - distinct owners receive independent tool excludes, Sub Agent grants, and + prompt guidance; + - owner-policy mappings and values are immutable. +- [ ] Unit: one-time runtime registration + - multiple enabled owners register one orchestrator and one copy of each + Activity; + - complete workflow handler and Agent catalogs remain available; + - excluding a handler for one owner does not unregister it for another; + - production execution does not authorize from the singleton app allowlist. +- [ ] Unit: owner-scoped context and management + - the same session ID under two owner slugs generates different prefixes; + - active limits, list, status, cancel, and terminate require both owner and + session; + - cross-owner operations return empty/not-found without disclosing existence; + - legacy session-only IDs do not match an owner-scoped prefix, are treated as + not-found, and their Durable instances are not deleted or mutated. +- [ ] Unit: plan and Activity authorization + - prompt guidance and start-time validation use the same owner policy; + - tool and Workflow Sub Agent Activities reject capabilities belonging only to + another owner; + - missing or disabled owner policy fails closed; + - restrictive policy changes reject a pending disallowed node; + - every capability-bearing Activity payload contains `owner_slug`; + - `wait` tasks retain existing behavior. +- [ ] Integration: invocation channels + - multiple workflow-enabled agents register distinct chat, streaming, MCP, + HTTP trigger, and non-HTTP trigger surfaces as configured; + - each enabled surface receives the Durable client binding and correct + channel addendum; + - HTTP workflow polling routes cannot observe another owner under the same + session ID; + - trigger starters return/end without waiting for terminal workflow state. +- [ ] Workflow Sub Agent isolation + - each owner can schedule only its own `workflows.subagents` grants; + - one specialist may be granted to multiple owners without duplicate Activity + registration; + - workflow leaf specialists retain their current isolated execution role. +- [ ] Fixture scenario: + `tests/fixtures/config_scenarios/_multi_owner_workflows/`. +- [ ] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping + session IDs, distinct policies, status/control isolation, and execution after + starter completion. +- [ ] Sample verifier: one command starts dependencies and proves both successful + workflows plus cross-owner denial. +- [ ] Canonical gate: + - `python -m ruff check src tests`; + - `python -m mypy src`; + - `python -m pytest --cache-clear --cov=./src/azure_functions_agents + --cov-report=xml --cov-branch tests`. + +## 7. Docs impact + +- [ ] `docs/architecture.md` — add the owner-policy catalog, one-time Durable + registration, owner-scoped execution, and Activity reauthorization. +- [ ] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and + document eligible starter surfaces. +- [ ] `docs/workflows.md` — document multiple owners, identity, isolation, + migration, trigger ownership, and operator guidance. +- [ ] `docs/triggers.md` — clarify that each workflow-enabled declared trigger + uses its owning agent's policy and Durable client. +- [ ] `README.md` — link the per-agent workflow sample. +- [ ] `samples/README.md` — list the runnable sample and its one-command verifier. +- [ ] `docs/front-matter-reference.md` — no change expected because no schema + change is planned. + +## 8. Status & sign-off + +- **Architecture review (phase 2):** Pending independent review against current + `main`, `docs/architecture.md`, FRD 0004, FRD 0007, issues #1274/#1275, and the + existing trigger and Workflow Sub Agent implementations. +- **Human sign-off:** Pending. Decisions #8-#10 and #13 require explicit + ratification before setting `status: Finalized` and beginning product + implementation. diff --git a/docs/frds/README.md b/docs/frds/README.md index 2843239f..7a05f61d 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -36,6 +36,7 @@ The full lifecycle that produces an FRD lives in [`../../AGENTS.md`](../../AGENT | [0005](0005-web-request-system-tool.md) | `web_request` system tool | In review | | [0006](0006-endpoint-authentication.md) | Endpoint & HTTP trigger authentication (API key / Entra ID) | Finalized | | [0007](0007-multi-agent-delegation.md) | Multi-agent delegation (agent-as-tool) | In review | +| [0009](0009-per-agent-dynamic-workflows.md) | Per-agent Dynamic Workflows | Draft | > `_template.md` is the template, not an FRD — the leading underscore keeps it > sorted first and excludes it from numbering. From 6ef8e31bfa59d497de202bf3834d6dfaa67c549d Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 16:24:03 -0700 Subject: [PATCH 02/18] docs: link per-agent workflows design PR Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0009-per-agent-dynamic-workflows.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 18cbb5f2..90c6e043 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -9,7 +9,8 @@ issues: - "Azure/azure-functions-agents-runtime#109" - "Azure/azure-functions-bucees-planning#1274" - "Azure/azure-functions-bucees-planning#1275" -pull_requests: [] +pull_requests: + - "Azure/azure-functions-agents-runtime#151" branch: tsuyoshiushio-per-agent-dynamic-workflows --- From 198172b50e7f84882b0126fb13366ddb6ccbd7de Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 19:38:25 -0700 Subject: [PATCH 03/18] docs: finalize per-agent Dynamic Workflows FRD Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0009-per-agent-dynamic-workflows.md | 25 +++++++++++-------- docs/frds/README.md | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 90c6e043..01963851 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -1,7 +1,7 @@ --- frd: 0009 title: Per-agent Dynamic Workflows -status: Draft +status: Finalized author: TsuyoshiUshio created: 2026-08-10 updated: 2026-08-10 @@ -376,12 +376,12 @@ authentication. | 5 | Durable registration lifetime | Once per owner / once per app | Register the Durable engine exactly once per app | Agent | 2026-08-10 | | 6 | Workflow handler inventory | First owner's filtered tools / union of owner tools / complete discovered catalog | Register the complete compatible handler catalog once and authorize separately per owner | Agent | 2026-08-10 | | 7 | Owner policy representation | Mutable process global / request-time reconstruction / immutable slug-keyed catalog | Build immutable `WorkflowPlanPolicy` values during side-effect-free composition | Agent | 2026-08-10 | -| 8 | Activity authorization | Trust start-time validation / persist start-time policy / reauthorize deployed policy | Reauthorize tool and Sub Agent Activities against current deployed owner policy; pending nodes fail closed after restrictive changes | Agent; pending human sign-off | 2026-08-10 | -| 9 | Enabled owner without starter | Warn and disable / silently ignore / fail composition | Propose fail-fast composition because inert workflow configuration is misleading | Agent; pending human sign-off | 2026-08-10 | -| 10 | Non-HTTP trigger management | Add owner index / shared synthetic session / generated non-discoverable invocation session | Propose generated sessions with no new application index; use Durable/DTS for operator management | Agent; pending human sign-off | 2026-08-10 | +| 8 | Activity authorization | Trust start-time validation / persist start-time policy / reauthorize deployed policy | Reauthorize tool and Sub Agent Activities against current deployed owner policy; pending nodes fail closed after restrictive changes | Human | 2026-08-10 | +| 9 | Enabled owner without starter | Warn and disable / silently ignore / fail composition | Fail composition because inert workflow configuration is misleading | Human | 2026-08-10 | +| 10 | Non-HTTP trigger management | Add owner index / shared synthetic session / generated non-discoverable invocation session | Use generated sessions with no new application index; use Durable/DTS for operator management | Human | 2026-08-10 | | 11 | Authoring schema | Add owner/config fields / reuse current workflow config | Reuse existing fields; owner identity is runtime-derived | Agent | 2026-08-10 | | 12 | Sample proof | Extend a main-agent sample / documentation only / dedicated multi-owner sample | Add a runnable sample with two non-main owners and same-session isolation verification | Human | 2026-08-10 | -| 13 | Ownership digest width | Retain 48-bit prefix / store literal owner data / expand digest | Propose a 128-bit truncated SHA-256 prefix over a length-delimited owner/session encoding; avoids exposing raw identity while making collisions impractical | Agent; pending human sign-off | 2026-08-10 | +| 13 | Ownership digest width | Retain 48-bit prefix / store literal owner data / expand digest | Use a 128-bit truncated SHA-256 prefix over a length-delimited owner/session encoding; avoids exposing raw identity while making collisions impractical | Human | 2026-08-10 | ## 6. Test plan @@ -458,9 +458,12 @@ authentication. ## 8. Status & sign-off -- **Architecture review (phase 2):** Pending independent review against current - `main`, `docs/architecture.md`, FRD 0004, FRD 0007, issues #1274/#1275, and the - existing trigger and Workflow Sub Agent implementations. -- **Human sign-off:** Pending. Decisions #8-#10 and #13 require explicit - ratification before setting `status: Finalized` and beginning product - implementation. +- **Architecture review (phase 2):** Completed by an independent rubber-duck + reviewer on 2026-08-10 against current `main`, `docs/architecture.md`, FRD + 0004, FRD 0007, issues #1274/#1275, and the existing trigger and Workflow Sub + Agent implementations. No blocking findings remained. Important findings on + ownership digest strength, provisional decisions, legacy-ID wording, and + verifier prerequisites were incorporated. +- **Human sign-off:** Completed by TsuyoshiUshio on 2026-08-10. The human + approved proceeding with implementation in the same PR, ratifying Decisions + #8-#10 and #13. Status set to `Finalized`. diff --git a/docs/frds/README.md b/docs/frds/README.md index 7a05f61d..fc37565a 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -36,7 +36,7 @@ The full lifecycle that produces an FRD lives in [`../../AGENTS.md`](../../AGENT | [0005](0005-web-request-system-tool.md) | `web_request` system tool | In review | | [0006](0006-endpoint-authentication.md) | Endpoint & HTTP trigger authentication (API key / Entra ID) | Finalized | | [0007](0007-multi-agent-delegation.md) | Multi-agent delegation (agent-as-tool) | In review | -| [0009](0009-per-agent-dynamic-workflows.md) | Per-agent Dynamic Workflows | Draft | +| [0009](0009-per-agent-dynamic-workflows.md) | Per-agent Dynamic Workflows | Finalized | > `_template.md` is the template, not an FRD — the leading underscore keeps it > sorted first and excludes it from numbering. From 4529e98b86bba7fd583b4aa4298c036a929240ee Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 20:03:31 -0700 Subject: [PATCH 04/18] feat: enable Dynamic Workflows per agent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- .../_trigger_support.py | 33 ++ src/azure_functions_agents/app.py | 67 +-- src/azure_functions_agents/config/loader.py | 10 + .../config/validation.py | 10 + .../registration/_handlers.py | 2 + .../registration/endpoints.py | 15 +- .../registration/triggers.py | 7 +- src/azure_functions_agents/runner.py | 9 + .../workflows/context.py | 74 ++- .../workflows/engine.py | 87 +++- .../workflows/integration.py | 193 +++++++- .../workflows/registry.py | 71 ++- src/azure_functions_agents/workflows/tools.py | 121 ++++- tests/test_app_routes.py | 15 +- tests/test_per_agent_workflows.py | 450 ++++++++++++++++++ tests/test_registration_endpoints.py | 70 +++ tests/test_registration_handlers.py | 61 +++ tests/test_registration_triggers.py | 7 + tests/test_workflow_engine.py | 191 +++++++- tests/test_workflow_registry.py | 93 +++- 20 files changed, 1416 insertions(+), 170 deletions(-) create mode 100644 src/azure_functions_agents/_trigger_support.py create mode 100644 tests/test_per_agent_workflows.py diff --git a/src/azure_functions_agents/_trigger_support.py b/src/azure_functions_agents/_trigger_support.py new file mode 100644 index 00000000..5c0e38ab --- /dev/null +++ b/src/azure_functions_agents/_trigger_support.py @@ -0,0 +1,33 @@ +"""Shared trigger decorator resolution for validation and registration.""" + +from __future__ import annotations + +from typing import Any + +import azure.functions as func + +from azure_functions_agents.config.schema import TRIGGER_TYPES + +_SUPPORTED_TRIGGER_TYPES = frozenset(TRIGGER_TYPES) + + +def resolve_trigger_decorator_name(owner: Any, trigger_type: str) -> str | None: + """Return the decorator exposed by *owner* for an authored trigger type.""" + if trigger_type not in _SUPPORTED_TRIGGER_TYPES: + return None + if trigger_type == "http_trigger": + return "route" if callable(getattr(owner, "route", None)) else None + if trigger_type == "connector_trigger": + if callable(getattr(owner, "connector_trigger", None)): + return "connector_trigger" + if callable(getattr(owner, "generic_trigger", None)): + return "generic_trigger" + return None + if callable(getattr(owner, trigger_type, None)): + return trigger_type + return None + + +def is_supported_trigger_type(trigger_type: str) -> bool: + """Return whether a standard FunctionApp can register this authored trigger.""" + return resolve_trigger_decorator_name(func.FunctionApp, trigger_type) is not None diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index 1c8b5386..4075582c 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_owner_workflow_integration, + build_workflow_handler_catalog, + build_workflow_owner_policy_catalog, + register_workflow_runtime, + validate_workflow_owner_starter, +) 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() @@ -186,12 +178,16 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog_entries: dict[str, CatalogEntry] = {} for resolved in resolved_agents: # Validation is owned by the app factory; compose() stays a pure translation step. + if resolved.trigger is None: + validate_workflow_owner_starter(resolved) validate_resolved_agent( resolved, discovered_mcp_names=mcp_names, discovered_skills=skill_names, is_referenced_as_subagent=resolved.slug in referenced_slugs, ) + if resolved.trigger is not None: + validate_workflow_owner_starter(resolved) capabilities = build_capabilities( resolved, discovered_user_tools=user_tools, @@ -203,8 +199,26 @@ 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_owner_policies = build_workflow_owner_policy_catalog( + catalog, + workflow_handler_catalog, + ) + app: func.FunctionApp = ( + df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) + if workflow_owner_policies + else func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + ) # --- Two-pass composition, pass 2 (FRD 0007 §4.2): mutate `app` -------------------- + if workflow_owner_policies: + register_workflow_runtime( + app, + handler_catalog=workflow_handler_catalog, + catalog=catalog, + owner_policies=workflow_owner_policies, + ) + for resolved in resolved_agents: capabilities = catalog[resolved.slug].capabilities @@ -212,28 +226,17 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: 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_owner_policies.get(resolved.slug) + if workflow_policy is not None: + workflow_integration = build_owner_workflow_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..c1fb502c 100644 --- a/src/azure_functions_agents/config/loader.py +++ b/src/azure_functions_agents/config/loader.py @@ -153,6 +153,16 @@ def _load_agent_spec(source_file: Path) -> AgentSpec: normalized["instructions"] = instructions # Keep the real on-disk path so diagnostics reference the file the user can actually edit normalized["source_file"] = str(resolved_source) + raw_builtin_endpoints = normalized.get("builtin_endpoints") + internal_metadata = dict(normalized.get("metadata") or {}) + internal_metadata["_workflow_chat_api_starter"] = bool( + raw_builtin_endpoints is True + or ( + isinstance(raw_builtin_endpoints, dict) + and raw_builtin_endpoints.get("chat_api") is True + ) + ) + normalized["metadata"] = internal_metadata # agent.md and CLAUDE.md (and their case variants) are aliases for main.agent.md; # check the normalized name to determine main-agent status normalized["is_main"] = normalized_file.name.lower() == "main.agent.md" diff --git a/src/azure_functions_agents/config/validation.py b/src/azure_functions_agents/config/validation.py index d5d22743..8ca8359e 100644 --- a/src/azure_functions_agents/config/validation.py +++ b/src/azure_functions_agents/config/validation.py @@ -5,6 +5,7 @@ from pathlib import Path from azure_functions_agents._logger import logger as _logger +from azure_functions_agents._trigger_support import is_supported_trigger_type from .schema import ResolvedAgent, SubagentRef, WorkflowSubagentRef @@ -91,6 +92,15 @@ def validate_resolved_agent( "#trigger", ) ) + if not is_supported_trigger_type(trigger_type): + raise ValueError( + _format_error( + source_file, + "trigger.type", + f"Unknown or unsupported trigger type `{trigger_type}`.", + "#trigger", + ) + ) known_mcp = set(discovered_mcp_names) for name in resolved.mcp_exclude_names: diff --git a/src/azure_functions_agents/registration/_handlers.py b/src/azure_functions_agents/registration/_handlers.py index 636b59a2..080d291f 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_owner_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_owner_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, ) diff --git a/src/azure_functions_agents/registration/endpoints.py b/src/azure_functions_agents/registration/endpoints.py index c5d1a425..b81c00cb 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_owner_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_owner_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, # S1b: `_register_http_chat_stream`'s `handle_chat_stream` (unlike @@ -552,9 +554,9 @@ 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, slug, session_id) except Exception: - logger.exception("workflows list endpoint failed") + logger.exception("workflows list endpoint failed owner=%s", slug) return Response( json.dumps({"error": "failed to list workflows"}), status_code=500, @@ -586,9 +588,14 @@ 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, + slug, + session_id, + workflow_id, + ) except Exception: - logger.exception("workflow status endpoint failed") + logger.exception("workflow status endpoint failed owner=%s", slug) return Response( json.dumps({"error": "failed to fetch workflow status"}), status_code=500, diff --git a/src/azure_functions_agents/registration/triggers.py b/src/azure_functions_agents/registration/triggers.py index 5292489f..8d9913a5 100644 --- a/src/azure_functions_agents/registration/triggers.py +++ b/src/azure_functions_agents/registration/triggers.py @@ -9,6 +9,7 @@ from .._logger import logger from .._source_marker import source_marker +from .._trigger_support import resolve_trigger_decorator_name from ..config import EndpointAuthConfig, ResolvedAgent from . import _naming from ._auth import resolve_endpoint_auth_level @@ -51,9 +52,9 @@ def _register_builtin_agent( workflow_policy: WorkflowPlanPolicy | None = None, ) -> None: trigger_params = dict(trigger_params) - decorator_fn = getattr(app, trigger_type, None) - if decorator_fn is None and trigger_type == "connector_trigger": - decorator_fn = getattr(app, "generic_trigger", None) + decorator_name = resolve_trigger_decorator_name(app, trigger_type) + decorator_fn = getattr(app, decorator_name, None) if decorator_name is not None else None + if decorator_name == "generic_trigger" and trigger_type == "connector_trigger": trigger_params.setdefault("type", "connectorTrigger") if decorator_fn is None: diff --git a/src/azure_functions_agents/runner.py b/src/azure_functions_agents/runner.py index 28836810..76c4a489 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_owner_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 "", + owner_slug=workflow_owner_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_owner_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_owner_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_owner_slug=workflow_owner_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_owner_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_owner_slug=workflow_owner_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_owner_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_owner_slug=workflow_owner_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..4531d302 100644 --- a/src/azure_functions_agents/workflows/context.py +++ b/src/azure_functions_agents/workflows/context.py @@ -1,10 +1,10 @@ -"""Per-session workflow context registry + instance-ID ownership scheme. +"""Per-owner-session workflow context registry + instance-ID ownership 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. + handler via ``durable_client_input`` and the owner/session pair. 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 @@ -15,7 +15,8 @@ 2. **Instance-ID ownership.** Every workflow started via ``start_workflow`` receives an instance ID whose leading - :data:`SESSION_PREFIX_LEN` hex characters are ``sha256(session_id)``. + :data:`OWNER_SESSION_PREFIX_LEN` hex characters are a SHA-256 prefix + over the owner slug and session ID. Ownership 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 @@ -31,52 +32,63 @@ from threading import Lock from typing import Any -SESSION_PREFIX_LEN = 12 +OWNER_SESSION_PREFIX_LEN = 32 +SESSION_PREFIX_LEN = OWNER_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(owner_slug: str, session_id: str) -> str: + """Return the fixed-length owner/session prefix embedded in workflow IDs. Workflow ownership 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 owner/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 (owner_slug, session_id): + encoded = value.encode("utf-8") + digest.update(len(encoded).to_bytes(8, byteorder="big")) + digest.update(encoded) + return digest.hexdigest()[:OWNER_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(owner_slug: str, session_id: str) -> str: + """Generate a fresh workflow instance ID for an owner/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-owner-session-hash}-{32-hex-uuid}``. """ - return f"{session_instance_prefix(session_id)}-{uuid.uuid4().hex}" + return f"{session_instance_prefix(owner_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 session_owns_workflow( + owner_slug: str, + session_id: str, + workflow_id: str, +) -> bool: + if not owner_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(owner_slug, session_id) + "-" + ) @dataclass(frozen=True) class WorkflowSessionContext: """Per-in-flight-request state needed by workflow tools.""" + owner_slug: str session_id: str agent_name: str durable_client: Any # azure.durable_functions.DurableOrchestrationClient token: str -_registry: dict[str, WorkflowSessionContext] = {} +_registry: dict[tuple[str, str], WorkflowSessionContext] = {} _lock = Lock() def register_workflow_session( + owner_slug: str, session_id: str, agent_name: str, durable_client: Any, @@ -88,7 +100,8 @@ def register_workflow_session( """ token = uuid.uuid4().hex with _lock: - _registry[session_id] = WorkflowSessionContext( + _registry[(owner_slug, session_id)] = WorkflowSessionContext( + owner_slug=owner_slug, session_id=session_id, agent_name=agent_name, durable_client=durable_client, @@ -97,26 +110,35 @@ def register_workflow_session( return token -def unregister_workflow_session(session_id: str, token: str) -> None: +def unregister_workflow_session( + owner_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 = (owner_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( + owner_slug: str | None, + session_id: str | None, +) -> WorkflowSessionContext | None: + if not owner_slug or not session_id: return None with _lock: - return _registry.get(session_id) + return _registry.get((owner_slug, session_id)) __all__ = [ + "OWNER_SESSION_PREFIX_LEN", "SESSION_PREFIX_LEN", "WorkflowSessionContext", "get_workflow_session", diff --git a/src/azure_functions_agents/workflows/engine.py b/src/azure_functions_agents/workflows/engine.py index 04d3d063..7c7b8301 100644 --- a/src/azure_functions_agents/workflows/engine.py +++ b/src/azure_functions_agents/workflows/engine.py @@ -15,14 +15,14 @@ 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 collections.abc import Mapping from typing import Any import azure.durable_functions as df @@ -41,6 +41,7 @@ TOOL_TASK_TYPE, WAIT_TASK_TYPE, TemplateResolutionError, + WorkflowPlanPolicy, parse_iso8601_datetime, parse_iso8601_duration, resolve_template_value, @@ -104,6 +105,8 @@ def register_workflows( app: func.FunctionApp, *, catalog: AgentCatalog | None = None, + handler_catalog: registry.WorkflowHandlerCatalog | None = None, + owner_policies: Mapping[str, WorkflowPlanPolicy] | None = None, ) -> None: """Register the workflow orchestrator + activities on ``app``. @@ -113,23 +116,65 @@ def register_workflows( """ bp = df.Blueprint() + def require_owner_policy(task: dict[str, Any]) -> tuple[str, WorkflowPlanPolicy]: + owner_slug = str(task.get("owner_slug") or "") + policy = owner_policies.get(owner_slug) if owner_policies is not None else None + if not owner_slug or policy is None: + logger.error( + "workflow activity owner policy miss: workflow_id=%s node_id=%s owner=%s", + str(task.get("workflow_id") or ""), + str(task.get("id") or ""), + owner_slug or "", + ) + raise RuntimeError( + f"task {str(task.get('id') or '')!r}: workflow owner policy is not available" + ) + return owner_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] task_id = task["id"] tool_name = task["tool"] args = task.get("args") or {} - handler = registry.get_handler(tool_name) - if handler is None: + owner_slug, policy = require_owner_policy(task) + workflow_id = str(task.get("workflow_id") or "") + if tool_name not in policy.allowed_tools: + logger.error( + "workflow tool authorization denied: workflow_id=%s node_id=%s owner=%s tool=%s", + workflow_id, + task_id, + owner_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 owner=%s id=%s tool=%s", + workflow_id, + owner_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 owner=%s id=%s tool=%s", + workflow_id, + owner_slug, + task_id, + tool_name, ) raise RuntimeError( f"task {task_id!r}: workflow-safe tool failed" @@ -146,11 +191,26 @@ async def agents_workflow_run_sub_agent(task) -> dict[str, Any]: # type: ignore task_id = str(task["id"]) agent_slug = str(task["agent"]) workflow_id = str(task.get("workflow_id") or "") + owner_slug, policy = require_owner_policy(task) + if agent_slug not in policy.allowed_subagents: + logger.error( + "workflow sub-agent authorization denied: " + "workflow_id=%s node_id=%s owner=%s agent=%s", + workflow_id, + task_id, + owner_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 owner=%s agent=%s", workflow_id, task_id, + owner_slug, agent_slug, ) raise RuntimeError( @@ -159,9 +219,11 @@ 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 owner=%s agent=%s", workflow_id, task_id, + owner_slug, agent_slug, ) try: @@ -227,6 +289,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 []) + owner_slug = str(payload.get("owner_slug") or "") by_id: dict[str, dict[str, Any]] = {t["id"]: t for t in tasks} deps: dict[str, set[str]] = { @@ -283,6 +346,8 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any "id": tid, "tool": task["tool"], "args": resolved_args, + "owner_slug": owner_slug, + "workflow_id": context.instance_id, }, ) ) @@ -306,6 +371,7 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any "agent": task["agent"], "task": resolved_task, "workflow_id": context.instance_id, + "owner_slug": owner_slug, }, ) ) @@ -344,8 +410,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 owner=%s reason=%r", context.instance_id, + owner_slug, reason, ) return { diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index 42f9497f..dcb4d082 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -8,25 +8,25 @@ 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 +owner-policy catalog, registers the Durable engine once, then builds each +owner'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._trigger_support import is_supported_trigger_type +from azure_functions_agents.config.schema import ResolvedAgent, WorkflowSubagentRef from azure_functions_agents.registration.catalog import AgentCatalog from . import registry @@ -34,6 +34,8 @@ from .schema import WorkflowPlanPolicy from .tools import build_workflow_tools +type WorkflowOwnerPolicyCatalog = 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 @@ -255,8 +257,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 +270,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 +279,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 +328,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 +353,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 +384,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 +420,98 @@ def _build_plan_policy( ) +def _has_eligible_starter(resolved: ResolvedAgent) -> bool: + if resolved.trigger is not None and is_supported_trigger_type( + str(resolved.trigger.type or "").strip() + ): + return True + endpoints = resolved.builtin_endpoints + chat_api = resolved.metadata.get( + "_workflow_chat_api_starter", + endpoints.chat_api and not endpoints.debug_chat_ui, + ) + return bool(chat_api or endpoints.mcp) + + +def validate_workflow_owner_starter(resolved: ResolvedAgent) -> None: + """Reject an enabled owner that has no Durable-capable invocation surface.""" + if ( + resolved.workflows is not None + and resolved.workflows.enabled + and not _has_eligible_starter(resolved) + ): + raise ValueError( + f"Agent {resolved.slug!r} sets workflows.enabled=true but has no " + "eligible workflow starter. Configure a trigger, " + "builtin_endpoints.chat_api, or builtin_endpoints.mcp; " + "debug_chat_ui alone is not sufficient." + ) + + +def build_workflow_owner_policy_catalog( + catalog: AgentCatalog, + handler_catalog: registry.WorkflowHandlerCatalog, +) -> WorkflowOwnerPolicyCatalog: + """Freeze one independent workflow policy per enabled eligible owner.""" + policies: dict[str, WorkflowPlanPolicy] = {} + for owner_slug, entry in catalog.items(): + resolved = entry.resolved + if resolved.workflows is None or not resolved.workflows.enabled: + continue + validate_workflow_owner_starter(resolved) + 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[owner_slug] = _build_plan_policy( + allowed_tools, + resolved.workflows.subagents, + catalog, + ) + return MappingProxyType(policies) + + +def build_owner_workflow_integration( + policy: WorkflowPlanPolicy, + handler_catalog: registry.WorkflowHandlerCatalog, +) -> WorkflowIntegrationResult: + """Build one owner'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, + owner_policies: WorkflowOwnerPolicyCatalog, +) -> None: + """Register the app-wide Durable engine exactly once.""" + register_workflows( + app, + catalog=catalog, + handler_catalog=handler_catalog, + owner_policies=owner_policies, + ) + + def build_workflow_integration( app: func.FunctionApp, metadata: dict[str, Any], @@ -384,7 +520,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 owner'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 +539,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, + owner_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 +556,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_owner_workflow_integration(policy, handler_catalog) __all__ = [ "WorkflowIntegrationResult", + "WorkflowOwnerPolicyCatalog", + "build_owner_workflow_integration", + "build_workflow_handler_catalog", "build_workflow_integration", + "build_workflow_owner_policy_catalog", + "register_workflow_runtime", + "validate_workflow_owner_starter", ] diff --git a/src/azure_functions_agents/workflows/registry.py b/src/azure_functions_agents/workflows/registry.py index 7b6d9b84..a70da8da 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 owner 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/tools.py b/src/azure_functions_agents/workflows/tools.py index 1decefd9..c9e15faa 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. +Ownership is enforced by prefix-matching the Durable instance ID against a +128-bit SHA-256 prefix for ``(owner_slug, session_id)``; a mismatch returns +404 (same shape as "not found") to avoid leaking another owner's workflows. """ from __future__ import annotations @@ -233,7 +232,9 @@ def _is_active_status(status: Any) -> bool: async def fetch_session_workflows( - durable_client: Any, session_id: str + durable_client: Any, + owner_slug: str, + session_id: str, ) -> list[dict[str, Any]]: """Return status envelopes for all workflows owned by ``session_id``. @@ -247,7 +248,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 session_owns_workflow( + owner_slug, session_id, instance_id + ): continue envelopes.append(status_envelope(status)) envelopes.sort( @@ -257,14 +260,18 @@ 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: Any, + owner_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 session_owns_workflow(owner_slug, session_id, instance_id) and _is_active_status(status) ): active += 1 @@ -274,12 +281,15 @@ 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: Any, + owner_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). """ - if not session_owns_workflow(session_id, workflow_id): + if not session_owns_workflow(owner_slug, session_id, workflow_id): return None status = await durable_client.get_status(workflow_id) envelope = status_envelope(status) @@ -371,17 +381,27 @@ async def start_workflow( return _error(str(exc)) owner = { + "owner_slug": session.owner_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.owner_slug, + session.session_id, + ) try: active_count = await count_active_session_workflows( - session.durable_client, session.session_id + session.durable_client, + session.owner_slug, + session.session_id, ) except Exception: - logger.exception("start_workflow: client.get_status_all failed") + logger.exception( + "start_workflow: client.get_status_all failed owner=%s session=%s", + session.owner_slug, + session.session_id, + ) return _error("failed to start workflow") if active_count >= MAX_ACTIVE_WORKFLOWS_PER_SESSION: return _error( @@ -396,11 +416,16 @@ async def start_workflow( instance_id=instance_id, client_input={ "tasks": plan_to_activity_inputs(plan), + "owner_slug": session.owner_slug, "owner": owner, }, ) except Exception: - logger.exception("start_workflow: client.start_new failed") + logger.exception( + "start_workflow: client.start_new failed owner=%s session=%s", + session.owner_slug, + session.session_id, + ) return _error("failed to start workflow") # Durable echoes back the instance ID we supplied; defend against SDK @@ -411,7 +436,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 owner=%s session=%s", + instance_id, + session.owner_slug, + session.session_id, + ) return json.dumps({"workflow_id": instance_id}) @@ -425,7 +455,11 @@ async def get_workflow_status( # Ownership 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 session_owns_workflow( + session.owner_slug, + session.session_id, + params.workflow_id, + ): return _error( f"workflow {params.workflow_id!r} not found", status=_NOT_FOUND_ERROR_STATUS, @@ -434,7 +468,11 @@ 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 owner=%s session=%s", + session.owner_slug, + session.session_id, + ) return _error("failed to fetch workflow status") envelope = _status_envelope(status) @@ -455,10 +493,16 @@ async def list_workflows( try: envelopes = await fetch_session_workflows( - session.durable_client, session.session_id + session.durable_client, + session.owner_slug, + session.session_id, ) except Exception: - logger.exception("list_workflows: fetch_session_workflows failed") + logger.exception( + "list_workflows: fetch_session_workflows failed owner=%s session=%s", + session.owner_slug, + session.session_id, + ) return _error("failed to list workflows") return json.dumps({"workflows": envelopes}) @@ -471,7 +515,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 session_owns_workflow( + session.owner_slug, + session.session_id, + params.workflow_id, + ): return _error( f"workflow {params.workflow_id!r} not found", status=_NOT_FOUND_ERROR_STATUS, @@ -480,11 +528,19 @@ 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 owner=%s session=%s", + session.owner_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 owner=%s session=%s reason=%r", + params.workflow_id, + session.owner_slug, + session.session_id, + params.reason, ) return json.dumps({"workflow_id": params.workflow_id, "terminated": True}) @@ -496,7 +552,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 session_owns_workflow( + session.owner_slug, + session.session_id, + params.workflow_id, + ): return _error( f"workflow {params.workflow_id!r} not found", status=_NOT_FOUND_ERROR_STATUS, @@ -507,12 +567,18 @@ 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 owner=%s session=%s", + session.owner_slug, + session.session_id, + ) return _error("failed to cancel workflow") logger.info( - "workflow cancel requested: id=%s reason=%r", + "workflow cancel requested: id=%s owner=%s session=%s reason=%r", params.workflow_id, + session.owner_slug, + session.session_id, params.reason, ) return json.dumps( @@ -521,6 +587,7 @@ async def cancel_workflow( def _build_session( + owner_slug: str, session_id: str | None, agent_name: str, durable_client: Any | None, @@ -528,6 +595,7 @@ def _build_session( if not session_id or durable_client is None: return None return WorkflowSessionContext( + owner_slug=owner_slug, session_id=session_id, agent_name=agent_name, durable_client=durable_client, @@ -538,12 +606,13 @@ def _build_session( def build_workflow_tools( *, session_id: str | None = None, + owner_slug: str = "main", agent_name: str = "main", durable_client: Any | 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(owner_slug, session_id, agent_name, durable_client) @define_tool( name="start_workflow", diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 00d97f18..b1d2090a 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,7 +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( +def test_non_main_trigger_workflows_enable_durable( tmp_path: Path, caplog: pytest.LogCaptureFixture ): _write_main_agent(tmp_path) @@ -114,8 +117,8 @@ 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( + assert isinstance(function_app, df.DFApp) + assert not any( "workflows.enabled is only honored on main.agent.md" in record.message for record in caplog.records ) @@ -266,7 +269,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 +300,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_per_agent_workflows.py b/tests/test_per_agent_workflows.py new file mode 100644 index 00000000..1c1d253c --- /dev/null +++ b/tests/test_per_agent_workflows.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +from types import MappingProxyType +from typing import Any + +import azure.durable_functions as df +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 test_non_main_workflow_owner_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_multiple_workflow_owners_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_owner_is_eligible(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_owner_fails_composition(tmp_path) -> None: + _write_agent( + tmp_path, + "unknown.agent.md", + """ +name: Unknown Trigger +description: Must not create an inert workflow owner. +trigger: + type: imaginary_trigger +workflows: + enabled: true +""", + ) + + with pytest.raises(ValueError, match=r"trigger\.type.*imaginary_trigger"): + create_function_app(tmp_path) + + +def test_callable_non_trigger_decorator_fails_workflow_owner_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) + + +@pytest.mark.parametrize( + "starter", + [ + "", + "builtin_endpoints:\n debug_chat_ui: true", + ], +) +def test_enabled_workflow_owner_requires_eligible_starter(tmp_path, starter: str) -> None: + _write_agent( + tmp_path, + "inert.agent.md", + f""" +name: Inert +description: Has no workflow starter. +{starter} +workflows: + enabled: true +""", + ) + + with pytest.raises( + ValueError, + match=r"workflows\.enabled.*eligible workflow starter", + ): + create_function_app(tmp_path) + + +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_owner_policy_catalog_is_immutable_and_keeps_owner_grants_independent() -> None: + owner_a, capabilities_a = _resolved( + "owner_a", + tools_enabled=("shared",), + subagents=("specialist_a",), + ) + owner_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(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("shared", "shared description", lambda args: args), + WorkflowTool("only_b", "only B description", lambda args: args), + ] + ) + + policies = integration.build_workflow_owner_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_owner_policy_catalog(catalog, handlers) + + owner_a_integration = integration.build_owner_workflow_integration( + policies["owner_a"], + handlers, + ) + owner_b_integration = integration.build_owner_workflow_integration( + policies["owner_b"], + handlers, + ) + + for addendum in ( + owner_a_integration.chat_system_addendum, + owner_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 ( + owner_b_integration.chat_system_addendum, + owner_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_owner_and_session_identity_uses_distinct_128_bit_prefixes() -> None: + first = context.new_workflow_instance_id("owner_a", "same-session") + second = context.new_workflow_instance_id("owner_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.session_owns_workflow("owner_a", "same-session", first) + assert not context.session_owns_workflow("owner_b", "same-session", first) + assert not context.session_owns_workflow( + "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_owner_management_is_not_found() -> None: + workflow_id = context.new_workflow_instance_id("owner_a", "same-session") + client = _StatusClient([_Status(workflow_id)]) + owner_b = context.WorkflowSessionContext( + owner_slug="owner_b", + session_id="same-session", + agent_name="Owner B", + durable_client=client, + token="", + ) + + assert await tools.fetch_session_workflows(client, "owner_b", "same-session") == [] + assert ( + await tools.fetch_session_workflow_status( + client, "owner_b", "same-session", workflow_id + ) + is None + ) + status = await tools.get_workflow_status( + tools.GetWorkflowStatusParams(workflow_id=workflow_id), owner_b + ) + cancel = await tools.cancel_workflow( + tools.CancelWorkflowParams(workflow_id=workflow_id), owner_b + ) + terminate = await tools.terminate_workflow( + tools.TerminateWorkflowParams(workflow_id=workflow_id), owner_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_owner_under_shared_session() -> None: + client = _StatusClient( + [_Status(context.new_workflow_instance_id("owner_a", "same-session"))] + ) + + assert ( + await tools.count_active_session_workflows( + client, + "owner_a", + "same-session", + ) + == 1 + ) + assert ( + await tools.count_active_session_workflows( + client, + "owner_b", + "same-session", + ) + == 0 + ) diff --git a/tests/test_registration_endpoints.py b/tests/test_registration_endpoints.py index 022fadda..153e18cd 100644 --- a/tests/test_registration_endpoints.py +++ b/tests/test_registration_endpoints.py @@ -27,6 +27,7 @@ register_builtin_endpoints, ) from azure_functions_agents.runner import _SESSION_ID_PATTERN +from azure_functions_agents.workflows.context import new_workflow_instance_id class FakeFunctionApp: @@ -311,6 +312,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_owner_slug"] == resolved.slug def test_run_builtin_agent_stream_generates_session_id_before_building_sandbox_tools( @@ -357,6 +359,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_owner_slug"] == resolved.slug assert calls["run_agent_stream"]["agent_name"] != resolved.name @@ -883,6 +886,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 +1267,69 @@ 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": []} diff --git a/tests/test_registration_handlers.py b/tests/test_registration_handlers.py index 9c8f980f..9c3976c6 100644 --- a/tests/test_registration_handlers.py +++ b/tests/test_registration_handlers.py @@ -619,6 +619,36 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: assert captured["agent_name"] != resolved.name +def test_non_http_workflow_handler_threads_owner_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_owner_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 +673,37 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: assert captured["agent_name"] != resolved.name +def test_http_workflow_handler_threads_owner_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_owner_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..255d8b35 100644 --- a/tests/test_registration_triggers.py +++ b/tests/test_registration_triggers.py @@ -8,9 +8,11 @@ import azure.functions as func import pytest +from azure_functions_agents._trigger_support import is_supported_trigger_type 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 +30,11 @@ ) +@pytest.mark.parametrize("trigger_type", sorted(TRIGGER_TYPES)) +def test_all_documented_trigger_types_are_supported(trigger_type: str) -> None: + assert is_supported_trigger_type(trigger_type) + + 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..06ea240f 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, + owner_policies=None, + handler_catalog=None, +) -> Callable[..., Any]: app = _FakeApp() - engine.register_workflows(app, catalog=catalog) + engine.register_workflows( + app, + catalog=catalog, + owner_policies=owner_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"), + owner_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", + "owner_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"), + owner_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", + "owner_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"), + owner_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", + "owner_slug": "coordinator", + } + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("owner_policies", [None, {}]) +async def test_sub_agent_activity_missing_owner_policy_fails_closed( + owner_policies, +) -> None: + activity = _registered_function( + engine.SUB_AGENT_ACTIVITY_NAME, + catalog=_catalog("pr_status_analyst"), + owner_policies=owner_policies, + ) + + with pytest.raises(RuntimeError, match="owner policy"): + await activity( + { + "id": "analyze_pr", + "agent": "pr_status_analyst", + "task": "Analyze PR 117.", + "workflow_id": "workflow-1", + "owner_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"), + owner_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", + "owner_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 = {"owner_slug": "coordinator", "tasks": tasks} self._result_for = result_for self.calls: list[tuple[str, dict[str, Any]]] = [] self.last_wave = _Task([]) @@ -295,9 +380,105 @@ 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["owner_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_owner_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": {}, + "owner_slug": "coordinator", + "workflow_id": "workflow-parent", + }, + ) + ] + + +def test_tool_activity_reauthorizes_current_owner_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, + owner_policies={ + "owner": WorkflowPlanPolicy( + allowed_tools=frozenset({"publish"}), + allowed_subagents=frozenset(), + ) + }, + ) + revoked = _registered_function( + "agents_workflow_run_tool", + handler_catalog=handler_catalog, + owner_policies={ + "owner": WorkflowPlanPolicy( + allowed_tools=frozenset(), + allowed_subagents=frozenset(), + ) + }, + ) + payload = { + "id": "publish", + "tool": "publish", + "args": {"value": 1}, + "owner_slug": "owner", + "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("owner_policies", [None, {}]) +def test_tool_activity_missing_owner_policy_fails_closed(owner_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, + owner_policies=owner_policies, + ) + + with pytest.raises(RuntimeError, match="owner policy"): + activity( + { + "id": "publish", + "tool": "publish", + "args": {}, + "owner_slug": "missing", + "workflow_id": "workflow-1", + } + ) diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index ccb8c295..17387ee9 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -93,12 +93,14 @@ 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"] @@ -106,6 +108,7 @@ async def start_new(self, *args, **kwargs): def failing_workflow_session(): session_id = "session-1" token = context.register_workflow_session( + "test-agent", session_id, "test-agent", _FailingDurableClient(), @@ -113,12 +116,16 @@ def failing_workflow_session(): try: yield session_id finally: - context.unregister_workflow_session(session_id, token) + context.unregister_workflow_session("test-agent", session_id, token) -def _registered_blueprint_function(name): +def _registered_blueprint_function( + name, + *, + owner_policies=None, +): app = _FakeApp() - engine.register_workflows(app) + engine.register_workflows(app, owner_policies=owner_policies) [blueprint] = app.blueprints for builder in blueprint._function_builders: function = builder._function @@ -533,15 +540,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", + owner_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": {}, + "owner_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 " + "owner=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,8 +630,12 @@ 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( + owner_slug="test-agent", session_id=failing_workflow_session, agent_name="test-agent", durable_client=_FailingDurableClient(), @@ -616,7 +647,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 +659,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,6 +667,7 @@ async def test_start_workflow_rejects_new_workflow_when_session_active_cap_reach ] client = _CappedDurableClient(statuses) session = context.WorkflowSessionContext( + owner_slug="test-agent", session_id=session_id, agent_name="test-agent", durable_client=client, @@ -664,6 +696,7 @@ async def get_status_all(self): raise AssertionError("authorization must fail before Durable scheduling") session = context.WorkflowSessionContext( + owner_slug="coordinator", session_id="session-1", agent_name="coordinator", durable_client=_UnexpectedClient(), @@ -689,6 +722,38 @@ async def get_status_all(self): assert "not authorized" in json.loads(result)["error"] +@pytest.mark.asyncio +async def test_start_workflow_threads_owner_slug_into_durable_input() -> None: + client = _CappedDurableClient([]) + session = context.WorkflowSessionContext( + owner_slug="incident", + session_id="session-1", + agent_name="Incident", + durable_client=client, + token="", + ) + 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"]["owner_slug"] == "incident" + assert client.start_kwargs["client_input"]["owner"] == { + "owner_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 +789,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 +797,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 +805,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( From 3e0c02c613922b0307a81063133313373cdb5811 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 20:19:07 -0700 Subject: [PATCH 05/18] feat: add per-agent workflows sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- samples/per-agent-workflows/README.md | 203 +++++ samples/per-agent-workflows/scripts/verify.py | 821 ++++++++++++++++++ samples/per-agent-workflows/src/.funcignore | 6 + .../agents/incident_evidence_analyst.agent.md | 14 + .../src/agents/release_risk_reviewer.agent.md | 14 + .../per-agent-workflows/src/function_app.py | 15 + samples/per-agent-workflows/src/host.dts.json | 25 + samples/per-agent-workflows/src/host.json | 21 + .../src/incident_commander.agent.md | 38 + .../src/local.settings.template.json | 13 + .../src/release_manager.agent.md | 38 + .../per-agent-workflows/src/requirements.txt | 2 + .../src/tools/incident_tools.py | 162 ++++ .../src/tools/release_tools.py | 171 ++++ .../agents/incident_analyst.agent.md | 6 + .../agents/release_reviewer.agent.md | 6 + .../incident_commander.agent.md | 13 + .../release_manager.agent.md | 13 + tests/test_config_fixtures.py | 33 + tests/test_per_agent_workflows_sample.py | 227 +++++ tests/test_per_agent_workflows_verify.py | 330 +++++++ 21 files changed, 2171 insertions(+) create mode 100644 samples/per-agent-workflows/README.md create mode 100644 samples/per-agent-workflows/scripts/verify.py create mode 100644 samples/per-agent-workflows/src/.funcignore create mode 100644 samples/per-agent-workflows/src/agents/incident_evidence_analyst.agent.md create mode 100644 samples/per-agent-workflows/src/agents/release_risk_reviewer.agent.md create mode 100644 samples/per-agent-workflows/src/function_app.py create mode 100644 samples/per-agent-workflows/src/host.dts.json create mode 100644 samples/per-agent-workflows/src/host.json create mode 100644 samples/per-agent-workflows/src/incident_commander.agent.md create mode 100644 samples/per-agent-workflows/src/local.settings.template.json create mode 100644 samples/per-agent-workflows/src/release_manager.agent.md create mode 100644 samples/per-agent-workflows/src/requirements.txt create mode 100644 samples/per-agent-workflows/src/tools/incident_tools.py create mode 100644 samples/per-agent-workflows/src/tools/release_tools.py create mode 100644 tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/incident_analyst.agent.md create mode 100644 tests/fixtures/config_scenarios/18_multi_owner_workflows/agents/release_reviewer.agent.md create mode 100644 tests/fixtures/config_scenarios/18_multi_owner_workflows/incident_commander.agent.md create mode 100644 tests/fixtures/config_scenarios/18_multi_owner_workflows/release_manager.agent.md create mode 100644 tests/test_per_agent_workflows_sample.py create mode 100644 tests/test_per_agent_workflows_verify.py diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md new file mode 100644 index 00000000..9d92f568 --- /dev/null +++ b/samples/per-agent-workflows/README.md @@ -0,0 +1,203 @@ +# 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 owners and their specialist agents. + +## 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 owner has a distinct +`workflows.exclude` set and one distinct `workflows.subagents` grant. Specialists +are internal files without triggers or built-in endpoints. + +## 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 or 3.14 with this repository installed using `pip install -e .[dev]` +- Azure Functions Core Tools v4 (`func`) +- Docker (Azurite is always required; the DTS emulator is optional) +- 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. + +For manual use, `src/requirements.txt` keeps the repository's standard +`-e ../../..` editable reference, which resolves to this checkout from the +committed sample directory. The verifier does not install requirements from its +nested temporary copy; it authoritatively prepends this checkout's `src` to the +Functions worker `PYTHONPATH` and fails startup if a different runtime is loaded. + +## 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: + +- +- + +The equivalent chat APIs are: + +- `POST /agents/incident_commander/chat` +- `POST /agents/release_manager/chat` + +Use the same valid `x-ms-session-id` header when demonstrating owner isolation. +Workflow polling is owner-specific: + +```text +GET /agents/incident_commander/workflow-status?workflow_id= +GET /agents/incident_commander/workflows +GET /agents/release_manager/workflow-status?workflow_id= +GET /agents/release_manager/workflows +``` + +### Exact incident demo 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. + +Expected terminal output: `runtime_status` is `Completed`; the +`output.results.incident_report` object contains `INCIDENT_REPORT_READY`, +`"severity": "SEV2"`, and `"decision": "ROLLBACK"`. + +### Exact release demo 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. + +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. + +## One-command verification + +The verifier creates uniquely named Docker containers with ephemeral host +ports, makes an isolated temporary app copy under this sample directory, writes +temporary settings, starts `func` on an ephemeral port, and cleans everything up. +It sends the SAME `x-ms-session-id` to both owner chat routes, starts both +workflows before polling, validates their structured terminal results and +capability sets, checks cross-owner status returns 404, and confirms list routes +do not expose the other owner. + +```powershell +python scripts/verify.py +``` + +The default `--backend storage` needs only Azurite. To keep containers after a +failure, add `--keep-services`. + +## DTS instructions + +DTS still requires Azurite for the Functions host's own storage. The verifier +starts both isolated containers, switches its temporary copy to +`src/host.dts.json`, and configures the mapped DTS gRPC port: + +```powershell +python scripts/verify.py --backend dts +``` + +The DTS container uses `DTS_TASK_HUB_NAMES=engineeringopshub`; its gRPC and +dashboard container ports are 8080 and 8082, both mapped to ephemeral localhost +ports. The verifier prints the mapped dashboard URL after success. + +For a manual DTS run, 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. +- **Docker unavailable:** start Docker and verify `docker info` succeeds. +- **No model provider configured:** create `src/local.settings.json` and fill in + one supported provider; blank template values intentionally fail the verifier. +- **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:** rerun with `--timeout 600`; inspect the Functions + output and optional DTS dashboard. The verifier still removes containers + unless `--keep-services` is supplied. diff --git a/samples/per-agent-workflows/scripts/verify.py b/samples/per-agent-workflows/scripts/verify.py new file mode 100644 index 00000000..0a817f44 --- /dev/null +++ b/samples/per-agent-workflows/scripts/verify.py @@ -0,0 +1,821 @@ +"""Verify both Engineering Operations Hub workflow owners end to end.""" + +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 + +SAMPLE_ROOT = Path(__file__).resolve().parents[1] +SAMPLE_SRC = SAMPLE_ROOT / "src" +REPO_ROOT = Path(__file__).resolve().parents[3] +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." +) + +Owner = Literal["incident_commander", "release_manager"] + + +class EmulatorCommands(NamedTuple): + azurite: list[str] + dts: list[str] | None + + +OWNER_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(owner: Owner, envelope: Mapping[str, object]) -> None: + """Validate terminal success, deterministic output, and owner capabilities.""" + if envelope.get("runtime_status") != "Completed": + raise RuntimeError( + f"{owner} 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"{owner} workflow output has no results object") + + expected = OWNER_EXPECTATIONS[owner] + 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"{owner} 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"{owner} terminal report has invalid {key!r}") + + known = set().union( + *(set(item["allowed"]) for item in OWNER_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"{owner} used unauthorized capabilities: {sorted(unauthorized)!r}" + ) + missing = set(expected["required"]) - used # type: ignore[arg-type] + if missing: + raise RuntimeError(f"{owner} 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"{owner} evidence {capability!r} has an invalid identity or service" + ) + + +def validate_owner_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("owner list exposed the other owner's workflow") + if own_workflow_id not in ids: + raise RuntimeError("owner 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()), + } + 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_MODEL",), + "azure_openai": ( + "AZURE_OPENAI_DEPLOYMENT", + "AZURE_OPENAI_API_VERSION", + ), + "openai": ("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 + ) + environment["AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT"] = checkout_src + return environment + + +@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__"), + ) + if backend == "dts": + shutil.copyfile(app_dir / "host.dts.json", app_dir / "host.json") + 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_owner(host: _FunctionHost, owner: Owner, prompt: str, *, timeout: float) -> str: + status, payload = _request_json( + "POST", + f"{host.base_url}/agents/{owner}/chat", + payload={"prompt": prompt}, + timeout=timeout, + ) + if status != 200: + raise RuntimeError(f"{owner} chat returned HTTP {status}: {payload!r}") + return extract_workflow_id(payload) + + +def _poll_owner( + host: _FunctionHost, + owner: Owner, + workflow_id: str, + *, + timeout: float, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + url = ( + f"{host.base_url}/agents/{owner}/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"{owner} status route returned HTTP {status}: {payload!r}") + time.sleep(2) + raise RuntimeError( + f"{owner} workflow {workflow_id} did not finish within {timeout:.0f}s " + f"(last status: {last_status})" + ) + + +def _assert_http_isolation( + host: _FunctionHost, + owner: Owner, + own_id: str, + other_id: str, + *, + timeout: float, +) -> None: + status_url = ( + f"{host.base_url}/agents/{owner}/workflow-status?" + f"{urlencode({'workflow_id': other_id})}" + ) + status, _ = _request_json("GET", status_url, timeout=timeout) + if status != 404: + raise RuntimeError( + f"{owner} status route exposed the other owner with HTTP {status}" + ) + status, payload = _request_json( + "GET", + f"{host.base_url}/agents/{owner}/workflows", + timeout=timeout, + ) + if status != 200: + raise RuntimeError(f"{owner} list route returned HTTP {status}: {payload!r}") + validate_owner_list(payload, own_id, other_id) + + +def verify(*, backend: str, timeout: float, keep_services: bool) -> None: + """Run both owners 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...") + incident_id = _start_owner( + host, "incident_commander", INCIDENT_PROMPT, timeout=timeout + ) + release_id = _start_owner( + host, "release_manager", RELEASE_PROMPT, timeout=timeout + ) + + incident = _poll_owner( + host, "incident_commander", incident_id, timeout=timeout + ) + release = _poll_owner( + 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 owner workflows completed with isolated capabilities, " + "cross-owner 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/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..589ce53d --- /dev/null +++ b/samples/per-agent-workflows/src/function_app.py @@ -0,0 +1,15 @@ +import os +from pathlib import Path + +import azure_functions_agents +from azure_functions_agents import create_function_app + +expected_root = os.environ.get("AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT") +if expected_root: + runtime_file = Path(azure_functions_agents.__file__).resolve() + if not runtime_file.is_relative_to(Path(expected_root).resolve()): + raise RuntimeError( + "azure_functions_agents was not imported from the verifier's current checkout" + ) + +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..9bf2c880 --- /dev/null +++ b/samples/per-agent-workflows/src/requirements.txt @@ -0,0 +1,2 @@ +-e ../../.. + 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/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/test_config_fixtures.py b/tests/test_config_fixtures.py index 43b014bb..3bdb6265 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 owners 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_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py new file mode 100644 index 00000000..0f0e1be0 --- /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_owners_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", + "python scripts/verify.py", + "--backend dts", + "x-ms-session-id", + "INCIDENT_REPORT_READY", + "RELEASE_DOSSIER_READY", + "Troubleshooting", + ): + assert required in readme + + 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..89d8e947 --- /dev/null +++ b/tests/test_per_agent_workflows_verify.py @@ -0,0 +1,330 @@ +"""Pure tests for the per-agent workflow sample verifier.""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +from types import ModuleType + +import pytest + +SAMPLE_ROOT = Path(__file__).resolve().parents[1] / "samples" / "per-agent-workflows" +VERIFY_SCRIPT = SAMPLE_ROOT / "scripts" / "verify.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"] + assert Path(environment["AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT"]).resolve() == ( + verify.REPO_ROOT / "src" + ).resolve() + + +def _clear_provider_environment( + monkeypatch: pytest.MonkeyPatch, + verify: ModuleType, +) -> None: + for key in verify.PROVIDER_KEYS: + monkeypatch.delenv(key, raising=False) + + +@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, + values: dict[str, str], + provider: str, +) -> None: + verify = _load_verify_module() + _clear_provider_environment(monkeypatch, verify) + for key, value in values.items(): + monkeypatch.setenv(key, value) + + resolved = verify._provider_values() + + assert resolved["AZURE_FUNCTIONS_AGENTS_PROVIDER"] == provider + + +@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, + missing: str, + message: str, +) -> None: + verify = _load_verify_module() + _clear_provider_environment(monkeypatch, verify) + 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, +) -> None: + verify = _load_verify_module() + _clear_provider_environment(monkeypatch, verify) + 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_owner_list_rejects_cross_owner_exposure() -> None: + verify = _load_verify_module() + incident_id = ( + "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc" + ) + release_id = ( + "fedcba9876543210fedcba9876543210-12345678123412341234123456789abc" + ) + + verify.validate_owner_list({"workflows": [{"workflow_id": incident_id}]}, incident_id, release_id) + with pytest.raises(RuntimeError, match="exposed the other owner"): + verify.validate_owner_list( + {"workflows": [{"workflow_id": incident_id}, {"workflow_id": release_id}]}, + incident_id, + release_id, + ) From 3711eaa65abb1159cb1330a7905a4dbfa4805ad4 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Mon, 10 Aug 2026 20:31:18 -0700 Subject: [PATCH 06/18] Document per-agent Dynamic Workflows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- README.md | 6 +- docs/architecture.md | 107 ++++++++++++------ docs/frds/0004-dynamic-workflows.md | 4 + docs/frds/0009-per-agent-dynamic-workflows.md | 38 ++++--- docs/front-matter-reference.md | 2 +- docs/front-matter-spec.md | 25 ++-- docs/triggers.md | 17 +-- docs/workflows.md | 75 +++++++++--- eng/scripts/generate_config_reference.py | 2 +- samples/README.md | 11 +- samples/workflow-incident-triage/README.md | 3 +- 11 files changed, 197 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 69ef8422..738361d0 100644 --- a/README.md +++ b/README.md @@ -428,7 +428,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 with a supported trigger, chat API, +or MCP endpoint can own workflows; see the +[`per-agent-workflows`](samples/per-agent-workflows) sample for two independent +non-main owners sharing one Durable engine. ## Built-in Endpoint Routes @@ -552,6 +555,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 owners and one-command Storage/DTS verification ## Deployment Notes diff --git a/docs/architecture.md b/docs/architecture.md index 13c31aea..b3c36abf 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 + owner-policy catalog
(immutable)"] + W -->|"any owner?"| 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 -->|"owner 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 owner-policy catalog. This makes both delegation and per-owner 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, validates references and workflow-starter eligibility, then + freezes the `AgentCatalog`, complete workflow-handler catalog, and per-owner + workflow-policy catalog. Only pass 2 creates/mutates the app, registers the + workflow runtime once, and registers agent surfaces (FRDs 0007 and 0009). - **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 owner-policy catalog. It chooses `DFApp` when any eligible owner exists, 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 owner-policy catalog, per-owner management tools/addenda, and performs the one app-wide Durable registration. It also rejects enabled owners without a supported trigger, chat API, or MCP starter. | `build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()`, `build_owner_workflow_integration()`, `register_workflow_runtime()`, `validate_workflow_owner_starter()` | +| `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 owner policy before complete-catalog dispatch. | `register_workflows()` | +| `azure_functions_agents/workflows/context.py` | Tracks invocation context by `(owner_slug, session_id)` and derives non-revealing 128-bit ownership prefixes for Durable instance IDs. | `session_instance_prefix()`, `new_workflow_instance_id()`, `session_owns_workflow()` | +| `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 owner-scoped management tools. Start-time validation and list/status/cancel/terminate operations use the captured owner policy and owner/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 enabled eligible owner. +11. `app.py` creates a `DFApp` when the owner-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 owner 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,52 +165,57 @@ 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_owner_starter()` - **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 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. Separately, a workflow-enabled owner must have a supported trigger, chat API, or MCP starter; debug UI alone is insufficient. 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 owner'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 owner-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_owner_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 `WorkflowOwnerPolicyCatalog` + - **Notes:** the handler and Agent catalogs answer what exists app-wide. They do not grant an owner access. Each enabled eligible owner 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 eligible owner policy exists, otherwise a plain `FunctionApp`) + - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and owner policies are captured by one app-level Durable registration before agent registration begins. 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 owner policy. Eligible trigger/chat API/MCP surfaces receive workflow guidance, owner-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 owner, `workflows/integration.py` uses the cataloged immutable +`WorkflowPlanPolicy` to generate model guidance and owner-scoped management +tools. Built-in chat/MCP handlers receive the chat addendum; declared-trigger +handlers receive the trigger addendum, Durable client, owner slug, and policy. +`start_workflow` validates against that policy. The orchestrator carries +`owner_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. +Ownership is `(owner_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 owners 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 owner 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. @@ -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 owner's + `WorkflowPlanPolicy`; it does not shrink the complete Activity handler catalog. +- `WorkflowIntegrationResult` supplies owner-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` / `WorkflowOwnerPolicyCatalog` — complete immutable + Activity handler inventory plus immutable per-owner authorization policies. + Built once after `AgentCatalog`; consumed by one-time Durable registration and + owner-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 + owner-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..426e51b5 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -11,6 +11,10 @@ pull_requests: [https://github.com/Azure/azure-functions-agents-runtime/pull/77, # FRD 0004 — Dynamic workflows +> **Superseded scope note:** FRD 0009 extends this historical main-agent-only +> design to [eligible per-agent workflow owners](0009-per-agent-dynamic-workflows.md). +> The original decisions below remain the record of the initial v1 design. + ## 1. Summary Add experimental Dynamic Workflows support to the markdown-first Azure Functions diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 01963851..3d0ff9dd 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -385,7 +385,7 @@ authentication. ## 6. Test plan -- [ ] Unit: composition and owner-policy catalog +- [x] Unit: composition and owner-policy catalog - any eligible non-main agent can enable workflows; - an app with only non-main workflow owners is a `df.DFApp`; - `main.agent.md` remains supported; @@ -394,20 +394,20 @@ authentication. - distinct owners receive independent tool excludes, Sub Agent grants, and prompt guidance; - owner-policy mappings and values are immutable. -- [ ] Unit: one-time runtime registration +- [x] Unit: one-time runtime registration - multiple enabled owners register one orchestrator and one copy of each Activity; - complete workflow handler and Agent catalogs remain available; - excluding a handler for one owner does not unregister it for another; - production execution does not authorize from the singleton app allowlist. -- [ ] Unit: owner-scoped context and management +- [x] Unit: owner-scoped context and management - the same session ID under two owner slugs generates different prefixes; - active limits, list, status, cancel, and terminate require both owner and session; - cross-owner operations return empty/not-found without disclosing existence; - legacy session-only IDs do not match an owner-scoped prefix, are treated as not-found, and their Durable instances are not deleted or mutated. -- [ ] Unit: plan and Activity authorization +- [x] Unit: plan and Activity authorization - prompt guidance and start-time validation use the same owner policy; - tool and Workflow Sub Agent Activities reject capabilities belonging only to another owner; @@ -415,7 +415,7 @@ authentication. - restrictive policy changes reject a pending disallowed node; - every capability-bearing Activity payload contains `owner_slug`; - `wait` tasks retain existing behavior. -- [ ] Integration: invocation channels +- [x] Integration: invocation channels - multiple workflow-enabled agents register distinct chat, streaming, MCP, HTTP trigger, and non-HTTP trigger surfaces as configured; - each enabled surface receives the Durable client binding and correct @@ -423,19 +423,21 @@ authentication. - HTTP workflow polling routes cannot observe another owner under the same session ID; - trigger starters return/end without waiting for terminal workflow state. -- [ ] Workflow Sub Agent isolation +- [x] Workflow Sub Agent isolation - each owner can schedule only its own `workflows.subagents` grants; - one specialist may be granted to multiple owners without duplicate Activity registration; - workflow leaf specialists retain their current isolated execution role. -- [ ] Fixture scenario: - `tests/fixtures/config_scenarios/_multi_owner_workflows/`. +- [x] Fixture scenario: + `tests/fixtures/config_scenarios/18_multi_owner_workflows/`. - [ ] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping session IDs, distinct policies, status/control isolation, and execution after starter completion. -- [ ] Sample verifier: one command starts dependencies and proves both successful - workflows plus cross-owner denial. -- [ ] Canonical gate: +- [x] Sample verifier: one command starts dependencies and proves both successful + workflows plus cross-owner denial. The script and its pure verification tests + are implemented; model-backed Storage/DTS execution remains covered by the + unchecked E2E item above. +- [x] Canonical gate: - `python -m ruff check src tests`; - `python -m mypy src`; - `python -m pytest --cache-clear --cov=./src/azure_functions_agents @@ -443,17 +445,17 @@ authentication. ## 7. Docs impact -- [ ] `docs/architecture.md` — add the owner-policy catalog, one-time Durable +- [x] `docs/architecture.md` — add the owner-policy catalog, one-time Durable registration, owner-scoped execution, and Activity reauthorization. -- [ ] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and +- [x] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and document eligible starter surfaces. -- [ ] `docs/workflows.md` — document multiple owners, identity, isolation, +- [x] `docs/workflows.md` — document multiple owners, identity, isolation, migration, trigger ownership, and operator guidance. -- [ ] `docs/triggers.md` — clarify that each workflow-enabled declared trigger +- [x] `docs/triggers.md` — clarify that each workflow-enabled declared trigger uses its owning agent's policy and Durable client. -- [ ] `README.md` — link the per-agent workflow sample. -- [ ] `samples/README.md` — list the runnable sample and its one-command verifier. -- [ ] `docs/front-matter-reference.md` — no change expected because no schema +- [x] `README.md` — link the per-agent workflow sample. +- [x] `samples/README.md` — list the runnable sample and its one-command verifier. +- [x] `docs/front-matter-reference.md` — no change expected because no schema change is planned. ## 8. Status & sign-off diff --git a/docs/front-matter-reference.md b/docs/front-matter-reference.md index 97189a44..f17f5c48 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) | diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md index b81ee09c..10ba2d63 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 with an eligible starter - 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 eligible 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 @@ -125,7 +125,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) @@ -567,7 +567,7 @@ tools: false #### `workflows` - **Type:** `object` -- **Location:** Agent front matter (`main.agent.md` only in v1) +- **Location:** Agent front matter (any agent with an eligible workflow starter) - **Description:** Enables Dynamic Workflows, filters discovered workflow tools, and grants access to leaf specialists for workflow tasks. @@ -584,8 +584,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 +owner-allowed public `@workflow_tool` handlers discovered from `tools/*.py` as +workflow task targets. No new owner or starter fields are required; owner +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 +595,14 @@ 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 owner's workflow Activity targets; it does +not affect normal tools or another owner's workflow policy. Conversely, +`tools.exclude` filters normal MAF tools and does not hide workflow tools. + +An enabled owner must expose at least one eligible starter: a supported declared +`trigger`, `builtin_endpoints.chat_api`, or `builtin_endpoints.mcp`. +`builtin_endpoints.debug_chat_ui` alone is insufficient because the UI depends +on the chat API. An enabled owner without a starter fails startup. `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 +663,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. diff --git a/docs/triggers.md b/docs/triggers.md index 4003972e..b6a71de4 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 any eligible 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 owner 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 owner 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 owner 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 7c4f239b..1b34027b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -9,7 +9,12 @@ > [queue-trigger sample](../samples/workflow-queue-p0-report/README.md) for a > non-interactive starter. The > [parallel PR report sample](../samples/workflow-subagents-preview/README.md) -> demonstrates workflow Sub Agents. Larger features such as sub-orchestrations, +> demonstrates workflow Sub Agents. The +> [Engineering Operations Hub](../samples/per-agent-workflows/README.md) +> demonstrates two non-main workflow owners with independent policies in one app. +> Its [one-command verifier](../samples/per-agent-workflows/README.md#one-command-verification) +> exercises same-session isolation with Azure Storage or DTS. +> Larger features such as sub-orchestrations, > configurable retry policies, and MCP Tasks integration are tracked as v2 > follow-up work. @@ -41,9 +46,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 + eligible agent in that app can own workflows and authorize leaf specialists. ## Why workflows (token, latency, context) @@ -165,11 +169,23 @@ 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. +> A workflow owner must have an eligible **starter**: a supported declared +> `trigger`, `builtin_endpoints.chat_api`, or `builtin_endpoints.mcp`. The debug +> UI alone is insufficient because it calls the chat API; enable `chat_api` too. +> Startup fails rather than silently accepting an enabled but inert owner. + +### App-wide engine, per-owner policy + +The app discovers complete, immutable catalogs of workflow handlers and agents. +If at least one eligible workflow owner 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 owner. + +Each enabled owner 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 owner's policy. One owner's exclusion never +removes a handler another owner is allowed to use. ### Workflow tool authoring @@ -260,7 +276,7 @@ hardening controls. ### Workflow Sub Agents -The author grants access in `main.agent.md` with `workflows.subagents`. Each +The owner grants access in its agent 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`, @@ -446,7 +462,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 +481,36 @@ 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. +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 owner index or reconnect API. In all cases the starter +returns after the initial model turn; orchestration continues asynchronously. + ## Ownership -Every workflow's Durable instance ID is prefixed with -`sha256(session_id)[:12]` at creation. `get_workflow_status`, +Workflow ownership is the pair `(owner_slug, session_id)`. Its Durable 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 owner **or** session does not match is treated +as nonexistent (404/empty, never 403), so two owners remain isolated even when +callers deliberately reuse the same session ID. + +Activities reauthorize immediately before dispatch against the **currently +deployed** owner policy. Removing an owner, disabling workflows, or tightening a +tool/Sub Agent grant therefore revokes pending capability-bearing nodes; they +fail closed rather than continuing under a stale policy snapshot. + +### Migration from legacy workflow IDs + +This experimental feature intentionally changes IDs from a session-only 48-bit +prefix to the owner-and-session 128-bit prefix. Pre-upgrade instances continue +running in Durable, but new agent tools and polling routes cannot list, inspect, +cancel, or terminate those legacy IDs. Drain or terminate active workflows +before upgrading when agent-level management must remain available; otherwise +use Durable Functions or DTS tooling to inspect or control legacy instances. ## Observability @@ -502,6 +540,8 @@ existence cannot be probed by guessing IDs across sessions). v1 includes: - five built-in workflow tools; +- any eligible agent may own workflows, with one app-wide engine and immutable + per-owner 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 +555,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, +v2 follow-up work includes sub-orchestrations and bounded nested agents, configurable caps, retry and timeout policies, HMAC-backed workflow ownership, 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..d15a1ed6 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)", } diff --git a/samples/README.md b/samples/README.md index a44a3af2..71891348 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 owners share one Durable engine while retaining separate policies. +From its directory, `python scripts/verify.py` verifies Azure Storage/Azurite; +add `--backend dts` to verify Durable Task Scheduler. ## Run Locally (optional) diff --git a/samples/workflow-incident-triage/README.md b/samples/workflow-incident-triage/README.md index ff559468..c0abd7c7 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 owner 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: From 014bda0d2128bf7dfa554fbabbaf39a791776184 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 09:44:35 -0700 Subject: [PATCH 07/18] Record per-agent workflow E2E Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0009-per-agent-dynamic-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 3d0ff9dd..190a9ef0 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -430,7 +430,7 @@ authentication. - workflow leaf specialists retain their current isolated execution role. - [x] Fixture scenario: `tests/fixtures/config_scenarios/18_multi_owner_workflows/`. -- [ ] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping +- [x] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping session IDs, distinct policies, status/control isolation, and execution after starter completion. - [x] Sample verifier: one command starts dependencies and proves both successful From f0ca21fa262ef73e8e46c2f685f489c1fef3693c Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 10:03:28 -0700 Subject: [PATCH 08/18] Add manual workflow sender sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- samples/per-agent-workflows/README.md | 32 +++++ samples/per-agent-workflows/scripts/send.py | 124 ++++++++++++++++++++ tests/test_per_agent_workflows_sample.py | 3 + tests/test_per_agent_workflows_send.py | 81 +++++++++++++ tests/test_per_agent_workflows_verify.py | 19 +++ 5 files changed, 259 insertions(+) create mode 100644 samples/per-agent-workflows/scripts/send.py create mode 100644 tests/test_per_agent_workflows_send.py diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 9d92f568..3bdb74ac 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -119,6 +119,38 @@ GET /agents/release_manager/workflow-status?workflow_id= GET /agents/release_manager/workflows ``` +### Send the sample messages yourself + +Keep `func start` running from `samples\per-agent-workflows\src`. In a second +PowerShell terminal, move to the sample root: + +```powershell +Set-Location samples\per-agent-workflows +``` + +Send only the incident workflow message: + +```powershell +python scripts/send.py incident +``` + +Send only the release workflow message: + +```powershell +python scripts/send.py release +``` + +Or start both owners with the same session ID to observe owner isolation: + +```powershell +python scripts/send.py both +``` + +This script does not start Docker, emulators, or the Functions host and does not +poll for completion. It only posts the documented prompt, then prints the +workflow ID and owner-specific status URL. Use `--base-url` for a non-default +host and `--session-id` to choose the shared session. + ### Exact incident demo prompt > Start exactly one incident workflow now for incident INC-4821 on checkout-api. diff --git a/samples/per-agent-workflows/scripts/send.py b/samples/per-agent-workflows/scripts/send.py new file mode 100644 index 00000000..cd4fa041 --- /dev/null +++ b/samples/per-agent-workflows/scripts/send.py @@ -0,0 +1,124 @@ +"""Send sample workflow-start messages to an already running Functions host.""" + +from __future__ import annotations + +import argparse +import json +from typing import Literal +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from verify import INCIDENT_PROMPT, RELEASE_PROMPT, extract_workflow_id + +type Pipeline = Literal["incident", "release"] + +PIPELINES: dict[Pipeline, tuple[str, str]] = { + "incident": ("incident_commander", INCIDENT_PROMPT), + "release": ("release_manager", RELEASE_PROMPT), +} +DEFAULT_SESSION_ID = "engineering-ops-manual-session" + + +def build_chat_request( + pipeline: Pipeline, + *, + base_url: str, + session_id: str, +) -> Request: + """Build one owner-specific chat request.""" + owner, prompt = PIPELINES[pipeline] + return Request( + f"{base_url.rstrip('/')}/agents/{owner}/chat", + data=json.dumps({"prompt": prompt}).encode(), + headers={ + "Content-Type": "application/json", + "x-ms-session-id": session_id, + }, + method="POST", + ) + + +def send_pipeline( + pipeline: Pipeline, + *, + base_url: str, + session_id: str, + timeout: float, +) -> str: + """Send one workflow-start message and return its workflow ID.""" + request = build_chat_request( + pipeline, + base_url=base_url, + session_id=session_id, + ) + try: + with urlopen(request, timeout=timeout) as response: + body = response.read() + except HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise RuntimeError(f"{pipeline} chat returned HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise RuntimeError( + f"could not reach {request.full_url}; start `func` and try again: {exc.reason}" + ) from exc + + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{pipeline} chat returned invalid JSON") from exc + return extract_workflow_id(payload) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Send workflow-start messages to a manually started sample host." + ) + parser.add_argument( + "pipeline", + choices=("incident", "release", "both"), + help="Pipeline to start.", + ) + parser.add_argument( + "--base-url", + default="http://localhost:7071", + help="Functions host URL (default: http://localhost:7071).", + ) + parser.add_argument( + "--session-id", + default=DEFAULT_SESSION_ID, + help=f"Shared chat session ID (default: {DEFAULT_SESSION_ID}).", + ) + parser.add_argument( + "--timeout", + type=float, + default=180, + help="Chat request timeout in seconds (default: 180).", + ) + args = parser.parse_args() + + selected: tuple[Pipeline, ...] = ( + ("incident", "release") if args.pipeline == "both" else (args.pipeline,) + ) + for pipeline in selected: + owner, _ = PIPELINES[pipeline] + print(f"Sending {pipeline} workflow request to {owner}...") + workflow_id = send_pipeline( + pipeline, + base_url=args.base_url, + session_id=args.session_id, + timeout=args.timeout, + ) + query = urlencode({"workflow_id": workflow_id}) + print(f"{pipeline} workflow ID: {workflow_id}") + print( + f"status: {args.base_url.rstrip('/')}/agents/{owner}/workflow-status?{query}" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RuntimeError as exc: + raise SystemExit(f"FAIL: {exc}") from exc diff --git a/tests/test_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py index 0f0e1be0..db2410d0 100644 --- a/tests/test_per_agent_workflows_sample.py +++ b/tests/test_per_agent_workflows_sample.py @@ -203,6 +203,7 @@ def test_sample_runtime_files_and_readme_are_complete() -> None: "requirements.txt", ): assert (SAMPLE_SRC / name).is_file() + assert (SAMPLE_ROOT / "scripts" / "send.py").is_file() assert not (SAMPLE_SRC / "main.agent.md").exists() readme = (SAMPLE_ROOT / "README.md").read_text(encoding="utf-8") @@ -212,6 +213,8 @@ def test_sample_runtime_files_and_readme_are_complete() -> None: "Incident workflow", "Release workflow", "python scripts/verify.py", + "python scripts/send.py incident", + "python scripts/send.py release", "--backend dts", "x-ms-session-id", "INCIDENT_REPORT_READY", diff --git a/tests/test_per_agent_workflows_send.py b/tests/test_per_agent_workflows_send.py new file mode 100644 index 00000000..8e882d1c --- /dev/null +++ b/tests/test_per_agent_workflows_send.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +SAMPLE_ROOT = Path(__file__).resolve().parents[1] / "samples" / "per-agent-workflows" +SEND_SCRIPT = SAMPLE_ROOT / "scripts" / "send.py" + + +def _load_send_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + monkeypatch.syspath_prepend(str(SEND_SCRIPT.parent)) + spec = importlib.util.spec_from_file_location("per_agent_workflows_send", SEND_SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class _Response: + status = 200 + + def __init__(self, payload: object) -> None: + self._body = json.dumps(payload).encode() + + def __enter__(self) -> _Response: + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return self._body + + +def test_send_pipeline_posts_prompt_and_shared_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + send = _load_send_module(monkeypatch) + workflow_id = "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc" + captured: dict[str, object] = {} + + def fake_urlopen(request: object, *, timeout: float) -> _Response: + captured["request"] = request + captured["timeout"] = timeout + return _Response({"response": f"Started {workflow_id}"}) + + monkeypatch.setattr(send, "urlopen", fake_urlopen) + + actual = send.send_pipeline( + "incident", + base_url="http://localhost:7071/", + session_id="manual-shared-session", + timeout=90, + ) + + request = captured["request"] + assert request.full_url == "http://localhost:7071/agents/incident_commander/chat" + assert request.get_header("X-ms-session-id") == "manual-shared-session" + assert json.loads(request.data) == {"prompt": send.INCIDENT_PROMPT} + assert captured["timeout"] == 90 + assert actual == workflow_id + + +def test_send_pipeline_selects_release_owner(monkeypatch: pytest.MonkeyPatch) -> None: + send = _load_send_module(monkeypatch) + + request = send.build_chat_request( + "release", + base_url="http://127.0.0.1:7071", + session_id="release-session", + ) + + assert request.full_url == "http://127.0.0.1:7071/agents/release_manager/chat" + assert json.loads(request.data) == {"prompt": send.RELEASE_PROMPT} diff --git a/tests/test_per_agent_workflows_verify.py b/tests/test_per_agent_workflows_verify.py index 89d8e947..35d3011b 100644 --- a/tests/test_per_agent_workflows_verify.py +++ b/tests/test_per_agent_workflows_verify.py @@ -63,6 +63,19 @@ def _clear_provider_environment( 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"), [ @@ -92,11 +105,13 @@ def _clear_provider_environment( ) 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) @@ -114,11 +129,13 @@ def test_provider_values_select_environment_provider_over_template_default( ) 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", @@ -134,9 +151,11 @@ def test_provider_values_requires_complete_azure_openai_configuration( 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"): From 34611836657e7b07b9e1e382ea677101221ff8e9 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 10:38:30 -0700 Subject: [PATCH 09/18] Clarify agent roles and reachability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/architecture.md | 2 +- docs/front-matter-reference.md | 2 +- docs/front-matter-spec.md | 46 ++++++++++++++++++++---- docs/workflows.md | 4 +++ eng/scripts/generate_config_reference.py | 3 +- samples/per-agent-workflows/README.md | 5 +++ 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b3c36abf..dfbef1ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,7 +218,7 @@ non-HTTP triggers generate an invocation session and no application owner 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. diff --git a/docs/front-matter-reference.md b/docs/front-matter-reference.md index f17f5c48..976f2ef4 100644 --- a/docs/front-matter-reference.md +++ b/docs/front-matter-reference.md @@ -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 10ba2d63..b3842185 100644 --- a/docs/front-matter-spec.md +++ b/docs/front-matter-spec.md @@ -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, a workflow owner, 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 owner | Sets `workflows.enabled: true` and has an eligible starter: a supported trigger, chat API, or MCP endpoint. | +| Workflow Sub Agent | Is referenced by an owner'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. --- @@ -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` @@ -1237,7 +1260,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 @@ -1331,9 +1354,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/workflows.md b/docs/workflows.md index 1b34027b..2e55227a 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -174,6 +174,10 @@ agent markdown stays focused on the domain. > UI alone is insufficient because it calls the chat API; enable `chat_api` too. > Startup fails rather than silently accepting an enabled but inert owner. +File placement does not assign these roles. See +[Agent roles and reachability](./front-matter-spec.md#agent-roles-and-reachability) +for how direct agents, workflow owners, and internal specialists are identified. + ### App-wide engine, per-owner policy The app discovers complete, immutable catalogs of workflow handlers and agents. diff --git a/eng/scripts/generate_config_reference.py b/eng/scripts/generate_config_reference.py index d15a1ed6..be072a61 100644 --- a/eng/scripts/generate_config_reference.py +++ b/eng/scripts/generate_config_reference.py @@ -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/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 3bdb74ac..324e775b 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -27,6 +27,11 @@ There is intentionally no `main.agent.md`. Each owner 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 From 23f5b2ab287099bac74b417bbf820971bcd81742 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 11:19:13 -0700 Subject: [PATCH 10/18] Fix CI trigger and typing regressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- .../src/OnNewEmail.agent.md | 2 +- src/azure_functions_agents/config/loader.py | 18 ++++++++++-------- tests/test_outlook_reply_sample.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 tests/test_outlook_reply_sample.py diff --git a/samples/outlook-reply-agent/src/OnNewEmail.agent.md b/samples/outlook-reply-agent/src/OnNewEmail.agent.md index bbc9e42c..6381d88c 100644 --- a/samples/outlook-reply-agent/src/OnNewEmail.agent.md +++ b/samples/outlook-reply-agent/src/OnNewEmail.agent.md @@ -3,7 +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 + type: connector_trigger args: type: connectorTrigger --- diff --git a/src/azure_functions_agents/config/loader.py b/src/azure_functions_agents/config/loader.py index c1fb502c..7a28b4ab 100644 --- a/src/azure_functions_agents/config/loader.py +++ b/src/azure_functions_agents/config/loader.py @@ -154,15 +154,17 @@ def _load_agent_spec(source_file: Path) -> AgentSpec: # Keep the real on-disk path so diagnostics reference the file the user can actually edit normalized["source_file"] = str(resolved_source) raw_builtin_endpoints = normalized.get("builtin_endpoints") - internal_metadata = dict(normalized.get("metadata") or {}) - internal_metadata["_workflow_chat_api_starter"] = bool( - raw_builtin_endpoints is True - or ( - isinstance(raw_builtin_endpoints, dict) - and raw_builtin_endpoints.get("chat_api") is True + raw_metadata = normalized.get("metadata") + if raw_metadata is None or isinstance(raw_metadata, dict): + internal_metadata = dict(raw_metadata or {}) + internal_metadata["_workflow_chat_api_starter"] = bool( + raw_builtin_endpoints is True + or ( + isinstance(raw_builtin_endpoints, dict) + and raw_builtin_endpoints.get("chat_api") is True + ) ) - ) - normalized["metadata"] = internal_metadata + normalized["metadata"] = internal_metadata # agent.md and CLAUDE.md (and their case variants) are aliases for main.agent.md; # check the normalized name to determine main-agent status normalized["is_main"] = normalized_file.name.lower() == "main.agent.md" diff --git a/tests/test_outlook_reply_sample.py b/tests/test_outlook_reply_sample.py new file mode 100644 index 00000000..f013a437 --- /dev/null +++ b/tests/test_outlook_reply_sample.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from azure_functions_agents._trigger_support import is_supported_trigger_type +from azure_functions_agents.config.loader import load_agent_specs + +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 is_supported_trigger_type(agent.trigger.type) From 44ec9e2b641779afab7cce817613b4825c6c6f64 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 13:02:42 -0700 Subject: [PATCH 11/18] Harden per-agent workflow execution Preserve Activity failures, isolate compatibility registration tokens, align workflow starter validation, and make the chat UI the primary sample walkthrough. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0009-per-agent-dynamic-workflows.md | 21 +++-- docs/workflows.md | 30 +++++-- samples/per-agent-workflows/README.md | 90 +++++++++---------- .../_trigger_support.py | 33 ------- src/azure_functions_agents/app.py | 3 +- src/azure_functions_agents/config/loader.py | 19 ++-- .../config/validation.py | 11 --- .../registration/capabilities.py | 9 ++ .../registration/endpoints.py | 10 ++- .../registration/triggers.py | 7 +- .../workflows/context.py | 39 ++++---- .../workflows/engine.py | 5 ++ .../workflows/integration.py | 28 ++++-- src/azure_functions_agents/workflows/tools.py | 7 +- tests/test_app_routes.py | 18 ++-- tests/test_outlook_reply_sample.py | 4 +- tests/test_per_agent_workflows.py | 25 +++++- tests/test_registration_capabilities.py | 18 ++++ tests/test_registration_endpoints.py | 56 +++++++++++- tests/test_registration_triggers.py | 11 ++- tests/test_workflow_engine.py | 24 +++++ tests/test_workflow_registry.py | 44 +++++---- 22 files changed, 329 insertions(+), 183 deletions(-) delete mode 100644 src/azure_functions_agents/_trigger_support.py diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 190a9ef0..0e7c0429 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -4,7 +4,7 @@ title: Per-agent Dynamic Workflows status: Finalized author: TsuyoshiUshio created: 2026-08-10 -updated: 2026-08-10 +updated: 2026-08-11 issues: - "Azure/azure-functions-agents-runtime#109" - "Azure/azure-functions-bucees-planning#1274" @@ -318,12 +318,13 @@ Dynamic Workflows surface. Existing workflow IDs use a session-only hash prefix. New IDs use an owner-plus-session prefix. No application-level legacy fallback is proposed: -- pre-upgrade instances continue running in Durable; - new agent tools and polling endpoints cannot list, inspect, cancel, or - terminate those legacy IDs; -- operators can still inspect or control them through Durable/DTS; and -- deployments requiring continued agent-level management should drain or - terminate active workflows before upgrading. + terminate pre-upgrade IDs; +- legacy orchestration inputs contain no `owner_slug`, so in-flight instances + fail closed when they next dispatch a `tool` or `sub_agent` Activity; +- operators can still inspect or control remaining instances through + Durable/DTS; and +- deployments should drain or terminate active workflows before upgrading. The rest of the public surface remains compatible: @@ -382,6 +383,9 @@ authentication. | 11 | Authoring schema | Add owner/config fields / reuse current workflow config | Reuse existing fields; owner identity is runtime-derived | Agent | 2026-08-10 | | 12 | Sample proof | Extend a main-agent sample / documentation only / dedicated multi-owner sample | Add a runnable sample with two non-main owners and same-session isolation verification | Human | 2026-08-10 | | 13 | Ownership digest width | Retain 48-bit prefix / store literal owner data / expand digest | Use a 128-bit truncated SHA-256 prefix over a length-delimited owner/session encoding; avoids exposing raw identity while making collisions impractical | Human | 2026-08-10 | +| 14 | Existing exported compatibility helpers | Delete as production-dead / retain unchanged / isolate compatibility state | Retain the exported registry and one-shot integration helper to avoid an unrelated breaking change, but remove the registration token from production `WorkflowSessionContext` and keep it private to the compatibility registry | Agent | 2026-08-11 | +| 15 | Trigger decorator resolution | New shared resolver / duplicate capability validation / retain registration-local fallback | Keep the existing registration-local `connector_trigger` → `generic_trigger` fallback; workflow eligibility uses documented `TRIGGER_TYPES`, avoiding a new helper and an unrelated hard failure for non-workflow agents | Agent | 2026-08-11 | +| 16 | Activity failure propagation | Let Durable wrapper behavior surface / explicitly rethrow failed wave result | Explicitly rethrow a failed `task_all` result so owner-policy denials retain their original actionable error instead of becoming a secondary `TypeError` | Agent | 2026-08-11 | ## 6. Test plan @@ -414,6 +418,7 @@ authentication. - missing or disabled owner policy fails closed; - restrictive policy changes reject a pending disallowed node; - every capability-bearing Activity payload contains `owner_slug`; + - failed Activity waves preserve the original authorization/execution error; - `wait` tasks retain existing behavior. - [x] Integration: invocation channels - multiple workflow-enabled agents register distinct chat, streaming, MCP, @@ -422,7 +427,9 @@ authentication. channel addendum; - HTTP workflow polling routes cannot observe another owner under the same session ID; - - trigger starters return/end without waiting for terminal workflow state. + - trigger starters return/end without waiting for terminal workflow state; + - explicitly configured coercible `chat_api` values are evaluated consistently + with the validated endpoint model. - [x] Workflow Sub Agent isolation - each owner can schedule only its own `workflows.subagents` grants; - one specialist may be granted to multiple owners without duplicate Activity diff --git a/docs/workflows.md b/docs/workflows.md index 2e55227a..9a110ff6 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -510,11 +510,31 @@ fail closed rather than continuing under a stale policy snapshot. ### Migration from legacy workflow IDs This experimental feature intentionally changes IDs from a session-only 48-bit -prefix to the owner-and-session 128-bit prefix. Pre-upgrade instances continue -running in Durable, but new agent tools and polling routes cannot list, inspect, -cancel, or terminate those legacy IDs. Drain or terminate active workflows -before upgrading when agent-level management must remain available; otherwise -use Durable Functions or DTS tooling to inspect or control legacy instances. +prefix to the owner-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 `owner_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 owner 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 owner-policy and handler catalogs from +the same deployed agent project during app startup. Orchestrators persist +`owner_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 owner/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 `(owner_slug, session_id)`; +non-HTTP trigger invocations generate new session IDs, so that limit is not an +owner-wide throttle. ## Observability diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 324e775b..6d68ebd5 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -109,54 +109,9 @@ Open either debug UI: - - -The equivalent chat APIs are: +### Run the incident workflow in chat -- `POST /agents/incident_commander/chat` -- `POST /agents/release_manager/chat` - -Use the same valid `x-ms-session-id` header when demonstrating owner isolation. -Workflow polling is owner-specific: - -```text -GET /agents/incident_commander/workflow-status?workflow_id= -GET /agents/incident_commander/workflows -GET /agents/release_manager/workflow-status?workflow_id= -GET /agents/release_manager/workflows -``` - -### Send the sample messages yourself - -Keep `func start` running from `samples\per-agent-workflows\src`. In a second -PowerShell terminal, move to the sample root: - -```powershell -Set-Location samples\per-agent-workflows -``` - -Send only the incident workflow message: - -```powershell -python scripts/send.py incident -``` - -Send only the release workflow message: - -```powershell -python scripts/send.py release -``` - -Or start both owners with the same session ID to observe owner isolation: - -```powershell -python scripts/send.py both -``` - -This script does not start Docker, emulators, or the Functions host and does not -poll for completion. It only posts the documented prompt, then prints the -workflow ID and owner-specific status URL. Use `--base-url` for a non-default -host and `--session-id` to choose the shared session. - -### Exact incident demo prompt +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 @@ -167,11 +122,16 @@ host and `--session-id` to choose the shared session. > 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"`. -### Exact release demo prompt +### 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, @@ -187,7 +147,39 @@ Expected terminal output: `runtime_status` is `Completed`; the `"decision": "NO_GO"` because the deterministic evidence includes an unexcepted critical vulnerability. -## One-command verification +### Optional: send the same prompts from a terminal + +Keep `func start` running. In a second PowerShell terminal, move to the sample +root and send either prompt: + +```powershell +Set-Location samples\per-agent-workflows +python scripts/send.py incident +python scripts/send.py release +``` + +To start both owners with the same session ID and demonstrate owner isolation: + +```powershell +python scripts/send.py both +``` + +This helper does not start Docker, emulators, or the Functions host and does not +poll for completion. It prints the workflow ID and owner-specific status URL. +Use `--base-url` for a non-default host and `--session-id` to choose the shared +session. + +The equivalent APIs are `POST /agents/incident_commander/chat` and +`POST /agents/release_manager/chat`. Workflow polling remains owner-specific: + +```text +GET /agents/incident_commander/workflow-status?workflow_id= +GET /agents/incident_commander/workflows +GET /agents/release_manager/workflow-status?workflow_id= +GET /agents/release_manager/workflows +``` + +## Optional automated E2E verification The verifier creates uniquely named Docker containers with ephemeral host ports, makes an isolated temporary app copy under this sample directory, writes diff --git a/src/azure_functions_agents/_trigger_support.py b/src/azure_functions_agents/_trigger_support.py deleted file mode 100644 index 5c0e38ab..00000000 --- a/src/azure_functions_agents/_trigger_support.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Shared trigger decorator resolution for validation and registration.""" - -from __future__ import annotations - -from typing import Any - -import azure.functions as func - -from azure_functions_agents.config.schema import TRIGGER_TYPES - -_SUPPORTED_TRIGGER_TYPES = frozenset(TRIGGER_TYPES) - - -def resolve_trigger_decorator_name(owner: Any, trigger_type: str) -> str | None: - """Return the decorator exposed by *owner* for an authored trigger type.""" - if trigger_type not in _SUPPORTED_TRIGGER_TYPES: - return None - if trigger_type == "http_trigger": - return "route" if callable(getattr(owner, "route", None)) else None - if trigger_type == "connector_trigger": - if callable(getattr(owner, "connector_trigger", None)): - return "connector_trigger" - if callable(getattr(owner, "generic_trigger", None)): - return "generic_trigger" - return None - if callable(getattr(owner, trigger_type, None)): - return trigger_type - return None - - -def is_supported_trigger_type(trigger_type: str) -> bool: - """Return whether a standard FunctionApp can register this authored trigger.""" - return resolve_trigger_decorator_name(func.FunctionApp, trigger_type) is not None diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index 4075582c..d0b2e1e5 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -178,6 +178,8 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog_entries: dict[str, CatalogEntry] = {} for resolved in resolved_agents: # Validation is owned by the app factory; compose() stays a pure translation step. + # Preserve trigger diagnostics: validate an authored trigger before reporting that + # the workflow owner has no eligible starter. if resolved.trigger is None: validate_workflow_owner_starter(resolved) validate_resolved_agent( @@ -225,7 +227,6 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: workflows_enabled = False workflow_system_addendum: str | None = None trigger_workflow_system_addendum: str | None = None - workflow_policy = None workflow_policy = workflow_owner_policies.get(resolved.slug) if workflow_policy is not None: workflow_integration = build_owner_workflow_integration( diff --git a/src/azure_functions_agents/config/loader.py b/src/azure_functions_agents/config/loader.py index 7a28b4ab..6b610356 100644 --- a/src/azure_functions_agents/config/loader.py +++ b/src/azure_functions_agents/config/loader.py @@ -7,7 +7,7 @@ import frontmatter import yaml # type: ignore[import-untyped] -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from azure_functions_agents._logger import logger from azure_functions_agents._slug import _is_single_agent_file @@ -19,6 +19,7 @@ from azure_functions_agents.config.schema import AgentSpec, GlobalConfig _FRONTMATTER_SCHEMA_LINK = "aka.ms/agents-front-matter-schema" +_BOOL_ADAPTER = TypeAdapter(bool) _FRONTMATTER_ACTION_ITEMS = ( @@ -157,13 +158,15 @@ def _load_agent_spec(source_file: Path) -> AgentSpec: raw_metadata = normalized.get("metadata") if raw_metadata is None or isinstance(raw_metadata, dict): internal_metadata = dict(raw_metadata or {}) - internal_metadata["_workflow_chat_api_starter"] = bool( - raw_builtin_endpoints is True - or ( - isinstance(raw_builtin_endpoints, dict) - and raw_builtin_endpoints.get("chat_api") is True - ) - ) + explicit_chat_api = raw_builtin_endpoints is True + if isinstance(raw_builtin_endpoints, dict) and "chat_api" in raw_builtin_endpoints: + try: + explicit_chat_api = _BOOL_ADAPTER.validate_python( + raw_builtin_endpoints["chat_api"] + ) + except ValidationError: + explicit_chat_api = False + internal_metadata["_workflow_chat_api_starter"] = explicit_chat_api normalized["metadata"] = internal_metadata # agent.md and CLAUDE.md (and their case variants) are aliases for main.agent.md; # check the normalized name to determine main-agent status diff --git a/src/azure_functions_agents/config/validation.py b/src/azure_functions_agents/config/validation.py index 8ca8359e..7c33bddc 100644 --- a/src/azure_functions_agents/config/validation.py +++ b/src/azure_functions_agents/config/validation.py @@ -5,7 +5,6 @@ from pathlib import Path from azure_functions_agents._logger import logger as _logger -from azure_functions_agents._trigger_support import is_supported_trigger_type from .schema import ResolvedAgent, SubagentRef, WorkflowSubagentRef @@ -92,16 +91,6 @@ def validate_resolved_agent( "#trigger", ) ) - if not is_supported_trigger_type(trigger_type): - raise ValueError( - _format_error( - source_file, - "trigger.type", - f"Unknown or unsupported trigger type `{trigger_type}`.", - "#trigger", - ) - ) - known_mcp = set(discovered_mcp_names) for name in resolved.mcp_exclude_names: if name not in known_mcp: 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 b81c00cb..38a2eda0 100644 --- a/src/azure_functions_agents/registration/endpoints.py +++ b/src/azure_functions_agents/registration/endpoints.py @@ -533,6 +533,7 @@ def _register_workflow_status_endpoints( app: func.FunctionApp, *, slug: str, + owner_slug: str, base_function_name: str, auth: EndpointAuthConfig, ) -> None: @@ -554,9 +555,9 @@ async def list_session_workflows(req: Request, client: str) -> Response: media_type="application/json", ) try: - envelopes = await fetch_session_workflows(client, slug, session_id) + envelopes = await fetch_session_workflows(client, owner_slug, session_id) except Exception: - logger.exception("workflows list endpoint failed owner=%s", slug) + logger.exception("workflows list endpoint failed owner=%s", owner_slug) return Response( json.dumps({"error": "failed to list workflows"}), status_code=500, @@ -590,12 +591,12 @@ async def get_session_workflow_status(req: Request, client: str) -> Response: try: envelope = await fetch_session_workflow_status( client, - slug, + owner_slug, session_id, workflow_id, ) except Exception: - logger.exception("workflow status endpoint failed owner=%s", slug) + logger.exception("workflow status endpoint failed owner=%s", owner_slug) return Response( json.dumps({"error": "failed to fetch workflow status"}), status_code=500, @@ -765,6 +766,7 @@ def register_builtin_endpoints( _register_workflow_status_endpoints( app, slug=slug, + owner_slug=resolved.slug, base_function_name=base_function_name, auth=auth, ) diff --git a/src/azure_functions_agents/registration/triggers.py b/src/azure_functions_agents/registration/triggers.py index 8d9913a5..5292489f 100644 --- a/src/azure_functions_agents/registration/triggers.py +++ b/src/azure_functions_agents/registration/triggers.py @@ -9,7 +9,6 @@ from .._logger import logger from .._source_marker import source_marker -from .._trigger_support import resolve_trigger_decorator_name from ..config import EndpointAuthConfig, ResolvedAgent from . import _naming from ._auth import resolve_endpoint_auth_level @@ -52,9 +51,9 @@ def _register_builtin_agent( workflow_policy: WorkflowPlanPolicy | None = None, ) -> None: trigger_params = dict(trigger_params) - decorator_name = resolve_trigger_decorator_name(app, trigger_type) - decorator_fn = getattr(app, decorator_name, None) if decorator_name is not None else None - if decorator_name == "generic_trigger" and trigger_type == "connector_trigger": + decorator_fn = getattr(app, trigger_type, None) + if decorator_fn is None and trigger_type == "connector_trigger": + decorator_fn = getattr(app, "generic_trigger", None) trigger_params.setdefault("type", "connectorTrigger") if decorator_fn is None: diff --git a/src/azure_functions_agents/workflows/context.py b/src/azure_functions_agents/workflows/context.py index 4531d302..a3d4fa52 100644 --- a/src/azure_functions_agents/workflows/context.py +++ b/src/azure_functions_agents/workflows/context.py @@ -2,16 +2,10 @@ 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 owner/session pair. - 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. +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 ownership.** Every workflow started via ``start_workflow`` receives an instance ID whose leading @@ -33,6 +27,8 @@ from typing import Any OWNER_SESSION_PREFIX_LEN = 32 +# Compatibility alias retained for callers that imported the original constant. +# Its value follows the current owner/session format, not the legacy 12-hex format. SESSION_PREFIX_LEN = OWNER_SESSION_PREFIX_LEN @@ -80,10 +76,15 @@ class WorkflowSessionContext: session_id: str agent_name: str durable_client: Any # azure.durable_functions.DurableOrchestrationClient + + +@dataclass(frozen=True) +class _WorkflowSessionRegistration: + context: WorkflowSessionContext token: str -_registry: dict[tuple[str, str], WorkflowSessionContext] = {} +_registry: dict[tuple[str, str], _WorkflowSessionRegistration] = {} _lock = Lock() @@ -99,12 +100,15 @@ def register_workflow_session( :func:`unregister_workflow_session` in its ``finally`` block. """ token = uuid.uuid4().hex + context = WorkflowSessionContext( + owner_slug=owner_slug, + session_id=session_id, + agent_name=agent_name, + durable_client=durable_client, + ) with _lock: - _registry[(owner_slug, session_id)] = WorkflowSessionContext( - owner_slug=owner_slug, - session_id=session_id, - agent_name=agent_name, - durable_client=durable_client, + _registry[(owner_slug, session_id)] = _WorkflowSessionRegistration( + context=context, token=token, ) return token @@ -134,7 +138,8 @@ def get_workflow_session( if not owner_slug or not session_id: return None with _lock: - return _registry.get((owner_slug, session_id)) + registration = _registry.get((owner_slug, session_id)) + return registration.context if registration is not None else None __all__ = [ diff --git a/src/azure_functions_agents/workflows/engine.py b/src/azure_functions_agents/workflows/engine.py index 7c7b8301..607af585 100644 --- a/src/azure_functions_agents/workflows/engine.py +++ b/src/azure_functions_agents/workflows/engine.py @@ -424,6 +424,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 dcb4d082..fa709e48 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -25,8 +25,11 @@ from azure_functions_agents._function_tool import WorkflowTool from azure_functions_agents._logger import logger -from azure_functions_agents._trigger_support import is_supported_trigger_type -from azure_functions_agents.config.schema import ResolvedAgent, WorkflowSubagentRef +from azure_functions_agents.config.schema import ( + TRIGGER_TYPES, + ResolvedAgent, + WorkflowSubagentRef, +) from azure_functions_agents.registration.catalog import AgentCatalog from . import registry @@ -421,8 +424,9 @@ def _build_plan_policy( def _has_eligible_starter(resolved: ResolvedAgent) -> bool: - if resolved.trigger is not None and is_supported_trigger_type( - str(resolved.trigger.type or "").strip() + if ( + resolved.trigger is not None + and str(resolved.trigger.type or "").strip() in TRIGGER_TYPES ): return True endpoints = resolved.builtin_endpoints @@ -435,11 +439,17 @@ def _has_eligible_starter(resolved: ResolvedAgent) -> bool: def validate_workflow_owner_starter(resolved: ResolvedAgent) -> None: """Reject an enabled owner that has no Durable-capable invocation surface.""" - if ( - resolved.workflows is not None - and resolved.workflows.enabled - and not _has_eligible_starter(resolved) - ): + if resolved.workflows is None or not resolved.workflows.enabled: + return + if resolved.trigger is not None: + 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`: " + "Unknown or unsupported " + f"trigger type `{trigger_type}`. See docs/front-matter-spec.md#trigger." + ) + if not _has_eligible_starter(resolved): raise ValueError( f"Agent {resolved.slug!r} sets workflows.enabled=true but has no " "eligible workflow starter. Configure a trigger, " diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index c9e15faa..1d1ff64e 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -211,10 +211,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)) @@ -475,7 +471,7 @@ async def get_workflow_status( ) 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", @@ -599,7 +595,6 @@ def _build_session( session_id=session_id, agent_name=agent_name, durable_client=durable_client, - token="", ) diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index b1d2090a..cb446825 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -101,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_trigger_workflows_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() @@ -118,10 +116,16 @@ def test_non_main_trigger_workflows_enable_durable( function_app = app_module.create_function_app(app_root=tmp_path) assert isinstance(function_app, df.DFApp) - assert not any( - "workflows.enabled is only honored on main.agent.md" in record.message - for record in caplog.records - ) + 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): diff --git a/tests/test_outlook_reply_sample.py b/tests/test_outlook_reply_sample.py index f013a437..140d48b7 100644 --- a/tests/test_outlook_reply_sample.py +++ b/tests/test_outlook_reply_sample.py @@ -1,7 +1,7 @@ from pathlib import Path -from azure_functions_agents._trigger_support import is_supported_trigger_type 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" @@ -13,4 +13,4 @@ def test_outlook_reply_sample_uses_supported_connector_trigger() -> None: assert agent.trigger is not None assert agent.trigger.type == "connector_trigger" - assert is_supported_trigger_type(agent.trigger.type) + assert agent.trigger.type in TRIGGER_TYPES diff --git a/tests/test_per_agent_workflows.py b/tests/test_per_agent_workflows.py index 1c1d253c..3d18365b 100644 --- a/tests/test_per_agent_workflows.py +++ b/tests/test_per_agent_workflows.py @@ -56,6 +56,30 @@ def test_non_main_workflow_owner_without_main_creates_dfapp(tmp_path) -> None: assert "agent_incident_builtin_chat" in names +@pytest.mark.parametrize("chat_api", ['"true"', "1"]) +def test_workflow_owner_accepts_coercible_explicit_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_owners_register_one_durable_blueprint(tmp_path) -> None: for slug in ("incident", "release"): _write_agent( @@ -399,7 +423,6 @@ async def test_same_session_cross_owner_management_is_not_found() -> None: session_id="same-session", agent_name="Owner B", durable_client=client, - token="", ) assert await tools.fetch_session_workflows(client, "owner_b", "same-session") == [] 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 153e18cd..4adcb06c 100644 --- a/tests/test_registration_endpoints.py +++ b/tests/test_registration_endpoints.py @@ -27,7 +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 +from azure_functions_agents.workflows.context import ( + new_workflow_instance_id, + session_instance_prefix, +) class FakeFunctionApp: @@ -1333,3 +1336,54 @@ async def get_status_all(self) -> list[Any]: assert response.status_code == 200 assert json.loads(response.body) == {"workflows": []} + + +def test_workflow_list_endpoint_uses_resolved_owner_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_triggers.py b/tests/test_registration_triggers.py index 255d8b35..9e853459 100644 --- a/tests/test_registration_triggers.py +++ b/tests/test_registration_triggers.py @@ -8,7 +8,6 @@ import azure.functions as func import pytest -from azure_functions_agents._trigger_support import is_supported_trigger_type 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 ( @@ -31,8 +30,14 @@ @pytest.mark.parametrize("trigger_type", sorted(TRIGGER_TYPES)) -def test_all_documented_trigger_types_are_supported(trigger_type: str) -> None: - assert is_supported_trigger_type(trigger_type) +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: diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index 06ea240f..c9329451 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -314,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 = [ { diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index 17387ee9..6537b6b2 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -106,17 +106,7 @@ async def start_new(self, *args, **kwargs): @pytest.fixture def failing_workflow_session(): - session_id = "session-1" - token = context.register_workflow_session( - "test-agent", - session_id, - "test-agent", - _FailingDurableClient(), - ) - try: - yield session_id - finally: - context.unregister_workflow_session("test-agent", session_id, token) + return "session-1" def _registered_blueprint_function( @@ -143,6 +133,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( + "owner", + "session", + "Owner", + first_client, + ) + second_token = context.register_workflow_session( + "owner", + "session", + "Owner", + second_client, + ) + + registered = context.get_workflow_session("owner", "session") + assert registered is not None + assert registered.durable_client is second_client + assert not hasattr(registered, "token") + + context.unregister_workflow_session("owner", "session", first_token) + assert context.get_workflow_session("owner", "session") is registered + + context.unregister_workflow_session("owner", "session", second_token) + assert context.get_workflow_session("owner", "session") is None + + def test_register_workflow_tool_rejects_reserved_name(): for reserved in registry.RESERVED_TOOL_NAMES: with pytest.raises(ValueError, match="reserved"): @@ -639,7 +657,6 @@ async def test_workflow_tools_log_durable_exceptions_without_returning_details( session_id=failing_workflow_session, agent_name="test-agent", durable_client=_FailingDurableClient(), - token="", ) text_result = await call_tool(workflow_id, session) @@ -671,7 +688,6 @@ async def test_start_workflow_rejects_new_workflow_when_session_active_cap_reach session_id=session_id, agent_name="test-agent", durable_client=client, - token="", ) registry.set_app_config(frozenset()) result = await tools.start_workflow( @@ -700,7 +716,6 @@ async def get_status_all(self): session_id="session-1", agent_name="coordinator", durable_client=_UnexpectedClient(), - token="", ) params = tools.StartWorkflowParams( tasks=[ @@ -730,7 +745,6 @@ async def test_start_workflow_threads_owner_slug_into_durable_input() -> None: session_id="session-1", agent_name="Incident", durable_client=client, - token="", ) policy = schema.WorkflowPlanPolicy( allowed_tools=frozenset(), From 97f928a8f8e65769e5411526c16d9ad28d9e77c1 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 19:44:10 -0700 Subject: [PATCH 12/18] Clarify multi-owner workflow design history Keep FRD 0004 as the initial Dynamic Workflows record while linking its evolution to the separate ownership, authorization, and identity design in FRD 0009. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0004-dynamic-workflows.md | 24 ++++++++++++++----- docs/frds/0009-per-agent-dynamic-workflows.md | 4 ++-- docs/frds/README.md | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 426e51b5..0f543975 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -4,17 +4,13 @@ title: Dynamic workflows status: Finalized author: TsuyoshiUshio created: 2026-07-06 -updated: 2026-07-24 +updated: 2026-08-11 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] +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 -> **Superseded scope note:** FRD 0009 extends this historical main-agent-only -> design to [eligible per-agent workflow owners](0009-per-agent-dynamic-workflows.md). -> The original decisions below remain the record of the initial v1 design. - ## 1. Summary Add experimental Dynamic Workflows support to the markdown-first Azure Functions @@ -28,6 +24,22 @@ Workflow-enabled main 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: multi-owner workflow ownership and isolation + +This FRD records the initial Dynamic Workflows v1 design, which assumed one +`main.agent.md` workflow owner and session-only workflow identity. FRD 0009, +[Multi-owner Dynamic Workflow Ownership and +Isolation](0009-per-agent-dynamic-workflows.md), extends that foundation so +multiple agents can own workflows independently. + +The follow-up is maintained as a separate FRD because it changes more than owner +eligibility: it introduces an app-wide execution catalog, immutable per-owner +authorization policies, Activity-time reauthorization, `(owner_slug, +session_id)` management isolation, and a breaking workflow-ID migration. Keeping +its Decisions log separate preserves this document as the historical record of +the original v1 requirements while making this section the entry point to the +current multi-owner design. + ## 2. Motivation / problem Today agents can call tools directly through the Microsoft Agent Framework (MAF) diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 0e7c0429..f7b6f29d 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -1,6 +1,6 @@ --- frd: 0009 -title: Per-agent Dynamic Workflows +title: Multi-owner Dynamic Workflow Ownership and Isolation status: Finalized author: TsuyoshiUshio created: 2026-08-10 @@ -14,7 +14,7 @@ pull_requests: branch: tsuyoshiushio-per-agent-dynamic-workflows --- -# FRD 0009 — Per-agent Dynamic Workflows +# FRD 0009 — Multi-owner Dynamic Workflow Ownership and Isolation ## 1. Summary diff --git a/docs/frds/README.md b/docs/frds/README.md index fc37565a..bac07d6b 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -36,7 +36,7 @@ The full lifecycle that produces an FRD lives in [`../../AGENTS.md`](../../AGENT | [0005](0005-web-request-system-tool.md) | `web_request` system tool | In review | | [0006](0006-endpoint-authentication.md) | Endpoint & HTTP trigger authentication (API key / Entra ID) | Finalized | | [0007](0007-multi-agent-delegation.md) | Multi-agent delegation (agent-as-tool) | In review | -| [0009](0009-per-agent-dynamic-workflows.md) | Per-agent Dynamic Workflows | Finalized | +| [0009](0009-per-agent-dynamic-workflows.md) | Multi-owner Dynamic Workflow Ownership and Isolation | Finalized | > `_template.md` is the template, not an FRD — the leading underscore keeps it > sorted first and excludes it from numbering. From c7474c044d30c1af37b039eb153558be98f159c6 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 11 Aug 2026 20:12:02 -0700 Subject: [PATCH 13/18] Address multi-owner workflow review Simplify workflow ownership eligibility, keep customer samples minimal, and type Durable workflow boundaries. Move the E2E verifier to engineering tooling and preserve actionable trigger diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- README.md | 2 +- docs/architecture.md | 16 +-- docs/frds/0009-per-agent-dynamic-workflows.md | 71 +++++----- docs/front-matter-spec.md | 15 +-- docs/triggers.md | 2 +- docs/workflows.md | 17 +-- .../scripts/verify_per_agent_workflows.py | 49 +++++-- samples/README.md | 4 +- samples/per-agent-workflows/README.md | 83 ++---------- samples/per-agent-workflows/scripts/send.py | 124 ------------------ .../per-agent-workflows/src/function_app.py | 12 -- src/azure_functions_agents/app.py | 9 +- src/azure_functions_agents/config/loader.py | 19 +-- .../workflows/context.py | 7 +- .../workflows/engine.py | 49 +++++-- .../workflows/integration.py | 43 ++---- src/azure_functions_agents/workflows/tools.py | 11 +- tests/test_per_agent_workflows.py | 64 +++++---- tests/test_per_agent_workflows_sample.py | 7 - tests/test_per_agent_workflows_send.py | 81 ------------ tests/test_per_agent_workflows_verify.py | 25 +++- 21 files changed, 225 insertions(+), 485 deletions(-) rename samples/per-agent-workflows/scripts/verify.py => eng/scripts/verify_per_agent_workflows.py (94%) delete mode 100644 samples/per-agent-workflows/scripts/send.py delete mode 100644 tests/test_per_agent_workflows_send.py diff --git a/README.md b/README.md index 738361d0..cb0ceff2 100644 --- a/README.md +++ b/README.md @@ -555,7 +555,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 owners and one-command Storage/DTS verification +- [`per-agent-workflows`](samples/per-agent-workflows) — Engineering Operations Hub with two non-main workflow owners and independent policies ## Deployment Notes diff --git a/docs/architecture.md b/docs/architecture.md index dfbef1ab..91fa4796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ 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` builds - the slug index, validates references and workflow-starter eligibility, then + the slug index and validates references, then freezes the `AgentCatalog`, complete workflow-handler catalog, and per-owner workflow-policy catalog. Only pass 2 creates/mutates the app, registers the workflow runtime once, and registers agent surfaces (FRDs 0007 and 0009). @@ -53,7 +53,7 @@ A few boundaries are worth calling out explicitly: | Package/module | Role | Key entry points | | --- | --- | --- | -| `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 owner-policy catalog. It chooses `DFApp` when any eligible owner exists, registers the workflow runtime once, then registers each agent. | `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 owner-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` | @@ -76,7 +76,7 @@ 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/integration.py` | Builds the complete immutable handler catalog, immutable slug-keyed owner-policy catalog, per-owner management tools/addenda, and performs the one app-wide Durable registration. It also rejects enabled owners without a supported trigger, chat API, or MCP starter. | `build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()`, `build_owner_workflow_integration()`, `register_workflow_runtime()`, `validate_workflow_owner_starter()` | +| `azure_functions_agents/workflows/integration.py` | Builds the complete immutable handler catalog, immutable slug-keyed owner-policy catalog, per-owner management tools/addenda, validates declared trigger support for enabled owners, and performs the one app-wide Durable registration. | `build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()`, `build_owner_workflow_integration()`, `validate_workflow_owner_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 owner policy before complete-catalog dispatch. | `register_workflows()` | | `azure_functions_agents/workflows/context.py` | Tracks invocation context by `(owner_slug, session_id)` and derives non-revealing 128-bit ownership prefixes for Durable instance IDs. | `session_instance_prefix()`, `new_workflow_instance_id()`, `session_owns_workflow()` | | `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()` | @@ -111,7 +111,7 @@ When the host imports your app module and calls `create_function_app()`, control 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 enabled eligible owner. + `WorkflowPlanPolicy` per enabled owner. 11. `app.py` creates a `DFApp` when the owner-policy catalog is non-empty (otherwise a plain `FunctionApp`) and registers the app-wide Durable runtime exactly once. @@ -165,10 +165,10 @@ 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()`, `src/azure_functions_agents/workflows/integration.py:validate_workflow_owner_starter()` + - **Implemented by:** `src/azure_functions_agents/config/validation.py:validate_resolved_agent()`, `src/azure_functions_agents/workflows/integration.py:validate_workflow_owner_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 unsupported trigger decorators, and validates capability references. A referenced internal specialist may remain endpoint-less. Separately, a workflow-enabled owner must have a supported trigger, chat API, or MCP starter; debug UI alone is insufficient. + - **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 ownership 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()` @@ -180,12 +180,12 @@ The `create_function_app()` docstring in `src/azure_functions_agents/app.py:crea - **Implemented by:** `src/azure_functions_agents/registration/catalog.py:build_catalog()`, `src/azure_functions_agents/workflows/integration.py:build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()` - **Input:** `dict[str, CatalogEntry]` — one entry per agent slug, pairing its validated `ResolvedAgent` and `AgentCapabilities` - **Output:** immutable `AgentCatalog`, complete `WorkflowHandlerCatalog`, and immutable slug-keyed `WorkflowOwnerPolicyCatalog` - - **Notes:** the handler and Agent catalogs answer what exists app-wide. They do not grant an owner access. Each enabled eligible owner receives a separate `WorkflowPlanPolicy` derived from its filtered workflow tools and independent `workflows.subagents` grants. This closes side-effect-free pass 1. + - **Notes:** the handler and Agent catalogs answer what exists app-wide. They do not grant an owner access. Each enabled owner 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 at least one eligible owner policy exists, otherwise a plain `FunctionApp`) + - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one owner policy exists, otherwise a plain `FunctionApp`) - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and owner policies are captured by one app-level Durable registration before agent registration begins. 11. **Register triggers and built-in endpoints (pass 2)** diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index f7b6f29d..3a824a5c 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -18,7 +18,7 @@ branch: tsuyoshiushio-per-agent-dynamic-workflows ## 1. Summary -Allow any eligible `*.agent.md` agent, rather than only `main.agent.md`, to own +Allow any `*.agent.md` agent, rather than only `main.agent.md`, to own Dynamic Workflows independently. One Function App will register one Durable engine and complete workflow handler inventory, while every workflow-enabled agent receives an immutable owner-specific policy, prompt guidance, management @@ -34,7 +34,9 @@ they cannot see or control each other's workflows through application surfaces. ## 2. Motivation / problem The runtime already supports Dynamic Workflow DAGs containing `tool`, `wait`, -and stateless leaf `sub_agent` tasks. Workflow-enabled agents can start those +and stateless leaf `sub_agent` tasks. `wait` is a built-in DAG node compiled to +a Durable timer; it is not a discovered or system-injected tool. +Workflow-enabled agents can start those plans from built-in chat, MCP, HTTP triggers, and non-interactive Markdown-declared triggers. The runtime also already has: @@ -68,11 +70,11 @@ snippet. **Goals** -- Honor `workflows.enabled: true` on every eligible discovered agent. +- Honor `workflows.enabled: true` on every discovered agent. - Keep `main.agent.md` working as an ordinary owner with slug `main`. - Use `ResolvedAgent.slug` as the stable workflow owner identity on chat, MCP, HTTP trigger, and non-interactive trigger paths. -- Create a `df.DFApp` when any eligible agent enables workflows. +- Create a `df.DFApp` when any agent enables workflows. - Register the Durable orchestrator and Activities exactly once per Function App. - Register one complete, unfiltered workflow handler inventory so one owner's exclusions never unregister another owner's tools. @@ -85,8 +87,8 @@ snippet. probe whether another owner has a workflow. - Preserve the asynchronous trigger starter contract: the initiating Function ends after the agent turn while Durable execution continues. -- Add a runnable, one-command-verifiable sample with multiple non-main workflow - owners and no `main.agent.md`. +- Add a runnable customer sample with multiple non-main workflow owners and no + `main.agent.md`, plus separate E2E automation. **Non-goals** @@ -109,7 +111,7 @@ snippet. | Pipeline stage | Module(s) | Change | | --- | --- | --- | | discover | `discovery/tools.py` | No behavior change. Continue returning one app-wide inventory of explicit `@workflow_tool` declarations. Discovery remains read-only and applies no owner policy. | -| translate | `config/schema.py`, `config/merge.py`, `config/validation.py`, `registration/capabilities.py` | Reuse `WorkflowConfig`, canonical `ResolvedAgent.slug`, validated Workflow Sub Agent references, and each agent's workflow tools after `workflows.exclude`. Validate that an enabled owner has a usable starter surface. No schema change is expected. | +| translate | `config/schema.py`, `config/merge.py`, `config/validation.py`, `registration/capabilities.py` | Reuse `WorkflowConfig`, canonical `ResolvedAgent.slug`, validated Workflow Sub Agent references, and each agent's workflow tools after `workflows.exclude`. No schema change is expected. | | compose (pass 1) | `app.py`, `registration/catalog.py`, `workflows/integration.py` | After app-wide slug and reference validation, freeze the existing `AgentCatalog` and a new slug-keyed workflow owner-policy catalog. This pass remains side-effect-free and does not mutate a `FunctionApp`. | | register (pass 2) | `app.py`, `workflows/integration.py`, `workflows/registry.py`, `workflows/engine.py`, `registration/endpoints.py`, `registration/triggers.py` | Create a `DFApp` when the policy catalog is non-empty. Register the complete handler inventory and Durable blueprint once, then thread each owner's policy and channel addendum into only that owner's surfaces. | | execute | `runner.py`, `workflows/tools.py`, `workflows/context.py`, `workflows/engine.py`, `registration/_handlers.py` | Capture owner slug, session ID, Durable client, and explicit policy in workflow tool closures. Namespace management by owner plus session and reauthorize capability-bearing Activities before dispatch. | @@ -117,7 +119,7 @@ snippet. This extends the existing two-pass composition model. Registration consumes typed, validated, immutable objects and does not re-parse frontmatter. -### 4.2 Authoring and eligible starter surfaces +### 4.2 Authoring and invocation surfaces No new authoring syntax is introduced. Any descriptively named agent can opt in: @@ -138,21 +140,16 @@ workflows: --- ``` -An enabled owner must have at least one invocation channel that can run the -plan-authoring agent with a Durable client: +Any agent may become an owner by enabling workflows. How that agent is invoked +remains an independent concern. Direct invocation can use: - built-in `chat_api`; - built-in MCP; or - any supported Markdown-declared trigger. -`debug_chat_ui` alone is not a starter because it is only a page surface. A -triggerless internal specialist referenced only through `subagents` or -`workflows.subagents` also has no starter surface. - -The proposed behavior for `workflows.enabled: true` without a usable starter is -to fail composition with an actionable error. Silently ignoring the setting or -warning and disabling it would leave an apparently valid but inert owner. This -choice remains an architecture-review/sign-off item. +`debug_chat_ui` automatically enables its backing chat API. An internal agent +without its own invocation surface may still enable workflows; it becomes +directly usable if an invocation surface is added later. If one agent exposes multiple channels, every channel uses the same owner policy. Chat and MCP receive chat-specific guidance; Markdown-declared triggers receive @@ -346,25 +343,22 @@ Add `samples/per-agent-workflows/` as a standalone Azure Functions app with no - `release_readiness.agent.md`, with chat endpoints and a different set of grants. -The tools use deterministic synthetic data so verification requires no external -service token. The sample includes: +The tools use deterministic synthetic data so manual operation requires no +external service token. The customer sample includes: - clear architecture and workflow-shape diagrams; - one manual prompt for each agent; -- expected workflow outputs and polling routes; -- Azure Storage and DTS local instructions; and -- `scripts/verify.py`, which defaults to the Azure Storage backend with isolated - Azurite, supports `--backend dts` for a DTS run, starts the Functions host from - a temporary app copy, and performs end-to-end assertions. +- expected workflow outputs; and +- Azure Storage and DTS local instructions. -The verifier deliberately uses the same `x-ms-session-id` for both agents. It +Separate repository E2E automation in +`eng/scripts/verify_per_agent_workflows.py` deliberately uses the same +`x-ms-session-id` for both agents. It starts one workflow through each agent, verifies both reach a terminal state, checks that each used only its own capabilities, and verifies that each owner's status route returns 404 for the other owner's workflow ID. This makes the main behavioral and security property directly observable for the exercised owner -pair. The README states the prerequisites explicitly: Docker (for isolated -Azurite and optional DTS), Functions Core Tools, and model-provider -authentication. +pair. This keeps internal verification infrastructure out of the customer app. ## 5. Decisions log @@ -386,15 +380,17 @@ authentication. | 14 | Existing exported compatibility helpers | Delete as production-dead / retain unchanged / isolate compatibility state | Retain the exported registry and one-shot integration helper to avoid an unrelated breaking change, but remove the registration token from production `WorkflowSessionContext` and keep it private to the compatibility registry | Agent | 2026-08-11 | | 15 | Trigger decorator resolution | New shared resolver / duplicate capability validation / retain registration-local fallback | Keep the existing registration-local `connector_trigger` → `generic_trigger` fallback; workflow eligibility uses documented `TRIGGER_TYPES`, avoiding a new helper and an unrelated hard failure for non-workflow agents | Agent | 2026-08-11 | | 16 | Activity failure propagation | Let Durable wrapper behavior surface / explicitly rethrow failed wave result | Explicitly rethrow a failed `task_all` result so owner-policy denials retain their original actionable error instead of becoming a secondary `TypeError` | Agent | 2026-08-11 | +| 17 | Workflow owner eligibility | Require a dedicated starter / allow every enabled agent to own workflows | Treat every agent with `workflows.enabled: true` as an owner and keep invocation surfaces independent; this supersedes Decision #9 and removes raw-frontmatter starter metadata | Human | 2026-08-11 | +| 18 | Customer sample boundary | Keep sender/verifier helpers in the sample / separate customer app from internal automation | Keep the sample directly runnable and documentation-led, remove the sender helper, and move E2E automation to `eng/scripts` | Human | 2026-08-11 | ## 6. Test plan - [x] Unit: composition and owner-policy catalog - - any eligible non-main agent can enable workflows; + - any non-main agent can enable workflows; - an app with only non-main workflow owners is a `df.DFApp`; - `main.agent.md` remains supported; - - `debug_chat_ui`-only and endpoint-less enabled owners follow the finalized - eligibility decision; + - an endpoint-less enabled owner referenced as a specialist composes without + special starter metadata; - distinct owners receive independent tool excludes, Sub Agent grants, and prompt guidance; - owner-policy mappings and values are immutable. @@ -440,10 +436,9 @@ authentication. - [x] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping session IDs, distinct policies, status/control isolation, and execution after starter completion. -- [x] Sample verifier: one command starts dependencies and proves both successful - workflows plus cross-owner denial. The script and its pure verification tests - are implemented; model-backed Storage/DTS execution remains covered by the - unchecked E2E item above. +- [x] E2E verifier: repository automation starts dependencies and proves both + successful workflows plus cross-owner denial without adding internal helper + scripts to the customer sample. - [x] Canonical gate: - `python -m ruff check src tests`; - `python -m mypy src`; @@ -455,13 +450,13 @@ authentication. - [x] `docs/architecture.md` — add the owner-policy catalog, one-time Durable registration, owner-scoped execution, and Activity reauthorization. - [x] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and - document eligible starter surfaces. + document that ownership and invocation surfaces are independent. - [x] `docs/workflows.md` — document multiple owners, identity, isolation, migration, trigger ownership, and operator guidance. - [x] `docs/triggers.md` — clarify that each workflow-enabled declared trigger uses its owning agent's policy and Durable client. - [x] `README.md` — link the per-agent workflow sample. -- [x] `samples/README.md` — list the runnable sample and its one-command verifier. +- [x] `samples/README.md` — list the runnable customer sample. - [x] `docs/front-matter-reference.md` — no change expected because no schema change is planned. diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md index b3842185..0b01ec00 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 any agent with an eligible starter +- 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 an eligible agent +- `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 @@ -127,7 +127,7 @@ Roles come from invocation surfaces and references, not file placement: | 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 owner | Sets `workflows.enabled: true` and has an eligible starter: a supported trigger, chat API, or MCP endpoint. | +| Workflow owner | Sets `workflows.enabled: true`. | | Workflow Sub Agent | Is referenced by an owner'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 @@ -590,7 +590,7 @@ tools: false #### `workflows` - **Type:** `object` -- **Location:** Agent front matter (any agent with an eligible workflow starter) +- **Location:** Agent front matter (any agent) - **Description:** Enables Dynamic Workflows, filters discovered workflow tools, and grants access to leaf specialists for workflow tasks. @@ -622,10 +622,9 @@ Normal custom tools keep their existing behavior. Plain public functions and `@t not affect normal tools or another owner's workflow policy. Conversely, `tools.exclude` filters normal MAF tools and does not hide workflow tools. -An enabled owner must expose at least one eligible starter: a supported declared -`trigger`, `builtin_endpoints.chat_api`, or `builtin_endpoints.mcp`. -`builtin_endpoints.debug_chat_ui` alone is insufficient because the UI depends -on the chat API. An enabled owner without a starter fails startup. +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 diff --git a/docs/triggers.md b/docs/triggers.md index b6a71de4..689a05aa 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -41,7 +41,7 @@ for a runnable example (`tech.agent.md` is one such endpoint-less specialist). ### Starting Dynamic Workflows -When any eligible agent sets `workflows.enabled: true`, each supported declared +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 owner policy. The runtime schedules the workflow asynchronously, and the trigger Function does not wait for it to finish. diff --git a/docs/workflows.md b/docs/workflows.md index 9a110ff6..ff3c8579 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -12,8 +12,6 @@ > demonstrates workflow Sub Agents. The > [Engineering Operations Hub](../samples/per-agent-workflows/README.md) > demonstrates two non-main workflow owners with independent policies in one app. -> Its [one-command verifier](../samples/per-agent-workflows/README.md#one-command-verification) -> exercises same-session isolation with Azure Storage or DTS. > Larger features such as sub-orchestrations, > configurable retry policies, and MCP Tasks integration are tracked as v2 > follow-up work. @@ -47,7 +45,7 @@ They are **not** the right tool for: - 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; any - eligible agent in that app can own workflows and authorize leaf specialists. + agent in that app can own workflows and authorize leaf specialists. ## Why workflows (token, latency, context) @@ -168,11 +166,10 @@ 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] -> A workflow owner must have an eligible **starter**: a supported declared -> `trigger`, `builtin_endpoints.chat_api`, or `builtin_endpoints.mcp`. The debug -> UI alone is insufficient because it calls the chat API; enable `chat_api` too. -> Startup fails rather than silently accepting an enabled but inert owner. +Any agent may become a workflow owner by setting `workflows.enabled: true`. +Invocation remains independent: triggers and built-in endpoints determine how +the owner 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) @@ -181,7 +178,7 @@ for how direct agents, workflow owners, and internal specialists are identified. ### App-wide engine, per-owner policy The app discovers complete, immutable catalogs of workflow handlers and agents. -If at least one eligible workflow owner exists, startup creates one `DFApp` and +If at least one workflow owner 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 owner. @@ -564,7 +561,7 @@ owner-wide throttle. v1 includes: - five built-in workflow tools; -- any eligible agent may own workflows, with one app-wide engine and immutable +- any agent may own workflows, with one app-wide engine and immutable per-owner policies; - DAG execution of `@workflow_tool` calls and wait tasks; - deny-by-default `workflows.subagents` grants and stateless `sub_agent` tasks; diff --git a/samples/per-agent-workflows/scripts/verify.py b/eng/scripts/verify_per_agent_workflows.py similarity index 94% rename from samples/per-agent-workflows/scripts/verify.py rename to eng/scripts/verify_per_agent_workflows.py index 0a817f44..67a4d530 100644 --- a/samples/per-agent-workflows/scripts/verify.py +++ b/eng/scripts/verify_per_agent_workflows.py @@ -24,9 +24,9 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen -SAMPLE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = Path(__file__).resolve().parents[2] +SAMPLE_ROOT = REPO_ROOT / "samples" / "per-agent-workflows" SAMPLE_SRC = SAMPLE_ROOT / "src" -REPO_ROOT = Path(__file__).resolve().parents[3] TASK_HUB = "engineeringopshub" SESSION_ID = "engineering-ops-shared-session" AZURITE_ACCOUNT = "devstoreaccount1" @@ -402,7 +402,22 @@ def _provider_values() -> dict[str, str]: "azure_openai": bool(values.get("AZURE_OPENAI_ENDPOINT", "").strip()), "openai": bool(values.get("OPENAI_API_KEY", "").strip()), } - selected = [provider for provider, is_configured in configured.items() if is_configured] + 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 " @@ -416,12 +431,13 @@ def _provider_values() -> dict[str, str]: provider = selected[0] required = { - "foundry": ("FOUNDRY_MODEL",), + "foundry": ("FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"), "azure_openai": ( + "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_API_VERSION", ), - "openai": ("OPENAI_CHAT_MODEL_ID",), + "openai": ("OPENAI_API_KEY", "OPENAI_CHAT_MODEL_ID"), } missing = [key for key in required[provider] if not values.get(key, "").strip()] if missing: @@ -440,7 +456,6 @@ def build_host_environment() -> dict[str, str]: environment["PYTHONPATH"] = ( f"{checkout_src}{os.pathsep}{existing}" if existing else checkout_src ) - environment["AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT"] = checkout_src return environment @@ -624,7 +639,10 @@ def _start_owner(host: _FunctionHost, owner: Owner, prompt: str, *, timeout: flo ) if status != 200: raise RuntimeError(f"{owner} chat returned HTTP {status}: {payload!r}") - return extract_workflow_id(payload) + try: + return extract_workflow_id(payload) + except RuntimeError as exc: + raise RuntimeError(f"{owner} chat response had no workflow ID: {payload!r}") from exc def _poll_owner( @@ -727,12 +745,17 @@ def verify(*, backend: str, timeout: float, keep_services: bool) -> None: print("Starting Functions host...") with _running_host(app_dir, timeout=timeout) as host: print("Starting incident and release workflows with one shared session...") - incident_id = _start_owner( - host, "incident_commander", INCIDENT_PROMPT, timeout=timeout - ) - release_id = _start_owner( - host, "release_manager", RELEASE_PROMPT, timeout=timeout - ) + try: + incident_id = _start_owner( + host, "incident_commander", INCIDENT_PROMPT, timeout=timeout + ) + release_id = _start_owner( + 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_owner( host, "incident_commander", incident_id, timeout=timeout diff --git a/samples/README.md b/samples/README.md index 71891348..918941cb 100644 --- a/samples/README.md +++ b/samples/README.md @@ -18,8 +18,8 @@ app deployable with [`azd up`](https://learn.microsoft.com/azure/developer/azure [`per-agent-workflows`](per-agent-workflows/) is the Engineering Operations Hub: two non-main owners share one Durable engine while retaining separate policies. -From its directory, `python scripts/verify.py` verifies Azure Storage/Azurite; -add `--backend dts` to verify Durable Task Scheduler. +Run it locally with Azurite and use either owner's browser chat UI to start and +observe an independent workflow. ## Run Locally (optional) diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 6d68ebd5..3f0f4a03 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -74,7 +74,7 @@ blocking findings, passed gates, required actions, and specialist analysis. - Python 3.13 or 3.14 with this repository installed using `pip install -e .[dev]` - Azure Functions Core Tools v4 (`func`) -- Docker (Azurite is always required; the DTS emulator is optional) +- Azurite - One model provider: - Microsoft Foundry project endpoint and authenticated Azure identity; - Azure OpenAI endpoint, deployment, API version, and credential; or @@ -82,11 +82,8 @@ blocking findings, passed gates, required actions, and specialist analysis. No model provider secret belongs in source control. -For manual use, `src/requirements.txt` keeps the repository's standard -`-e ../../..` editable reference, which resolves to this checkout from the -committed sample directory. The verifier does not install requirements from its -nested temporary copy; it authoritatively prepends this checkout's `src` to the -Functions worker `PYTHONPATH` and fails startup if a different runtime is loaded. +`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 @@ -147,70 +144,10 @@ Expected terminal output: `runtime_status` is `Completed`; the `"decision": "NO_GO"` because the deterministic evidence includes an unexcepted critical vulnerability. -### Optional: send the same prompts from a terminal +## Optional DTS backend -Keep `func start` running. In a second PowerShell terminal, move to the sample -root and send either prompt: - -```powershell -Set-Location samples\per-agent-workflows -python scripts/send.py incident -python scripts/send.py release -``` - -To start both owners with the same session ID and demonstrate owner isolation: - -```powershell -python scripts/send.py both -``` - -This helper does not start Docker, emulators, or the Functions host and does not -poll for completion. It prints the workflow ID and owner-specific status URL. -Use `--base-url` for a non-default host and `--session-id` to choose the shared -session. - -The equivalent APIs are `POST /agents/incident_commander/chat` and -`POST /agents/release_manager/chat`. Workflow polling remains owner-specific: - -```text -GET /agents/incident_commander/workflow-status?workflow_id= -GET /agents/incident_commander/workflows -GET /agents/release_manager/workflow-status?workflow_id= -GET /agents/release_manager/workflows -``` - -## Optional automated E2E verification - -The verifier creates uniquely named Docker containers with ephemeral host -ports, makes an isolated temporary app copy under this sample directory, writes -temporary settings, starts `func` on an ephemeral port, and cleans everything up. -It sends the SAME `x-ms-session-id` to both owner chat routes, starts both -workflows before polling, validates their structured terminal results and -capability sets, checks cross-owner status returns 404, and confirms list routes -do not expose the other owner. - -```powershell -python scripts/verify.py -``` - -The default `--backend storage` needs only Azurite. To keep containers after a -failure, add `--keep-services`. - -## DTS instructions - -DTS still requires Azurite for the Functions host's own storage. The verifier -starts both isolated containers, switches its temporary copy to -`src/host.dts.json`, and configures the mapped DTS gRPC port: - -```powershell -python scripts/verify.py --backend dts -``` - -The DTS container uses `DTS_TASK_HUB_NAMES=engineeringopshub`; its gRPC and -dashboard container ports are 8080 and 8082, both mapped to ephemeral localhost -ports. The verifier prints the mapped dashboard URL after success. - -For a manual DTS run, start the emulator with ports of your choice, copy +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. @@ -218,15 +155,13 @@ 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. -- **Docker unavailable:** start Docker and verify `docker info` succeeds. - **No model provider configured:** create `src/local.settings.json` and fill in - one supported provider; blank template values intentionally fail the verifier. + 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:** rerun with `--timeout 600`; inspect the Functions - output and optional DTS dashboard. The verifier still removes containers - unless `--keep-services` is supplied. +- **A workflow times out:** inspect the Functions output and optional DTS + dashboard. diff --git a/samples/per-agent-workflows/scripts/send.py b/samples/per-agent-workflows/scripts/send.py deleted file mode 100644 index cd4fa041..00000000 --- a/samples/per-agent-workflows/scripts/send.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Send sample workflow-start messages to an already running Functions host.""" - -from __future__ import annotations - -import argparse -import json -from typing import Literal -from urllib.error import HTTPError, URLError -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from verify import INCIDENT_PROMPT, RELEASE_PROMPT, extract_workflow_id - -type Pipeline = Literal["incident", "release"] - -PIPELINES: dict[Pipeline, tuple[str, str]] = { - "incident": ("incident_commander", INCIDENT_PROMPT), - "release": ("release_manager", RELEASE_PROMPT), -} -DEFAULT_SESSION_ID = "engineering-ops-manual-session" - - -def build_chat_request( - pipeline: Pipeline, - *, - base_url: str, - session_id: str, -) -> Request: - """Build one owner-specific chat request.""" - owner, prompt = PIPELINES[pipeline] - return Request( - f"{base_url.rstrip('/')}/agents/{owner}/chat", - data=json.dumps({"prompt": prompt}).encode(), - headers={ - "Content-Type": "application/json", - "x-ms-session-id": session_id, - }, - method="POST", - ) - - -def send_pipeline( - pipeline: Pipeline, - *, - base_url: str, - session_id: str, - timeout: float, -) -> str: - """Send one workflow-start message and return its workflow ID.""" - request = build_chat_request( - pipeline, - base_url=base_url, - session_id=session_id, - ) - try: - with urlopen(request, timeout=timeout) as response: - body = response.read() - except HTTPError as exc: - detail = exc.read().decode(errors="replace") - raise RuntimeError(f"{pipeline} chat returned HTTP {exc.code}: {detail}") from exc - except URLError as exc: - raise RuntimeError( - f"could not reach {request.full_url}; start `func` and try again: {exc.reason}" - ) from exc - - try: - payload = json.loads(body) - except json.JSONDecodeError as exc: - raise RuntimeError(f"{pipeline} chat returned invalid JSON") from exc - return extract_workflow_id(payload) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Send workflow-start messages to a manually started sample host." - ) - parser.add_argument( - "pipeline", - choices=("incident", "release", "both"), - help="Pipeline to start.", - ) - parser.add_argument( - "--base-url", - default="http://localhost:7071", - help="Functions host URL (default: http://localhost:7071).", - ) - parser.add_argument( - "--session-id", - default=DEFAULT_SESSION_ID, - help=f"Shared chat session ID (default: {DEFAULT_SESSION_ID}).", - ) - parser.add_argument( - "--timeout", - type=float, - default=180, - help="Chat request timeout in seconds (default: 180).", - ) - args = parser.parse_args() - - selected: tuple[Pipeline, ...] = ( - ("incident", "release") if args.pipeline == "both" else (args.pipeline,) - ) - for pipeline in selected: - owner, _ = PIPELINES[pipeline] - print(f"Sending {pipeline} workflow request to {owner}...") - workflow_id = send_pipeline( - pipeline, - base_url=args.base_url, - session_id=args.session_id, - timeout=args.timeout, - ) - query = urlencode({"workflow_id": workflow_id}) - print(f"{pipeline} workflow ID: {workflow_id}") - print( - f"status: {args.base_url.rstrip('/')}/agents/{owner}/workflow-status?{query}" - ) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except RuntimeError as exc: - raise SystemExit(f"FAIL: {exc}") from exc diff --git a/samples/per-agent-workflows/src/function_app.py b/samples/per-agent-workflows/src/function_app.py index 589ce53d..736ad492 100644 --- a/samples/per-agent-workflows/src/function_app.py +++ b/samples/per-agent-workflows/src/function_app.py @@ -1,15 +1,3 @@ -import os -from pathlib import Path - -import azure_functions_agents from azure_functions_agents import create_function_app -expected_root = os.environ.get("AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT") -if expected_root: - runtime_file = Path(azure_functions_agents.__file__).resolve() - if not runtime_file.is_relative_to(Path(expected_root).resolve()): - raise RuntimeError( - "azure_functions_agents was not imported from the verifier's current checkout" - ) - app = create_function_app() diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index d0b2e1e5..cf50b954 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -33,7 +33,7 @@ build_workflow_handler_catalog, build_workflow_owner_policy_catalog, register_workflow_runtime, - validate_workflow_owner_starter, + validate_workflow_owner_trigger, ) @@ -178,18 +178,13 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog_entries: dict[str, CatalogEntry] = {} for resolved in resolved_agents: # Validation is owned by the app factory; compose() stays a pure translation step. - # Preserve trigger diagnostics: validate an authored trigger before reporting that - # the workflow owner has no eligible starter. - if resolved.trigger is None: - validate_workflow_owner_starter(resolved) validate_resolved_agent( resolved, discovered_mcp_names=mcp_names, discovered_skills=skill_names, is_referenced_as_subagent=resolved.slug in referenced_slugs, ) - if resolved.trigger is not None: - validate_workflow_owner_starter(resolved) + validate_workflow_owner_trigger(resolved) capabilities = build_capabilities( resolved, discovered_user_tools=user_tools, diff --git a/src/azure_functions_agents/config/loader.py b/src/azure_functions_agents/config/loader.py index 6b610356..a5d8542a 100644 --- a/src/azure_functions_agents/config/loader.py +++ b/src/azure_functions_agents/config/loader.py @@ -7,7 +7,7 @@ import frontmatter import yaml # type: ignore[import-untyped] -from pydantic import TypeAdapter, ValidationError +from pydantic import ValidationError from azure_functions_agents._logger import logger from azure_functions_agents._slug import _is_single_agent_file @@ -19,9 +19,6 @@ from azure_functions_agents.config.schema import AgentSpec, GlobalConfig _FRONTMATTER_SCHEMA_LINK = "aka.ms/agents-front-matter-schema" -_BOOL_ADAPTER = TypeAdapter(bool) - - _FRONTMATTER_ACTION_ITEMS = ( "Fix YAML syntax between leading and trailing '---' delimiters.", f"Validate required fields like `name`, `description`, and `trigger` against {_FRONTMATTER_SCHEMA_LINK}.", @@ -154,20 +151,6 @@ def _load_agent_spec(source_file: Path) -> AgentSpec: normalized["instructions"] = instructions # Keep the real on-disk path so diagnostics reference the file the user can actually edit normalized["source_file"] = str(resolved_source) - raw_builtin_endpoints = normalized.get("builtin_endpoints") - raw_metadata = normalized.get("metadata") - if raw_metadata is None or isinstance(raw_metadata, dict): - internal_metadata = dict(raw_metadata or {}) - explicit_chat_api = raw_builtin_endpoints is True - if isinstance(raw_builtin_endpoints, dict) and "chat_api" in raw_builtin_endpoints: - try: - explicit_chat_api = _BOOL_ADAPTER.validate_python( - raw_builtin_endpoints["chat_api"] - ) - except ValidationError: - explicit_chat_api = False - internal_metadata["_workflow_chat_api_starter"] = explicit_chat_api - normalized["metadata"] = internal_metadata # agent.md and CLAUDE.md (and their case variants) are aliases for main.agent.md; # check the normalized name to determine main-agent status normalized["is_main"] = normalized_file.name.lower() == "main.agent.md" diff --git a/src/azure_functions_agents/workflows/context.py b/src/azure_functions_agents/workflows/context.py index a3d4fa52..3308a063 100644 --- a/src/azure_functions_agents/workflows/context.py +++ b/src/azure_functions_agents/workflows/context.py @@ -24,7 +24,8 @@ import uuid from dataclasses import dataclass from threading import Lock -from typing import Any + +from azure.durable_functions import DurableOrchestrationClient OWNER_SESSION_PREFIX_LEN = 32 # Compatibility alias retained for callers that imported the original constant. @@ -75,7 +76,7 @@ class WorkflowSessionContext: owner_slug: str session_id: str agent_name: str - durable_client: Any # azure.durable_functions.DurableOrchestrationClient + durable_client: DurableOrchestrationClient @dataclass(frozen=True) @@ -92,7 +93,7 @@ def register_workflow_session( owner_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. diff --git a/src/azure_functions_agents/workflows/engine.py b/src/azure_functions_agents/workflows/engine.py index 607af585..9e13fa87 100644 --- a/src/azure_functions_agents/workflows/engine.py +++ b/src/azure_functions_agents/workflows/engine.py @@ -23,7 +23,7 @@ import asyncio import json from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict import azure.durable_functions as df import azure.functions as func @@ -55,6 +55,25 @@ WORKFLOW_SAFE_ECHO_TOOL = ECHO_TOOL_NAME +class _ActivityInputBase(TypedDict): + id: str + owner_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. @@ -116,28 +135,28 @@ def register_workflows( """ bp = df.Blueprint() - def require_owner_policy(task: dict[str, Any]) -> tuple[str, WorkflowPlanPolicy]: - owner_slug = str(task.get("owner_slug") or "") + def require_owner_policy(task: _ActivityInput) -> tuple[str, WorkflowPlanPolicy]: + owner_slug = task["owner_slug"] policy = owner_policies.get(owner_slug) if owner_policies is not None else None if not owner_slug or policy is None: logger.error( "workflow activity owner policy miss: workflow_id=%s node_id=%s owner=%s", - str(task.get("workflow_id") or ""), - str(task.get("id") or ""), + task["workflow_id"], + task["id"], owner_slug or "", ) raise RuntimeError( - f"task {str(task.get('id') or '')!r}: workflow owner policy is not available" + f"task {task['id']!r}: workflow owner policy is not available" ) return owner_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 {} + args = task["args"] owner_slug, policy = require_owner_policy(task) - workflow_id = str(task.get("workflow_id") or "") + 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 owner=%s tool=%s", @@ -187,10 +206,12 @@ 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"] owner_slug, policy = require_owner_policy(task) if agent_slug not in policy.allowed_subagents: logger.error( @@ -230,7 +251,7 @@ async def agents_workflow_run_sub_agent(task) -> dict[str, Any]: # type: ignore text = await run_leaf_agent_task( entry.resolved, entry.capabilities, - str(task["task"]), + task["task"], timeout=entry.resolved.timeout, execution_role="workflow_subagent", ) diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index fa709e48..69ce92f5 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -423,38 +423,20 @@ def _build_plan_policy( ) -def _has_eligible_starter(resolved: ResolvedAgent) -> bool: +def validate_workflow_owner_trigger(resolved: ResolvedAgent) -> None: + """Reject unsupported declared triggers for a workflow-enabled owner.""" if ( - resolved.trigger is not None - and str(resolved.trigger.type or "").strip() in TRIGGER_TYPES + resolved.workflows is None + or not resolved.workflows.enabled + or resolved.trigger is None ): - return True - endpoints = resolved.builtin_endpoints - chat_api = resolved.metadata.get( - "_workflow_chat_api_starter", - endpoints.chat_api and not endpoints.debug_chat_ui, - ) - return bool(chat_api or endpoints.mcp) - - -def validate_workflow_owner_starter(resolved: ResolvedAgent) -> None: - """Reject an enabled owner that has no Durable-capable invocation surface.""" - if resolved.workflows is None or not resolved.workflows.enabled: return - if resolved.trigger is not None: - 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`: " - "Unknown or unsupported " - f"trigger type `{trigger_type}`. See docs/front-matter-spec.md#trigger." - ) - if not _has_eligible_starter(resolved): + trigger_type = str(resolved.trigger.type or "").strip() + if trigger_type not in TRIGGER_TYPES: raise ValueError( - f"Agent {resolved.slug!r} sets workflows.enabled=true but has no " - "eligible workflow starter. Configure a trigger, " - "builtin_endpoints.chat_api, or builtin_endpoints.mcp; " - "debug_chat_ui alone is not sufficient." + f"{resolved.source_file or ''}: field `trigger.type`: " + f"Unknown or unsupported trigger type `{trigger_type}`. " + "See docs/front-matter-spec.md#trigger." ) @@ -462,13 +444,12 @@ def build_workflow_owner_policy_catalog( catalog: AgentCatalog, handler_catalog: registry.WorkflowHandlerCatalog, ) -> WorkflowOwnerPolicyCatalog: - """Freeze one independent workflow policy per enabled eligible owner.""" + """Freeze one independent workflow policy per enabled owner.""" policies: dict[str, WorkflowPlanPolicy] = {} for owner_slug, entry in catalog.items(): resolved = entry.resolved if resolved.workflows is None or not resolved.workflows.enabled: continue - validate_workflow_owner_starter(resolved) allowed_tools = frozenset( tool.name for tool in entry.capabilities.filtered_workflow_tools @@ -577,5 +558,5 @@ def build_workflow_integration( "build_workflow_integration", "build_workflow_owner_policy_catalog", "register_workflow_runtime", - "validate_workflow_owner_starter", + "validate_workflow_owner_trigger", ] diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index 1d1ff64e..337d1044 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -20,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 @@ -228,7 +229,7 @@ def _is_active_status(status: Any) -> bool: async def fetch_session_workflows( - durable_client: Any, + durable_client: DurableOrchestrationClient, owner_slug: str, session_id: str, ) -> list[dict[str, Any]]: @@ -257,7 +258,7 @@ async def fetch_session_workflows( async def count_active_session_workflows( - durable_client: Any, + durable_client: DurableOrchestrationClient, owner_slug: str, session_id: str, ) -> int: @@ -277,7 +278,7 @@ async def count_active_session_workflows( async def fetch_session_workflow_status( - durable_client: Any, + durable_client: DurableOrchestrationClient, owner_slug: str, session_id: str, workflow_id: str, @@ -586,7 +587,7 @@ def _build_session( owner_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 @@ -603,7 +604,7 @@ def build_workflow_tools( session_id: str | None = None, owner_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.""" diff --git a/tests/test_per_agent_workflows.py b/tests/test_per_agent_workflows.py index 3d18365b..a50280c9 100644 --- a/tests/test_per_agent_workflows.py +++ b/tests/test_per_agent_workflows.py @@ -57,10 +57,7 @@ def test_non_main_workflow_owner_without_main_creates_dfapp(tmp_path) -> None: @pytest.mark.parametrize("chat_api", ['"true"', "1"]) -def test_workflow_owner_accepts_coercible_explicit_chat_api( - tmp_path, - chat_api: str, -) -> None: +def test_workflow_owner_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None: _write_agent( tmp_path, "incident.agent.md", @@ -135,7 +132,7 @@ def test_shared_workflow_subagent_registers_one_durable_activity(tmp_path) -> No assert _function_names(app).count(engine.SUB_AGENT_ACTIVITY_NAME) == 1 -def test_mcp_only_workflow_owner_is_eligible(tmp_path) -> None: +def test_mcp_only_workflow_owner_is_supported(tmp_path) -> None: _write_agent( tmp_path, "mcp_owner.agent.md", @@ -175,6 +172,24 @@ def test_unknown_trigger_workflow_owner_fails_composition(tmp_path) -> None: create_function_app(tmp_path) +def test_workflow_owner_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_owner_composition(tmp_path) -> None: _write_agent( tmp_path, @@ -193,31 +208,34 @@ def test_callable_non_trigger_decorator_fails_workflow_owner_composition(tmp_pat create_function_app(tmp_path) -@pytest.mark.parametrize( - "starter", - [ - "", - "builtin_endpoints:\n debug_chat_ui: true", - ], -) -def test_enabled_workflow_owner_requires_eligible_starter(tmp_path, starter: str) -> None: +def test_internal_agent_can_enable_workflows_when_referenced_as_subagent(tmp_path) -> None: _write_agent( tmp_path, - "inert.agent.md", - f""" -name: Inert -description: Has no workflow starter. -{starter} + "coordinator.agent.md", + """ +name: Coordinator +description: Invokes the internal owner. +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 """, ) - with pytest.raises( - ValueError, - match=r"workflows\.enabled.*eligible workflow starter", - ): - create_function_app(tmp_path) + app = create_function_app(tmp_path) + + assert isinstance(app, df.DFApp) + assert _function_names(app).count(engine.ORCHESTRATOR_NAME) == 1 def _resolved( diff --git a/tests/test_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py index db2410d0..8381f772 100644 --- a/tests/test_per_agent_workflows_sample.py +++ b/tests/test_per_agent_workflows_sample.py @@ -203,8 +203,6 @@ def test_sample_runtime_files_and_readme_are_complete() -> None: "requirements.txt", ): assert (SAMPLE_SRC / name).is_file() - assert (SAMPLE_ROOT / "scripts" / "send.py").is_file() - assert not (SAMPLE_SRC / "main.agent.md").exists() readme = (SAMPLE_ROOT / "README.md").read_text(encoding="utf-8") for required in ( @@ -212,11 +210,6 @@ def test_sample_runtime_files_and_readme_are_complete() -> None: "Architecture", "Incident workflow", "Release workflow", - "python scripts/verify.py", - "python scripts/send.py incident", - "python scripts/send.py release", - "--backend dts", - "x-ms-session-id", "INCIDENT_REPORT_READY", "RELEASE_DOSSIER_READY", "Troubleshooting", diff --git a/tests/test_per_agent_workflows_send.py b/tests/test_per_agent_workflows_send.py deleted file mode 100644 index 8e882d1c..00000000 --- a/tests/test_per_agent_workflows_send.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path -from types import ModuleType - -import pytest - -SAMPLE_ROOT = Path(__file__).resolve().parents[1] / "samples" / "per-agent-workflows" -SEND_SCRIPT = SAMPLE_ROOT / "scripts" / "send.py" - - -def _load_send_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: - monkeypatch.syspath_prepend(str(SEND_SCRIPT.parent)) - spec = importlib.util.spec_from_file_location("per_agent_workflows_send", SEND_SCRIPT) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -class _Response: - status = 200 - - def __init__(self, payload: object) -> None: - self._body = json.dumps(payload).encode() - - def __enter__(self) -> _Response: - return self - - def __exit__(self, *args: object) -> None: - return None - - def read(self) -> bytes: - return self._body - - -def test_send_pipeline_posts_prompt_and_shared_session( - monkeypatch: pytest.MonkeyPatch, -) -> None: - send = _load_send_module(monkeypatch) - workflow_id = "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc" - captured: dict[str, object] = {} - - def fake_urlopen(request: object, *, timeout: float) -> _Response: - captured["request"] = request - captured["timeout"] = timeout - return _Response({"response": f"Started {workflow_id}"}) - - monkeypatch.setattr(send, "urlopen", fake_urlopen) - - actual = send.send_pipeline( - "incident", - base_url="http://localhost:7071/", - session_id="manual-shared-session", - timeout=90, - ) - - request = captured["request"] - assert request.full_url == "http://localhost:7071/agents/incident_commander/chat" - assert request.get_header("X-ms-session-id") == "manual-shared-session" - assert json.loads(request.data) == {"prompt": send.INCIDENT_PROMPT} - assert captured["timeout"] == 90 - assert actual == workflow_id - - -def test_send_pipeline_selects_release_owner(monkeypatch: pytest.MonkeyPatch) -> None: - send = _load_send_module(monkeypatch) - - request = send.build_chat_request( - "release", - base_url="http://127.0.0.1:7071", - session_id="release-session", - ) - - assert request.full_url == "http://127.0.0.1:7071/agents/release_manager/chat" - assert json.loads(request.data) == {"prompt": send.RELEASE_PROMPT} diff --git a/tests/test_per_agent_workflows_verify.py b/tests/test_per_agent_workflows_verify.py index 35d3011b..fcce7bc3 100644 --- a/tests/test_per_agent_workflows_verify.py +++ b/tests/test_per_agent_workflows_verify.py @@ -9,8 +9,8 @@ import pytest -SAMPLE_ROOT = Path(__file__).resolve().parents[1] / "samples" / "per-agent-workflows" -VERIFY_SCRIPT = SAMPLE_ROOT / "scripts" / "verify.py" +REPO_ROOT = Path(__file__).resolve().parents[1] +VERIFY_SCRIPT = REPO_ROOT / "eng" / "scripts" / "verify_per_agent_workflows.py" def _load_verify_module() -> ModuleType: @@ -50,9 +50,6 @@ def test_host_environment_prepends_current_checkout_without_dropping_pythonpath( paths = environment["PYTHONPATH"].split(os.pathsep) assert Path(paths[0]).resolve() == (verify.REPO_ROOT / "src").resolve() assert paths[1:] == ["first-existing", "second-existing"] - assert Path(environment["AZURE_FUNCTIONS_AGENTS_EXPECTED_ROOT"]).resolve() == ( - verify.REPO_ROOT / "src" - ).resolve() def _clear_provider_environment( @@ -120,6 +117,24 @@ def test_provider_values_select_environment_provider_over_template_default( 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"), [ From 2680ca2a045bbe0ff7c930357134c40293f09b93 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Wed, 12 Aug 2026 10:36:36 -0700 Subject: [PATCH 14/18] fix: retain workflow runtime during drain Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- README.md | 1 + docs/architecture.md | 9 +- docs/frds/0009-per-agent-dynamic-workflows.md | 33 +++- docs/workflows.md | 59 ++++++- .../src/OnNewEmail.agent.md | 2 - samples/per-agent-workflows/README.md | 2 +- .../per-agent-workflows/src/requirements.txt | 3 +- src/azure_functions_agents/app.py | 15 +- .../workflows/integration.py | 42 ++++- .../workflows/schema.py | 1 + .../workflows/settings.py | 27 ++++ src/azure_functions_agents/workflows/tools.py | 10 +- tests/test_outlook_reply_sample.py | 1 + tests/test_per_agent_workflows.py | 145 ++++++++++++++++++ tests/test_per_agent_workflows_sample.py | 4 + tests/test_workflow_registry.py | 48 ++++++ 16 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 src/azure_functions_agents/workflows/settings.py diff --git a/README.md b/README.md index cb0ceff2..572b30ca 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,7 @@ correlation, `host.json` `telemetryMode: OpenTelemetry` is optional and additive | `AZURE_FUNCTIONS_AGENTS_MODEL` | Runtime-owned model fallback when no provider-specific model/deployment is set | | `AZURE_FUNCTIONS_AGENTS_REASONING_EFFORT` | Optional reasoning effort for supported reasoning models (valid values include `none`, `low`, `medium`, `high`, `xhigh`) | | `AZURE_FUNCTIONS_AGENTS_REASONING_SUMMARY` | Optional reasoning summary mode for supported reasoning models (valid values are `auto`, `concise`, `detailed`) | +| `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` | Retain the Durable workflow runtime while rejecting new workflow starts during final-owner drain; keep enabled until Task Hub tooling confirms no non-terminal instances | ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 91fa4796..3c5deabd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ A few boundaries are worth calling out explicitly: | Package/module | Role | Key entry points | | --- | --- | --- | -| `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 owner-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/app.py` | Top-level two-pass composition root. Before app mutation it builds the slug index, `AgentCatalog`, complete workflow-handler catalog, and immutable workflow owner-policy catalog. It chooses `DFApp` when any agent enables workflows or explicit drain mode retains the runtime, 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` | @@ -79,6 +79,7 @@ A few boundaries are worth calling out explicitly: | `azure_functions_agents/workflows/integration.py` | Builds the complete immutable handler catalog, immutable slug-keyed owner-policy catalog, per-owner management tools/addenda, validates declared trigger support for enabled owners, and performs the one app-wide Durable registration. | `build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()`, `build_owner_workflow_integration()`, `validate_workflow_owner_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 owner policy before complete-catalog dispatch. | `register_workflows()` | | `azure_functions_agents/workflows/context.py` | Tracks invocation context by `(owner_slug, session_id)` and derives non-revealing 128-bit ownership prefixes for Durable instance IDs. | `session_instance_prefix()`, `new_workflow_instance_id()`, `session_owns_workflow()` | +| `azure_functions_agents/workflows/settings.py` | Parses the explicit workflow drain-mode app setting once during composition, rejecting invalid values. The result is captured in immutable owner policies so request execution cannot drift from the startup decision. | `workflow_drain_mode_enabled()` | | `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 owner-scoped management tools. Start-time validation and list/status/cancel/terminate operations use the captured owner policy and owner/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()` | @@ -184,9 +185,9 @@ The `create_function_app()` docstring in `src/azure_functions_agents/app.py:crea 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 at least one owner policy exists, otherwise a plain `FunctionApp`) - - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and owner policies are captured by one app-level Durable registration before agent registration begins. + - **Input:** startup defaults such as `http_auth_level=func.AuthLevel.FUNCTION` and `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` + - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one owner policy exists or drain mode is active, otherwise a plain `FunctionApp`) + - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and owner policies are captured by one app-level Durable registration before agent registration begins. Drain mode deliberately performs the same registration with an empty policy catalog so instances from a removed final owner reach Activity reauthorization and fail closed; ordinary apps that never use workflows retain the lower-overhead plain `FunctionApp`. Active drain mode is emitted in the indexing summary and as a startup warning. 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` diff --git a/docs/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md index 3a824a5c..9c4f61a0 100644 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ b/docs/frds/0009-per-agent-dynamic-workflows.md @@ -284,12 +284,22 @@ payload. It performs no mutable policy lookup during replay. `wait` tasks have n capability dispatch and retain their existing validated bounds. Activity checks intentionally use policy from the currently deployed app. If a -deployment removes an owner, disables workflows, or tightens a grant, a pending -node using the removed capability fails closed. Persisting an old policy snapshot -as indefinitely authoritative would make policy revocation ineffective. - -This reauthorization and fail-closed revocation behavior is provisional while -the FRD is `Draft`; implementation must not begin until Decision #8 is ratified. +deployment removes an owner while at least one owner remains, or tightens a +grant, a pending node using the removed capability fails closed. Persisting an +old policy snapshot as indefinitely authoritative would make policy revocation +ineffective. + +Removing or disabling the final owner is a distinct lifecycle transition: +without an owner policy, the default app would no longer register the Durable +runtime, so pending instances could be stranded before reaching Activity +reauthorization. Operators must first set +`AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true`. Drain mode omits +`start_workflow` from owner tool sets, rejects direct application-level start +calls defensively, and retains the `DFApp`, orchestrator, and Activities even +when the current owner-policy catalog is empty. Removed owners then fail closed +against that empty catalog. After Durable/DTS tooling reports no non-terminal +instances, operators remove the drain setting to return an app with no owners to +a plain `FunctionApp`. Invalid drain-mode values fail startup. Direct Durable orchestration starts remain privileged control-plane operations. The application-level owner boundary protects starts and management through @@ -382,6 +392,7 @@ pair. This keeps internal verification infrastructure out of the customer app. | 16 | Activity failure propagation | Let Durable wrapper behavior surface / explicitly rethrow failed wave result | Explicitly rethrow a failed `task_all` result so owner-policy denials retain their original actionable error instead of becoming a secondary `TypeError` | Agent | 2026-08-11 | | 17 | Workflow owner eligibility | Require a dedicated starter / allow every enabled agent to own workflows | Treat every agent with `workflows.enabled: true` as an owner and keep invocation surfaces independent; this supersedes Decision #9 and removes raw-frontmatter starter metadata | Human | 2026-08-11 | | 18 | Customer sample boundary | Keep sender/verifier helpers in the sample / separate customer app from internal automation | Keep the sample directly runnable and documentation-led, remove the sender helper, and move E2E automation to `eng/scripts` | Human | 2026-08-11 | +| 19 | Final-owner removal | Always register Durable runtime / documentation-only drain requirement / explicit runtime-retention drain mode | Add `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`: reject new application starts and retain Durable registration with an empty policy catalog until operators confirm the Task Hub has no non-terminal instances; ordinary non-workflow apps remain plain `FunctionApp` | Human | 2026-08-12 | ## 6. Test plan @@ -400,6 +411,10 @@ pair. This keeps internal verification infrastructure out of the customer app. - complete workflow handler and Agent catalogs remain available; - excluding a handler for one owner does not unregister it for another; - production execution does not authorize from the singleton app allowlist. + - a normal app with no owners remains a plain `FunctionApp`; + - drain mode with no owners retains one Durable runtime with an empty policy + catalog; + - invalid drain-mode values fail startup. - [x] Unit: owner-scoped context and management - the same session ID under two owner slugs generates different prefixes; - active limits, list, status, cancel, and terminate require both owner and @@ -415,7 +430,9 @@ pair. This keeps internal verification infrastructure out of the customer app. - restrictive policy changes reject a pending disallowed node; - every capability-bearing Activity payload contains `owner_slug`; - failed Activity waves preserve the original authorization/execution error; - - `wait` tasks retain existing behavior. + - `wait` tasks retain existing behavior; + - drain mode rejects new application-level workflow starts before Durable + scheduling. - [x] Integration: invocation channels - multiple workflow-enabled agents register distinct chat, streaming, MCP, HTTP trigger, and non-HTTP trigger surfaces as configured; @@ -452,7 +469,7 @@ pair. This keeps internal verification infrastructure out of the customer app. - [x] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and document that ownership and invocation surfaces are independent. - [x] `docs/workflows.md` — document multiple owners, identity, isolation, - migration, trigger ownership, and operator guidance. + migration, trigger ownership, final-owner drain mode, and operator guidance. - [x] `docs/triggers.md` — clarify that each workflow-enabled declared trigger uses its owning agent's policy and Durable client. - [x] `README.md` — link the per-agent workflow sample. diff --git a/docs/workflows.md b/docs/workflows.md index ff3c8579..3ae7a7d1 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -182,6 +182,9 @@ If at least one workflow owner 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 owner. +An app with no owners remains a plain `FunctionApp` unless the operator enables +[final-owner drain mode](#removing-the-final-workflow-owner). + Each enabled owner 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 @@ -500,9 +503,59 @@ as nonexistent (404/empty, never 403), so two owners remain isolated even when callers deliberately reuse the same session ID. Activities reauthorize immediately before dispatch against the **currently -deployed** owner policy. Removing an owner, disabling workflows, or tightening a -tool/Sub Agent grant therefore revokes pending capability-bearing nodes; they -fail closed rather than continuing under a stale policy snapshot. +deployed** owner policy. Removing an owner while another owner 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 owner + +Removing the final owner without retaining the Durable runtime can strand +pending instances: a plain `FunctionApp` has no registered orchestrator or +Activities, so those instances cannot reach owner-policy reauthorization. Use +this two-deployment drain procedure: + +1. Set `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` while the current + workflow deployment is still active. Drain mode removes `start_workflow` from + the agent's tool set and defensively rejects direct application-level start + calls before Durable scheduling, while list, status, cancel, terminate, + orchestrator, and Activity execution remain available. The management tools + can access only workflows started under the same owner and session ID; use + Durable Functions or DTS Task Hub tooling as the authoritative app-wide + management surface from the start of the drain. Startup emits a warning and + records drain mode in the indexing summary. +2. Stop or quiesce external trigger/chat traffic that could repeatedly ask the + agent to start workflows. Direct Durable control-plane starts are privileged + operations outside this application guard and must also stop. +3. Remove or disable the final owner if desired, but keep drain mode enabled. + The app remains a `DFApp` with an empty owner-policy catalog, so pending tool + or Sub Agent Activities from removed owners fail closed instead of becoming + stranded. The removed owner's chat tools and + `/agents/{slug}/workflows`/`workflow-status` endpoints no longer exist, so + Durable/DTS tooling is now the only complete management surface. +4. Use Durable Functions management tooling or the DTS dashboard to query the + whole Task Hub. The session-scoped application list endpoint is capped and + cannot discover every non-HTTP invocation. Drain is complete only when + repeated queries show no `Pending`, `Running`, `Suspended`, or + `ContinuedAsNew` instances. +5. If instances do not complete within the maintenance window, inspect their + history and terminate the remainder through Durable/DTS management tooling. + Already-dispatched Activity side effects are not rolled back. Confirm every + instance reaches a terminal status. +6. Only after that confirmation, remove + `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`. An app with no owners then + returns to a plain `FunctionApp`. + +If the Task Hub cannot be queried, termination cannot be confirmed, or +non-terminal instances remain, keep drain mode and the Durable runtime deployed; +do not complete the final transition. Accepted true values are `true`, `1`, +`yes`, and `y`; false values are `false`, `0`, `no`, and `n`. Any other +non-empty value fails startup. + +Keep the Durable backend identity constant throughout the drain window: +`host.json` Durable configuration, Task Hub name, Azure Storage or DTS +connection settings, and extension bundle must continue pointing at the same +Task Hub. Changing those values during the drain can strand instances in a hub +the retained runtime no longer polls. ### Migration from legacy workflow IDs diff --git a/samples/outlook-reply-agent/src/OnNewEmail.agent.md b/samples/outlook-reply-agent/src/OnNewEmail.agent.md index 6381d88c..d43ef9d8 100644 --- a/samples/outlook-reply-agent/src/OnNewEmail.agent.md +++ b/samples/outlook-reply-agent/src/OnNewEmail.agent.md @@ -4,8 +4,6 @@ description: Drafts a reply when new Office 365 Outlook email comes from the wat trigger: type: connector_trigger - args: - type: connectorTrigger --- You are an Outlook reply drafting assistant. diff --git a/samples/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 3f0f4a03..5453429b 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -72,7 +72,7 @@ blocking findings, passed gates, required actions, and specialist analysis. ## Prerequisites -- Python 3.13 or 3.14 with this repository installed using `pip install -e .[dev]` +- Python 3.13+ with this repository installed using `pip install -e .[dev]` - Azure Functions Core Tools v4 (`func`) - Azurite - One model provider: diff --git a/samples/per-agent-workflows/src/requirements.txt b/samples/per-agent-workflows/src/requirements.txt index 9bf2c880..73d526eb 100644 --- a/samples/per-agent-workflows/src/requirements.txt +++ b/samples/per-agent-workflows/src/requirements.txt @@ -1,2 +1 @@ --e ../../.. - +-e ../../..[monitor] diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index cf50b954..53b6bb07 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -35,6 +35,7 @@ register_workflow_runtime, validate_workflow_owner_trigger, ) +from .workflows.settings import workflow_drain_mode_enabled def _tool_name(tool: object) -> str: @@ -197,24 +198,33 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog: AgentCatalog = build_catalog(catalog_entries) workflow_handler_catalog = build_workflow_handler_catalog(workflow_tools) + workflow_drain_mode = workflow_drain_mode_enabled() workflow_owner_policies = build_workflow_owner_policy_catalog( catalog, workflow_handler_catalog, + starts_allowed=not workflow_drain_mode, ) + workflow_runtime_required = bool(workflow_owner_policies) or workflow_drain_mode app: func.FunctionApp = ( df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) - if workflow_owner_policies + if workflow_runtime_required else func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) ) # --- Two-pass composition, pass 2 (FRD 0007 §4.2): mutate `app` -------------------- - if workflow_owner_policies: + if workflow_runtime_required: register_workflow_runtime( app, handler_catalog=workflow_handler_catalog, catalog=catalog, owner_policies=workflow_owner_policies, ) + if workflow_drain_mode: + logger.warning( + "workflow drain mode active: new application-level workflow starts " + "are disabled; owner_policy_count=%d", + len(workflow_owner_policies), + ) for resolved in resolved_agents: capabilities = catalog[resolved.slug].capabilities @@ -308,6 +318,7 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: "agent_count": len(agent_specs), "agents": agents_summary, "system_tools": list(system_tools_used), + "workflow_drain_mode": workflow_drain_mode, "discovered_capabilities": { "mcp_servers": len(mcp_names), "skills": len(skill_names), diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index 69ce92f5..39550676 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -75,13 +75,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 " @@ -104,7 +98,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-" @@ -134,6 +139,15 @@ "response format permits it." ) +_DRAIN_ADDENDUM = ( + "\n\n" + "## Workflow drain mode\n\n" + "This app is draining existing workflows. New workflow starts are disabled. " + "Do not attempt to call `start_workflow`; only use workflow status, list, " + "cancel, or terminate tools when the user explicitly asks to manage an " + "existing workflow.\n" +) + @dataclass(frozen=True) class WorkflowIntegrationResult: @@ -393,6 +407,12 @@ def _build_addendum( trigger_invocation: bool, handler_catalog: registry.WorkflowHandlerCatalog | None = None, ) -> str: + if not policy.starts_allowed: + return ( + _DRAIN_ADDENDUM + if trigger_invocation + else _DRAIN_ADDENDUM + _CHAT_NOTIFICATION_ADDENDUM + ) channel_addendum = _TRIGGER_ADDENDUM if trigger_invocation else _CHAT_ADDENDUM return ( _SHARED_ADDENDUM @@ -406,6 +426,8 @@ def _build_plan_policy( allowed_tools: frozenset[str], workflow_subagents: Sequence[WorkflowSubagentRef], catalog: AgentCatalog | None, + *, + starts_allowed: bool = True, ) -> WorkflowPlanPolicy: guidance: list[tuple[str, str]] = [] for ref in workflow_subagents: @@ -420,6 +442,7 @@ def _build_plan_policy( allowed_tools=allowed_tools, allowed_subagents=frozenset(ref.agent for ref in workflow_subagents), subagent_guidance=tuple(guidance), + starts_allowed=starts_allowed, ) @@ -443,6 +466,8 @@ def validate_workflow_owner_trigger(resolved: ResolvedAgent) -> None: def build_workflow_owner_policy_catalog( catalog: AgentCatalog, handler_catalog: registry.WorkflowHandlerCatalog, + *, + starts_allowed: bool = True, ) -> WorkflowOwnerPolicyCatalog: """Freeze one independent workflow policy per enabled owner.""" policies: dict[str, WorkflowPlanPolicy] = {} @@ -462,6 +487,7 @@ def build_workflow_owner_policy_catalog( allowed_tools, resolved.workflows.subagents, catalog, + starts_allowed=starts_allowed, ) return MappingProxyType(policies) diff --git a/src/azure_functions_agents/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py index e1322521..9ad3f659 100644 --- a/src/azure_functions_agents/workflows/schema.py +++ b/src/azure_functions_agents/workflows/schema.py @@ -60,6 +60,7 @@ class WorkflowPlanPolicy: allowed_tools: frozenset[str] allowed_subagents: frozenset[str] subagent_guidance: tuple[tuple[str, str], ...] = () + starts_allowed: bool = True class WorkflowTask(BaseModel): diff --git a/src/azure_functions_agents/workflows/settings.py b/src/azure_functions_agents/workflows/settings.py new file mode 100644 index 00000000..29d2da0f --- /dev/null +++ b/src/azure_functions_agents/workflows/settings.py @@ -0,0 +1,27 @@ +"""Operational settings for the Dynamic Workflows runtime.""" + +from azure_functions_agents.config.env import runtime_env_value + +WORKFLOW_DRAIN_MODE_ENV = "AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE" + +_TRUE_VALUES = frozenset({"true", "1", "yes", "y"}) +_FALSE_VALUES = frozenset({"false", "0", "no", "n"}) + + +def workflow_drain_mode_enabled() -> bool: + """Return whether the app should retain Durable runtime for workflow draining.""" + raw = runtime_env_value(WORKFLOW_DRAIN_MODE_ENV) + if not raw: + return False + normalized = raw.lower() + if normalized in _TRUE_VALUES: + return True + if normalized in _FALSE_VALUES: + return False + raise ValueError( + f"{WORKFLOW_DRAIN_MODE_ENV} must be a boolean " + "(true/false, 1/0, yes/no, or y/n)" + ) + + +__all__ = ["WORKFLOW_DRAIN_MODE_ENV", "workflow_drain_mode_enabled"] diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index 337d1044..dc678fb2 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -352,6 +352,10 @@ async def start_workflow( ) -> str: if session is None: return _error(_NO_CLIENT_MESSAGE) + if policy is not None and not policy.starts_allowed: + return _error( + "workflow drain mode is active; new workflows cannot be started" + ) allowed_tools = registry.get_app_config() if policy is None else None if policy is None and allowed_tools is None: @@ -650,13 +654,15 @@ async def _cancel_workflow(params: CancelWorkflowParams) -> str: async def _terminate_workflow(params: TerminateWorkflowParams) -> str: return await terminate_workflow(params, session) - return [ - _start_workflow, + workflow_tools = [ _get_workflow_status, _list_workflows, _cancel_workflow, _terminate_workflow, ] + if policy is None or policy.starts_allowed: + workflow_tools.insert(0, _start_workflow) + return workflow_tools __all__ = [ diff --git a/tests/test_outlook_reply_sample.py b/tests/test_outlook_reply_sample.py index 140d48b7..2ae93498 100644 --- a/tests/test_outlook_reply_sample.py +++ b/tests/test_outlook_reply_sample.py @@ -14,3 +14,4 @@ def test_outlook_reply_sample_uses_supported_connector_trigger() -> None: 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 index a50280c9..8846021d 100644 --- a/tests/test_per_agent_workflows.py +++ b/tests/test_per_agent_workflows.py @@ -4,6 +4,7 @@ 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 @@ -32,6 +33,14 @@ 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_owner_without_main_creates_dfapp(tmp_path) -> None: _write_agent( tmp_path, @@ -56,6 +65,142 @@ def test_non_main_workflow_owner_without_main_creates_dfapp(tmp_path) -> None: 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) + + +def test_drain_mode_retains_runtime_with_no_workflow_owners( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "true") + caplog.set_level("INFO", logger="azure.functions.AgentRuntime") + _write_agent( + tmp_path, + "assistant.agent.md", + """ +name: Assistant +description: Handles chat after the final workflow owner was removed. +builtin_endpoints: + chat_api: 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 + activity = _registered_function(app, "agents_workflow_run_tool") + with pytest.raises(RuntimeError, match="owner policy"): + activity( + { + "id": "pending", + "tool": "removed_tool", + "args": {}, + "owner_slug": "removed_owner", + "workflow_id": "workflow-1", + } + ) + assert "workflow drain mode active" in caplog.text + assert '"workflow_drain_mode": true' in caplog.text + + +def test_drain_mode_disables_starts_for_existing_owner( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "true") + captured: dict[str, schema.WorkflowPlanPolicy] = {} + original_builder = integration.build_workflow_owner_policy_catalog + + def capture_policies(catalog, handler_catalog, *, starts_allowed=True): + policies = original_builder( + catalog, + handler_catalog, + starts_allowed=starts_allowed, + ) + captured.update(policies) + return policies + + monkeypatch.setattr( + "azure_functions_agents.app.build_workflow_owner_policy_catalog", + capture_policies, + ) + _write_agent( + tmp_path, + "incident.agent.md", + """ +name: Incident +description: Triage incidents while existing workflows drain. +builtin_endpoints: + chat_api: true +workflows: + enabled: true +""", + ) + + app = create_function_app(tmp_path) + + assert isinstance(app, df.DFApp) + assert "agent_incident_builtin_chat" in _function_names(app) + policy = captured["incident"] + assert not policy.starts_allowed + owner_integration = integration.build_owner_workflow_integration( + policy, + MappingProxyType({}), + ) + assert {tool.name for tool in owner_integration.workflow_tools} == { + "get_workflow_status", + "list_workflows", + "cancel_workflow", + "terminate_workflow", + } + assert "Workflow drain mode" in owner_integration.chat_system_addendum + assert "" in owner_integration.chat_system_addendum + + +def test_invalid_drain_mode_fails_startup( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "sometimes") + _write_agent( + tmp_path, + "assistant.agent.md", + """ +name: Assistant +description: Handles chat without workflows. +builtin_endpoints: + chat_api: true +""", + ) + + with pytest.raises( + ValueError, + match="AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", + ): + create_function_app(tmp_path) + + @pytest.mark.parametrize("chat_api", ['"true"', "1"]) def test_workflow_owner_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None: _write_agent( diff --git a/tests/test_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py index 8381f772..853b7a08 100644 --- a/tests/test_per_agent_workflows_sample.py +++ b/tests/test_per_agent_workflows_sample.py @@ -215,6 +215,10 @@ def test_sample_runtime_files_and_readme_are_complete() -> None: "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") diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index 6537b6b2..75ffb2be 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -176,6 +176,23 @@ def test_reserved_names_match_management_tools(): assert actual == set(registry.RESERVED_TOOL_NAMES) +def test_drain_policy_exposes_management_tools_without_start(): + policy = schema.WorkflowPlanPolicy( + allowed_tools=frozenset(), + allowed_subagents=frozenset(), + starts_allowed=False, + ) + + actual = {tool.name for tool in tools.build_workflow_tools(policy=policy)} + + assert actual == { + "get_workflow_status", + "list_workflows", + "cancel_workflow", + "terminate_workflow", + } + + def test_register_workflow_tool_rejects_async_handler(): async def async_handler(args): return {} @@ -737,6 +754,37 @@ async def get_status_all(self): assert "not authorized" in json.loads(result)["error"] +@pytest.mark.asyncio +async def test_start_workflow_rejects_new_instances_in_drain_mode( +) -> None: + class _UnexpectedClient: + async def get_status_all(self): + raise AssertionError("drain mode must reject before Durable scheduling") + + session = context.WorkflowSessionContext( + owner_slug="incident", + session_id="session-1", + agent_name="Incident", + durable_client=_UnexpectedClient(), + ) + + result = await tools.start_workflow( + tools.StartWorkflowParams( + tasks=[{"id": "pause", "type": "wait", "duration": "PT1S"}] + ), + session, + policy=schema.WorkflowPlanPolicy( + allowed_tools=frozenset(), + allowed_subagents=frozenset(), + starts_allowed=False, + ), + ) + + assert json.loads(result) == { + "error": "workflow drain mode is active; new workflows cannot be started" + } + + @pytest.mark.asyncio async def test_start_workflow_threads_owner_slug_into_durable_input() -> None: client = _CappedDurableClient([]) From 371c845e744326da95ba2e8ce51ccc06f070d9fc Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Wed, 12 Aug 2026 20:18:22 -0700 Subject: [PATCH 15/18] docs: consolidate workflow design in FRD 0004 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- README.md | 10 +- docs/architecture.md | 2 +- docs/frds/0004-dynamic-workflows.md | 259 +++++++-- docs/frds/0007-multi-agent-delegation.md | 4 +- docs/frds/0009-per-agent-dynamic-workflows.md | 490 ------------------ docs/frds/README.md | 1 - docs/front-matter-spec.md | 14 +- docs/triggers.md | 6 +- docs/workflows.md | 98 ++-- samples/README.md | 6 +- samples/per-agent-workflows/README.md | 4 +- samples/workflow-incident-triage/README.md | 4 +- .../workflows/schema.py | 2 +- 13 files changed, 288 insertions(+), 612 deletions(-) delete mode 100644 docs/frds/0009-per-agent-dynamic-workflows.md diff --git a/README.md b/README.md index 1400f5c9..be2c3ff0 100644 --- a/README.md +++ b/README.md @@ -430,10 +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`. Any agent with a supported trigger, chat API, -or MCP endpoint can own workflows; see the +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 owners sharing one Durable engine. +non-main workflow-enabled agents sharing one Durable engine. ## Built-in Endpoint Routes @@ -557,7 +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 owners and independent policies +- [`per-agent-workflows`](samples/per-agent-workflows) — Engineering Operations Hub with two non-main workflow-enabled agents and independent policies ## Deployment Notes @@ -590,7 +590,7 @@ correlation, `host.json` `telemetryMode: OpenTelemetry` is optional and additive | `AZURE_FUNCTIONS_AGENTS_MODEL` | Runtime-owned model fallback when no provider-specific model/deployment is set | | `AZURE_FUNCTIONS_AGENTS_REASONING_EFFORT` | Optional reasoning effort for supported reasoning models (valid values include `none`, `low`, `medium`, `high`, `xhigh`) | | `AZURE_FUNCTIONS_AGENTS_REASONING_SUMMARY` | Optional reasoning summary mode for supported reasoning models (valid values are `auto`, `concise`, `detailed`) | -| `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` | Retain the Durable workflow runtime while rejecting new workflow starts during final-owner drain; keep enabled until Task Hub tooling confirms no non-terminal instances | +| `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` | Retain the Durable workflow runtime while rejecting new workflow starts when removing the final workflow-enabled agent; keep enabled until Task Hub tooling confirms no non-terminal instances | ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 6b9d589e..04d778aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ A few boundaries are worth calling out explicitly: the slug index and validates references, then freezes the `AgentCatalog`, complete workflow-handler catalog, and per-owner workflow-policy catalog. Only pass 2 creates/mutates the app, registers the - workflow runtime once, and registers agent surfaces (FRDs 0007 and 0009). + 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. diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 0f543975..ee32375c 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -4,8 +4,8 @@ title: Dynamic workflows status: Finalized author: TsuyoshiUshio created: 2026-07-06 -updated: 2026-08-11 -issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108] +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] --- @@ -14,31 +14,26 @@ 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: multi-owner workflow ownership and isolation +## Evolution -This FRD records the initial Dynamic Workflows v1 design, which assumed one -`main.agent.md` workflow owner and session-only workflow identity. FRD 0009, -[Multi-owner Dynamic Workflow Ownership and -Isolation](0009-per-agent-dynamic-workflows.md), extends that foundation so -multiple agents can own workflows independently. - -The follow-up is maintained as a separate FRD because it changes more than owner -eligibility: it introduces an app-wide execution catalog, immutable per-owner -authorization policies, Activity-time reauthorization, `(owner_slug, -session_id)` management isolation, and a breaking workflow-ID migration. Keeping -its Decisions log separate preserves this document as the historical record of -the original v1 requirements while making this section the entry point to the -current multi-owner design. +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-ownership-and-isolation-addendum-pr-151) +records only that extension's behavioral and architectural delta instead of +repeating the base workflow design. ## 2. Motivation / problem @@ -64,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` @@ -84,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 @@ -107,14 +101,14 @@ explicitly opt a function into the Durable Activity execution path. | --- | --- | --- | | 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. | +| 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 --- @@ -128,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 owner, 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 @@ -289,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 --- @@ -327,9 +318,9 @@ 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. +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: @@ -425,6 +416,126 @@ 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 ownership and 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 *owner* internally because its +slug defines an authorization namespace. Customer documentation uses +*workflow-enabled agent*; `owner_slug` 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 +`(owner_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 `owner_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 and drain mode + +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_owner_policy()` and fail because the Function that executes that check +is absent. `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` retains the +`DFApp`, orchestrator, and Activities while allowing the current policy catalog +to be empty. It also omits `start_workflow` from agent tool sets and defensively +rejects direct application-level starts. + +The safe transition is: + +1. Enable drain mode while the final workflow-enabled agent is still deployed, + and quiesce external starters. +2. Prefer to let existing instances reach terminal states. If the agent must be + removed first, keep drain mode enabled: retained Activities then execute and + fail explicitly against the missing policy instead of remaining queued + indefinitely. +3. Use Task Hub management tooling—not the session-scoped application list—to + confirm there are no `Pending`, `Running`, `Suspended`, or `ContinuedAsNew` + instances. Terminate any remainder when completion is no longer possible; + termination does not undo already-dispatched Activity side effects. +4. Only after confirmation, disable drain mode. An app with no workflow-enabled + agents then returns to a plain `FunctionApp`. + +Task Hub name, Storage or DTS connection, `host.json` Durable settings, and +extension bundle must continue to identify the same backend throughout the +drain. If the hub cannot be queried or termination cannot be confirmed, the +retained runtime must remain deployed. Direct Durable control-plane starts are +privileged operations outside the application-level start guard. + +#### 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 | @@ -452,6 +563,22 @@ 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; call it `owner_slug` only inside the authorization implementation | Agent | 2026-08-10 | +| 26 | Workflow isolation scope | Session only / agent only / agent plus session | Scope application management by `(owner_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 `eng/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 | ## 6. Test plan @@ -473,9 +600,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. @@ -490,7 +614,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 @@ -507,6 +631,21 @@ 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 and final-agent drain + - 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; + - retain the Durable runtime with an empty policy catalog in drain mode and + reject new application-level starts; + - fail startup for invalid drain-mode values; + - keep an ordinary app with no workflow-enabled agents on plain + `FunctionApp`; + - 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 @@ -527,6 +666,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 @@ -548,3 +692,18 @@ are a prerequisite, a parallel feature, or a later hardening step. - **Workflow Sub Agent human sign-off:** TsuyoshiUshio, 2026-07-24. Approved Activity-only execution, `{agent, text}` results, main-only v1 ownership, 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/frds/0009-per-agent-dynamic-workflows.md b/docs/frds/0009-per-agent-dynamic-workflows.md deleted file mode 100644 index 9c4f61a0..00000000 --- a/docs/frds/0009-per-agent-dynamic-workflows.md +++ /dev/null @@ -1,490 +0,0 @@ ---- -frd: 0009 -title: Multi-owner Dynamic Workflow Ownership and Isolation -status: Finalized -author: TsuyoshiUshio -created: 2026-08-10 -updated: 2026-08-11 -issues: - - "Azure/azure-functions-agents-runtime#109" - - "Azure/azure-functions-bucees-planning#1274" - - "Azure/azure-functions-bucees-planning#1275" -pull_requests: - - "Azure/azure-functions-agents-runtime#151" -branch: tsuyoshiushio-per-agent-dynamic-workflows ---- - -# FRD 0009 — Multi-owner Dynamic Workflow Ownership and Isolation - -## 1. Summary - -Allow any `*.agent.md` agent, rather than only `main.agent.md`, to own -Dynamic Workflows independently. One Function App will register one Durable -engine and complete workflow handler inventory, while every workflow-enabled -agent receives an immutable owner-specific policy, prompt guidance, management -tools, and workflow ownership namespace keyed by its canonical -`ResolvedAgent.slug`. - -This change preserves the existing `workflows.enabled`, `workflows.exclude`, and -`workflows.subagents` authoring surface. It changes workflow identity and -management from session-only ownership to `(owner_slug, session_id)` ownership, -which cryptographically namespaces two agents receiving the same session ID so -they cannot see or control each other's workflows through application surfaces. - -## 2. Motivation / problem - -The runtime already supports Dynamic Workflow DAGs containing `tool`, `wait`, -and stateless leaf `sub_agent` tasks. `wait` is a built-in DAG node compiled to -a Durable timer; it is not a discovered or system-injected tool. -Workflow-enabled agents can start those -plans from built-in chat, MCP, HTTP triggers, and non-interactive -Markdown-declared triggers. The runtime also already has: - -- a canonical, app-wide unique `ResolvedAgent.slug`; -- an immutable `AgentCatalog`; -- owner-shaped `WorkflowPlanPolicy` values containing workflow tool and - Workflow Sub Agent grants; and -- per-agent routes such as `/agents/{slug}/workflows`. - -Despite those foundations, `app.py` still honors `workflows.enabled: true` only -when `resolved.is_main` is true. A non-main agent receives a warning and no -workflow integration. - -Removing only that `is_main` check would be incorrect: - -- `build_workflow_integration()` currently registers the Durable blueprint, so - calling it for multiple agents would register the same Functions repeatedly; -- the workflow registry stores one process-global effective tool allowlist, so - one agent's `workflows.exclude` could affect another agent; -- workflow IDs are namespaced only by `session_id`, so two agents using the same - caller-provided session ID can pass each other's ownership-prefix checks; and -- Activities dispatch through shared handler and Agent catalogs without - rechecking the workflow owner's policy. - -The feature therefore requires an architectural separation between app-wide -execution inventory and per-owner authorization. It also needs a runnable sample -that proves the behavior and isolation rather than showing only a frontmatter -snippet. - -## 3. Goals / Non-goals - -**Goals** - -- Honor `workflows.enabled: true` on every discovered agent. -- Keep `main.agent.md` working as an ordinary owner with slug `main`. -- Use `ResolvedAgent.slug` as the stable workflow owner identity on chat, MCP, - HTTP trigger, and non-interactive trigger paths. -- Create a `df.DFApp` when any agent enables workflows. -- Register the Durable orchestrator and Activities exactly once per Function App. -- Register one complete, unfiltered workflow handler inventory so one owner's - exclusions never unregister another owner's tools. -- Build one immutable `WorkflowPlanPolicy` per enabled owner. -- Use the same owner policy for prompt guidance, start-time plan validation, and - defense-in-depth Activity authorization. -- Isolate workflow IDs, active-workflow limits, list, status, cancel, terminate, - and HTTP polling by `(owner_slug, session_id)`. -- Preserve non-existence semantics for cross-owner access so a caller cannot - probe whether another owner has a workflow. -- Preserve the asynchronous trigger starter contract: the initiating Function - ends after the agent turn while Durable execution continues. -- Add a runnable customer sample with multiple non-main workflow owners and no - `main.agent.md`, plus separate E2E automation. - -**Non-goals** - -- New workflow frontmatter keys or a positive workflow-tool allowlist. -- Changes to the `tool`, `wait`, or `sub_agent` DAG schemas. -- Stateful or nested Workflow Sub Agents. -- Cross-app workflow invocation or ownership. -- Per-node retry, timeout, human approval, or compensation policy. -- An application-level index or reconnect API for workflows started by - non-HTTP triggers with generated session IDs. -- Changing chat history, MAF session, runner lock, sandbox session, or general - `x-ms-session-id` semantics outside Dynamic Workflows. -- Application-level management compatibility for legacy session-only workflow - IDs. - -## 4. Proposed design - -### 4.1 Pipeline alignment - -| Pipeline stage | Module(s) | Change | -| --- | --- | --- | -| discover | `discovery/tools.py` | No behavior change. Continue returning one app-wide inventory of explicit `@workflow_tool` declarations. Discovery remains read-only and applies no owner policy. | -| translate | `config/schema.py`, `config/merge.py`, `config/validation.py`, `registration/capabilities.py` | Reuse `WorkflowConfig`, canonical `ResolvedAgent.slug`, validated Workflow Sub Agent references, and each agent's workflow tools after `workflows.exclude`. No schema change is expected. | -| compose (pass 1) | `app.py`, `registration/catalog.py`, `workflows/integration.py` | After app-wide slug and reference validation, freeze the existing `AgentCatalog` and a new slug-keyed workflow owner-policy catalog. This pass remains side-effect-free and does not mutate a `FunctionApp`. | -| register (pass 2) | `app.py`, `workflows/integration.py`, `workflows/registry.py`, `workflows/engine.py`, `registration/endpoints.py`, `registration/triggers.py` | Create a `DFApp` when the policy catalog is non-empty. Register the complete handler inventory and Durable blueprint once, then thread each owner's policy and channel addendum into only that owner's surfaces. | -| execute | `runner.py`, `workflows/tools.py`, `workflows/context.py`, `workflows/engine.py`, `registration/_handlers.py` | Capture owner slug, session ID, Durable client, and explicit policy in workflow tool closures. Namespace management by owner plus session and reauthorize capability-bearing Activities before dispatch. | - -This extends the existing two-pass composition model. Registration consumes -typed, validated, immutable objects and does not re-parse frontmatter. - -### 4.2 Authoring and invocation surfaces - -No new authoring syntax is introduced. Any descriptively named agent can opt in: - -```yaml ---- -name: Incident Triage Assistant -description: Investigates production incidents. -builtin_endpoints: - debug_chat_ui: true - chat_api: true -workflows: - enabled: true - exclude: - - expensive_diagnostics - subagents: - - agent: log_analyst - when: Analyze one bounded set of logs ---- -``` - -Any agent may become an owner by enabling workflows. How that agent is invoked -remains an independent concern. Direct invocation can use: - -- built-in `chat_api`; -- built-in MCP; or -- any supported Markdown-declared trigger. - -`debug_chat_ui` automatically enables its backing chat API. An internal agent -without its own invocation surface may still enable workflows; it becomes -directly usable if an invocation surface is added later. - -If one agent exposes multiple channels, every channel uses the same owner policy. -Chat and MCP receive chat-specific guidance; Markdown-declared triggers receive -trigger-specific guidance. Authorization does not vary by channel. - -### 4.3 Stable owner identity and workflow IDs - -`ResolvedAgent.slug` is the sole owner identity. It is already: - -- derived during composition from the normalized source filename; -- guaranteed unique app-wide; -- the key of `AgentCatalog`; -- the built-in endpoint route identity; and -- the identity used by delegation and Workflow Sub Agent references. - -Workflow code must not derive or allocate a second owner identity. Configured -display name remains metadata only. - -Every invocation constructs an owner key from `(resolved.slug, session_id)`. -Instance IDs use SHA-256 over an unambiguous, length-delimited encoding of both -values, followed by the existing random UUID suffix: - -```text -{32-hex-owner-and-session-hash-prefix}-{uuid} -``` - -The raw slug and session ID remain absent from Durable-visible instance IDs. -This feature increases the ownership prefix from 12 hex characters (48 bits) to -32 hex characters (128 bits). A 48-bit truncated digest is insufficient for a -multi-owner authorization boundary at scale; 128 bits makes accidental or -chosen collision impractical while keeping IDs comfortably within Durable -limits. Ownership is still digest-based rather than literal owner-key storage, -so the guarantee is bounded by the collision resistance of the truncated -SHA-256 digest. - -All workflow management paths require both owner-key components: - -- workflow management tool closures capture the owner slug and resolved session; -- polling endpoint closures capture their route's owner slug and read the - request session; -- active count, list, status, cancel, and terminate helpers compare the - owner-scoped prefix; and -- a mismatched owner or session returns the same not-found/empty result as an - unknown workflow. - -### 4.4 App-wide execution catalogs - -The app owns two complete, read-only execution inventories: - -1. the existing `AgentCatalog`, used by Workflow Sub Agent Activities; and -2. a workflow handler catalog containing every valid discovered - `@workflow_tool` handler and its metadata. - -These catalogs answer what exists, not what a particular owner may invoke. -Owner A excluding tool X must not unregister X when owner B allows it. An agent -being present in `AgentCatalog` similarly does not grant Workflow Sub Agent -access. - -The Durable blueprint closes over the Agent catalog, workflow handler catalog, -and owner-policy catalog and is registered once. The singleton app allowlist -must no longer be an authorization source in production. Compatibility helpers -may remain temporarily for focused tests or external callers, but normal app -construction and execution always pass an explicit owner policy. - -### 4.5 Immutable owner-policy catalog - -Pass 1 constructs an immutable mapping: - -```text -owner slug -> WorkflowPlanPolicy( - allowed_tools=frozenset(...), - allowed_subagents=frozenset(...), - subagent_guidance=((slug, guidance), ...), -) -``` - -`allowed_tools` is the owner's set of public workflow tools after its existing -`workflows.exclude` filter. `allowed_subagents` and `subagent_guidance` come from -the owner's independent, deny-by-default `workflows.subagents` grants and the -immutable `AgentCatalog`. - -The same policy value: - -- generates the owner's chat and trigger prompt addenda; -- is captured by the owner's `start_workflow` closure; -- validates every authored `tool` and `sub_agent` node before Durable start; and -- is available to Activity dispatch for defense-in-depth authorization. - -### 4.6 One-time Durable registration - -After pass 1, `app.py` creates: - -- a `df.DFApp` when at least one owner policy exists; or -- a plain `func.FunctionApp` otherwise. - -Before individual agent registration, one app-level workflow registration step: - -- registers every compatible handler from the unfiltered workflow-tool - inventory; and -- registers one Durable blueprint containing the orchestrator, tool Activity, - and Workflow Sub Agent Activity. - -Individual agent registration then looks up `owner_policies[resolved.slug]`. -When present, it threads enabled state, explicit policy, owner slug, and the -appropriate addendum into `register_agent()` and -`register_builtin_endpoints()`. When absent, existing non-workflow handler -signatures and bindings remain unchanged. - -`build_workflow_integration()` will be split or reshaped so a pure per-owner -integration builder cannot accidentally register app-wide Functions. The -one-time registration function is the only workflow layer that mutates the -`DFApp`. - -### 4.7 Plan validation and Activity authorization - -`start_workflow` validates the complete authored plan with the captured owner -policy before starting Durable. The Durable input includes `owner_slug` with the -existing owner/session audit metadata and normalized tasks. - -Subject to explicit human ratification of Decision #8, each capability-bearing -Activity checks the currently deployed owner policy immediately before -shared-catalog dispatch: - -- tool Activity requires `task.tool in policy.allowed_tools`; -- Workflow Sub Agent Activity requires - `task.agent in policy.allowed_subagents`; and -- a missing owner policy, handler, or Agent catalog entry fails closed with a - non-sensitive error and correlated owner/workflow/node telemetry. - -The orchestrator passes `owner_slug` in each tool and Workflow Sub Agent Activity -payload. It performs no mutable policy lookup during replay. `wait` tasks have no -capability dispatch and retain their existing validated bounds. - -Activity checks intentionally use policy from the currently deployed app. If a -deployment removes an owner while at least one owner remains, or tightens a -grant, a pending node using the removed capability fails closed. Persisting an -old policy snapshot as indefinitely authoritative would make policy revocation -ineffective. - -Removing or disabling the final owner is a distinct lifecycle transition: -without an owner policy, the default app would no longer register the Durable -runtime, so pending instances could be stranded before reaching Activity -reauthorization. Operators must first set -`AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true`. Drain mode omits -`start_workflow` from owner tool sets, rejects direct application-level start -calls defensively, and retains the `DFApp`, orchestrator, and Activities even -when the current owner-policy catalog is empty. Removed owners then fail closed -against that empty catalog. After Durable/DTS tooling reports no non-terminal -instances, operators remove the drain setting to return an app with no owners to -a plain `FunctionApp`. Invalid drain-mode values fail startup. - -Direct Durable orchestration starts remain privileged control-plane operations. -The application-level owner boundary protects starts and management through -agent surfaces; it is not an authentication boundary against an actor already -authorized to start arbitrary Durable instances. - -### 4.8 Trigger ownership - -HTTP triggers use the caller-provided `x-ms-session-id` or the existing generated -session behavior. Non-HTTP triggers generate a fresh invocation session ID. In -both cases, the workflow owner is `(resolved.slug, invocation_session_id)`. - -The initial trigger Function remains short-lived and never polls for terminal -workflow state. Non-HTTP trigger workflows do not gain a new application-level -owner index or reconnect API. Applications should deliver final output through a -workflow task, while operators use Durable Functions or DTS tooling. - -### 4.9 Compatibility and migration - -This feature contains one intentional breaking change within the experimental -Dynamic Workflows surface. - -Existing workflow IDs use a session-only hash prefix. New IDs use an -owner-plus-session prefix. No application-level legacy fallback is proposed: - -- new agent tools and polling endpoints cannot list, inspect, cancel, or - terminate pre-upgrade IDs; -- legacy orchestration inputs contain no `owner_slug`, so in-flight instances - fail closed when they next dispatch a `tool` or `sub_agent` Activity; -- operators can still inspect or control remaining instances through - Durable/DTS; and -- deployments should drain or terminate active workflows before upgrading. - -The rest of the public surface remains compatible: - -- `main.agent.md` remains a valid workflow owner with slug `main`; -- `workflows.enabled`, `workflows.exclude`, and `workflows.subagents` do not - change; -- task schemas and workflow management tool names do not change; -- Durable orchestrator and Activity names do not change; -- built-in route shapes remain `/agents/{slug}/...`; and -- non-workflow session behavior does not change. - -### 4.10 Runnable sample - -Add `samples/per-agent-workflows/` as a standalone Azure Functions app with no -`main.agent.md`. It contains two descriptively named agents, for example: - -- `incident_triage.agent.md`, with chat endpoints and one set of workflow tool - and Sub Agent grants; and -- `release_readiness.agent.md`, with chat endpoints and a different set of - grants. - -The tools use deterministic synthetic data so manual operation requires no -external service token. The customer sample includes: - -- clear architecture and workflow-shape diagrams; -- one manual prompt for each agent; -- expected workflow outputs; and -- Azure Storage and DTS local instructions. - -Separate repository E2E automation in -`eng/scripts/verify_per_agent_workflows.py` deliberately uses the same -`x-ms-session-id` for both agents. It -starts one workflow through each agent, verifies both reach a terminal state, -checks that each used only its own capabilities, and verifies that each owner's -status route returns 404 for the other owner's workflow ID. This makes the main -behavioral and security property directly observable for the exercised owner -pair. This keeps internal verification infrastructure out of the customer app. - -## 5. Decisions log - -| # | Decision | Options considered | Choice | Decided by | Date | -| - | -------- | ------------------ | ------ | ---------- | ---- | -| 1 | FRD number | 0008 from current `main` / include open and draft PR reservations | Use 0009 because open PRs #111 and #121 both reserve 0008 | Agent | 2026-08-10 | -| 2 | Workflow owner identity | Display name / source path / endpoint-specific name / canonical slug | Use app-wide unique `ResolvedAgent.slug` on every channel | Agent | 2026-08-10 | -| 3 | Workflow ownership scope | Session only / owner only / `(owner_slug, session_id)` | Use `(owner_slug, session_id)` so equal session IDs across agents remain isolated | Human | 2026-08-10 | -| 4 | Existing workflow IDs | Dual-format fallback / migration map / no application fallback | Accept the experimental breaking change, preserve Durable/DTS operator access, and document drain guidance | Human | 2026-08-10 | -| 5 | Durable registration lifetime | Once per owner / once per app | Register the Durable engine exactly once per app | Agent | 2026-08-10 | -| 6 | Workflow handler inventory | First owner's filtered tools / union of owner tools / complete discovered catalog | Register the complete compatible handler catalog once and authorize separately per owner | Agent | 2026-08-10 | -| 7 | Owner policy representation | Mutable process global / request-time reconstruction / immutable slug-keyed catalog | Build immutable `WorkflowPlanPolicy` values during side-effect-free composition | Agent | 2026-08-10 | -| 8 | Activity authorization | Trust start-time validation / persist start-time policy / reauthorize deployed policy | Reauthorize tool and Sub Agent Activities against current deployed owner policy; pending nodes fail closed after restrictive changes | Human | 2026-08-10 | -| 9 | Enabled owner without starter | Warn and disable / silently ignore / fail composition | Fail composition because inert workflow configuration is misleading | Human | 2026-08-10 | -| 10 | Non-HTTP trigger management | Add owner index / shared synthetic session / generated non-discoverable invocation session | Use generated sessions with no new application index; use Durable/DTS for operator management | Human | 2026-08-10 | -| 11 | Authoring schema | Add owner/config fields / reuse current workflow config | Reuse existing fields; owner identity is runtime-derived | Agent | 2026-08-10 | -| 12 | Sample proof | Extend a main-agent sample / documentation only / dedicated multi-owner sample | Add a runnable sample with two non-main owners and same-session isolation verification | Human | 2026-08-10 | -| 13 | Ownership digest width | Retain 48-bit prefix / store literal owner data / expand digest | Use a 128-bit truncated SHA-256 prefix over a length-delimited owner/session encoding; avoids exposing raw identity while making collisions impractical | Human | 2026-08-10 | -| 14 | Existing exported compatibility helpers | Delete as production-dead / retain unchanged / isolate compatibility state | Retain the exported registry and one-shot integration helper to avoid an unrelated breaking change, but remove the registration token from production `WorkflowSessionContext` and keep it private to the compatibility registry | Agent | 2026-08-11 | -| 15 | Trigger decorator resolution | New shared resolver / duplicate capability validation / retain registration-local fallback | Keep the existing registration-local `connector_trigger` → `generic_trigger` fallback; workflow eligibility uses documented `TRIGGER_TYPES`, avoiding a new helper and an unrelated hard failure for non-workflow agents | Agent | 2026-08-11 | -| 16 | Activity failure propagation | Let Durable wrapper behavior surface / explicitly rethrow failed wave result | Explicitly rethrow a failed `task_all` result so owner-policy denials retain their original actionable error instead of becoming a secondary `TypeError` | Agent | 2026-08-11 | -| 17 | Workflow owner eligibility | Require a dedicated starter / allow every enabled agent to own workflows | Treat every agent with `workflows.enabled: true` as an owner and keep invocation surfaces independent; this supersedes Decision #9 and removes raw-frontmatter starter metadata | Human | 2026-08-11 | -| 18 | Customer sample boundary | Keep sender/verifier helpers in the sample / separate customer app from internal automation | Keep the sample directly runnable and documentation-led, remove the sender helper, and move E2E automation to `eng/scripts` | Human | 2026-08-11 | -| 19 | Final-owner removal | Always register Durable runtime / documentation-only drain requirement / explicit runtime-retention drain mode | Add `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`: reject new application starts and retain Durable registration with an empty policy catalog until operators confirm the Task Hub has no non-terminal instances; ordinary non-workflow apps remain plain `FunctionApp` | Human | 2026-08-12 | - -## 6. Test plan - -- [x] Unit: composition and owner-policy catalog - - any non-main agent can enable workflows; - - an app with only non-main workflow owners is a `df.DFApp`; - - `main.agent.md` remains supported; - - an endpoint-less enabled owner referenced as a specialist composes without - special starter metadata; - - distinct owners receive independent tool excludes, Sub Agent grants, and - prompt guidance; - - owner-policy mappings and values are immutable. -- [x] Unit: one-time runtime registration - - multiple enabled owners register one orchestrator and one copy of each - Activity; - - complete workflow handler and Agent catalogs remain available; - - excluding a handler for one owner does not unregister it for another; - - production execution does not authorize from the singleton app allowlist. - - a normal app with no owners remains a plain `FunctionApp`; - - drain mode with no owners retains one Durable runtime with an empty policy - catalog; - - invalid drain-mode values fail startup. -- [x] Unit: owner-scoped context and management - - the same session ID under two owner slugs generates different prefixes; - - active limits, list, status, cancel, and terminate require both owner and - session; - - cross-owner operations return empty/not-found without disclosing existence; - - legacy session-only IDs do not match an owner-scoped prefix, are treated as - not-found, and their Durable instances are not deleted or mutated. -- [x] Unit: plan and Activity authorization - - prompt guidance and start-time validation use the same owner policy; - - tool and Workflow Sub Agent Activities reject capabilities belonging only to - another owner; - - missing or disabled owner policy fails closed; - - restrictive policy changes reject a pending disallowed node; - - every capability-bearing Activity payload contains `owner_slug`; - - failed Activity waves preserve the original authorization/execution error; - - `wait` tasks retain existing behavior; - - drain mode rejects new application-level workflow starts before Durable - scheduling. -- [x] Integration: invocation channels - - multiple workflow-enabled agents register distinct chat, streaming, MCP, - HTTP trigger, and non-HTTP trigger surfaces as configured; - - each enabled surface receives the Durable client binding and correct - channel addendum; - - HTTP workflow polling routes cannot observe another owner under the same - session ID; - - trigger starters return/end without waiting for terminal workflow state; - - explicitly configured coercible `chat_api` values are evaluated consistently - with the validated endpoint model. -- [x] Workflow Sub Agent isolation - - each owner can schedule only its own `workflows.subagents` grants; - - one specialist may be granted to multiple owners without duplicate Activity - registration; - - workflow leaf specialists retain their current isolated execution role. -- [x] Fixture scenario: - `tests/fixtures/config_scenarios/18_multi_owner_workflows/`. -- [x] E2E: Azure Storage and DTS runs demonstrate concurrent owners, overlapping - session IDs, distinct policies, status/control isolation, and execution after - starter completion. -- [x] E2E verifier: repository automation starts dependencies and proves both - successful workflows plus cross-owner denial without adding internal helper - scripts to the customer sample. -- [x] Canonical gate: - - `python -m ruff check src tests`; - - `python -m mypy src`; - - `python -m pytest --cache-clear --cov=./src/azure_functions_agents - --cov-report=xml --cov-branch tests`. - -## 7. Docs impact - -- [x] `docs/architecture.md` — add the owner-policy catalog, one-time Durable - registration, owner-scoped execution, and Activity reauthorization. -- [x] `docs/front-matter-spec.md` — remove the `main.agent.md` restriction and - document that ownership and invocation surfaces are independent. -- [x] `docs/workflows.md` — document multiple owners, identity, isolation, - migration, trigger ownership, final-owner drain mode, and operator guidance. -- [x] `docs/triggers.md` — clarify that each workflow-enabled declared trigger - uses its owning agent's policy and Durable client. -- [x] `README.md` — link the per-agent workflow sample. -- [x] `samples/README.md` — list the runnable customer sample. -- [x] `docs/front-matter-reference.md` — no change expected because no schema - change is planned. - -## 8. Status & sign-off - -- **Architecture review (phase 2):** Completed by an independent rubber-duck - reviewer on 2026-08-10 against current `main`, `docs/architecture.md`, FRD - 0004, FRD 0007, issues #1274/#1275, and the existing trigger and Workflow Sub - Agent implementations. No blocking findings remained. Important findings on - ownership digest strength, provisional decisions, legacy-ID wording, and - verifier prerequisites were incorporated. -- **Human sign-off:** Completed by TsuyoshiUshio on 2026-08-10. The human - approved proceeding with implementation in the same PR, ratifying Decisions - #8-#10 and #13. Status set to `Finalized`. diff --git a/docs/frds/README.md b/docs/frds/README.md index a91d9a28..926b3fd5 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -36,7 +36,6 @@ The full lifecycle that produces an FRD lives in [`AGENTS.md`](https://github.co | [0005](0005-web-request-system-tool.md) | `web_request` system tool | In review | | [0006](0006-endpoint-authentication.md) | Endpoint & HTTP trigger authentication (API key / Entra ID) | Finalized | | [0007](0007-multi-agent-delegation.md) | Multi-agent delegation (agent-as-tool) | In review | -| [0009](0009-per-agent-dynamic-workflows.md) | Multi-owner Dynamic Workflow Ownership and Isolation | Finalized | > `_template.md` is the template, not an FRD — the leading underscore keeps it > sorted first and excludes it from numbering. diff --git a/docs/front-matter-spec.md b/docs/front-matter-spec.md index 0b01ec00..7c51c412 100644 --- a/docs/front-matter-spec.md +++ b/docs/front-matter-spec.md @@ -116,7 +116,7 @@ Agent markdown files (`*.agent.md`) can be placed at the app root or in an 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, a workflow owner, or a specialist. +agent is directly invokable, a coordinator, workflow-enabled, or a specialist. ### Agent roles and reachability @@ -127,8 +127,8 @@ Roles come from invocation surfaces and references, not file placement: | 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 owner | Sets `workflows.enabled: true`. | -| Workflow Sub Agent | Is referenced by an owner's `workflows.subagents`. It does not need `workflows.enabled` and 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. @@ -608,8 +608,8 @@ 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 exposes the -owner-allowed public `@workflow_tool` handlers discovered from `tools/*.py` as -workflow task targets. No new owner or starter fields are required; owner +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 @@ -618,8 +618,8 @@ 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 that owner's workflow Activity targets; it does -not affect normal tools or another owner's workflow policy. Conversely, +`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 diff --git a/docs/triggers.md b/docs/triggers.md index f08ad43b..bd849a3d 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -43,13 +43,13 @@ for a runnable example (`tech.agent.md` is one such endpoint-less specialist). 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 owner policy. The runtime schedules the +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. HTTP uses the caller-provided or generated session ID. Non-HTTP invocations generate a fresh -session ID; there is intentionally no owner index for finding those sessions, so +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. @@ -153,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 workflow owner 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 3146fe97..83e55212 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -11,7 +11,7 @@ > [parallel PR report sample](https://github.com/Azure/azure-functions-agents-runtime/blob/main/samples/workflow-subagents-preview/README.md) > 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 owners with independent policies in one app. +> 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. @@ -166,30 +166,31 @@ 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. -Any agent may become a workflow owner by setting `workflows.enabled: true`. +Any agent may enable workflows by setting `workflows.enabled: true`. Invocation remains independent: triggers and built-in endpoints determine how -the owner can be reached, and `debug_chat_ui` automatically enables its backing +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 agents, workflow owners, and internal specialists are identified. +for how direct, workflow-enabled, and internal specialist agents are identified. -### App-wide engine, per-owner policy +### App-wide engine, per-agent policy The app discovers complete, immutable catalogs of workflow handlers and agents. -If at least one workflow owner exists, startup creates one `DFApp` and +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 owner. +app. It does **not** register a separate engine per agent. -An app with no owners remains a plain `FunctionApp` unless the operator enables -[final-owner drain mode](#removing-the-final-workflow-owner). +An app with no workflow-enabled agents remains a plain `FunctionApp` unless the +operator enables +[drain mode](#removing-the-final-workflow-enabled-agent). -Each enabled owner instead gets an immutable policy containing only its allowed +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 owner's policy. One owner's exclusion never -removes a handler another owner is allowed to use. +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 @@ -280,12 +281,13 @@ hardening controls. ### Workflow Sub Agents -The owner grants access in its agent frontmatter 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. @@ -414,7 +416,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 @@ -454,7 +457,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. @@ -488,48 +491,53 @@ tooling for operational monitoring and control. 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 owner index or reconnect API. In all cases the starter +no application-level session index or reconnect API. In all cases the starter returns after the initial model turn; orchestration continues asynchronously. -## Ownership +## Agent and session isolation -Workflow ownership is the pair `(owner_slug, session_id)`. Its Durable instance +Each workflow is isolated by the workflow-enabled agent's canonical slug and the +invocation `session_id`. Internally, Durable payloads call this pair +`(owner_slug, session_id)`; `owner_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 owner **or** session does not match is treated -as nonexistent (404/empty, never 403), so two owners remain isolated even when +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** owner policy. Removing an owner while another owner remains, or +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 owner +### Removing the final workflow-enabled agent -Removing the final owner without retaining the Durable runtime can strand -pending instances: a plain `FunctionApp` has no registered orchestrator or -Activities, so those instances cannot reach owner-policy reauthorization. Use -this two-deployment drain procedure: +Removing the final workflow-enabled agent without retaining the Durable runtime +can strand pending instances. For example, 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 remains +non-terminal in the hub instead. Use this drain procedure: 1. Set `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` while the current workflow deployment is still active. Drain mode removes `start_workflow` from the agent's tool set and defensively rejects direct application-level start calls before Durable scheduling, while list, status, cancel, terminate, orchestrator, and Activity execution remain available. The management tools - can access only workflows started under the same owner and session ID; use + can access only workflows started under the same agent and session ID; use Durable Functions or DTS Task Hub tooling as the authoritative app-wide management surface from the start of the drain. Startup emits a warning and records drain mode in the indexing summary. 2. Stop or quiesce external trigger/chat traffic that could repeatedly ask the agent to start workflows. Direct Durable control-plane starts are privileged operations outside this application guard and must also stop. -3. Remove or disable the final owner if desired, but keep drain mode enabled. - The app remains a `DFApp` with an empty owner-policy catalog, so pending tool - or Sub Agent Activities from removed owners fail closed instead of becoming - stranded. The removed owner's chat tools and +3. Prefer to let existing instances finish before removing or disabling the + final workflow-enabled agent. If removal must happen first, keep drain mode + enabled. The app remains a `DFApp` with an empty agent-policy catalog, so + pending tool or Sub Agent Activities from the removed agent fail closed + instead of remaining queued indefinitely. The removed agent's chat tools and `/agents/{slug}/workflows`/`workflow-status` endpoints no longer exist, so Durable/DTS tooling is now the only complete management surface. 4. Use Durable Functions management tooling or the DTS dashboard to query the @@ -542,8 +550,8 @@ this two-deployment drain procedure: Already-dispatched Activity side effects are not rolled back. Confirm every instance reaches a terminal status. 6. Only after that confirmation, remove - `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`. An app with no owners then - returns to a plain `FunctionApp`. + `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`. An app with no + workflow-enabled agents then returns to a plain `FunctionApp`. If the Task Hub cannot be queried, termination cannot be confirmed, or non-terminal instances remain, keep drain mode and the Durable runtime deployed; @@ -560,17 +568,17 @@ the retained runtime no longer polls. ### Migration from legacy workflow IDs This experimental feature intentionally changes IDs from a session-only 48-bit -prefix to the owner-and-session 128-bit prefix. New agent tools and polling +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 `owner_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 owner authorization. Drain or terminate active +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 owner-policy and handler catalogs from +Each worker reconstructs the immutable agent-policy and handler catalogs from the same deployed agent project during app startup. Orchestrators persist `owner_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 @@ -580,11 +588,11 @@ 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 owner/session prefix in the application. Configure backend retention +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 `(owner_slug, session_id)`; +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 -owner-wide throttle. +agent-wide throttle. ## Observability @@ -614,8 +622,8 @@ owner-wide throttle. v1 includes: - five built-in workflow tools; -- any agent may own workflows, with one app-wide engine and immutable - per-owner policies; +- 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`; @@ -630,6 +638,6 @@ v1 includes: workflows per session, and status-list result count. v2 follow-up work includes sub-orchestrations and bounded nested agents, -configurable caps, retry and timeout policies, HMAC-backed workflow -ownership, blob-offloaded large outputs, an MCP Tasks bridge, richer error -taxonomy, and storage hygiene. +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/samples/README.md b/samples/README.md index 918941cb..c23a68cc 100644 --- a/samples/README.md +++ b/samples/README.md @@ -17,9 +17,9 @@ app deployable with [`azd up`](https://learn.microsoft.com/azure/developer/azure | [secured-endpoints](secured-endpoints/) | HTTP + MCP | | | | | | | [`per-agent-workflows`](per-agent-workflows/) is the Engineering Operations Hub: -two non-main owners share one Durable engine while retaining separate policies. -Run it locally with Azurite and use either owner's browser chat UI to start and -observe an independent workflow. +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/per-agent-workflows/README.md b/samples/per-agent-workflows/README.md index 5453429b..368575af 100644 --- a/samples/per-agent-workflows/README.md +++ b/samples/per-agent-workflows/README.md @@ -7,7 +7,7 @@ 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 owners and their specialist agents. +is still required for the two workflow-enabled agents and their specialists. ## Architecture @@ -23,7 +23,7 @@ flowchart LR D --> RR[Release Risk Reviewer] ``` -There is intentionally no `main.agent.md`. Each owner has a distinct +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. diff --git a/samples/workflow-incident-triage/README.md b/samples/workflow-incident-triage/README.md index c0abd7c7..0c7635a9 100644 --- a/samples/workflow-incident-triage/README.md +++ b/samples/workflow-incident-triage/README.md @@ -150,8 +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 app-wide workflow engine; this sample's owner enables them in -`main.agent.md`. +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/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py index 9ad3f659..74c82fda 100644 --- a/src/azure_functions_agents/workflows/schema.py +++ b/src/azure_functions_agents/workflows/schema.py @@ -264,7 +264,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)}" ) From 016f3f401129c7243abf286df1d84754c0e263a9 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Wed, 12 Aug 2026 20:39:08 -0700 Subject: [PATCH 16/18] refactor: clarify workflow agent identity Rename the internal workflow owner identity and Durable payload contract to workflow_agent_slug across composition, execution, tests, and documentation. Make the E2E verifier isolate Storage runs from local DTS host configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/architecture.md | 74 +++++----- docs/frds/0004-dynamic-workflows.md | 29 ++-- docs/workflows.md | 6 +- eng/scripts/verify_per_agent_workflows.py | 129 ++++++++++++------ src/azure_functions_agents/app.py | 22 +-- src/azure_functions_agents/config/schema.py | 4 +- .../config/validation.py | 2 +- .../registration/_handlers.py | 4 +- .../registration/endpoints.py | 24 ++-- src/azure_functions_agents/runner.py | 18 +-- .../workflows/context.py | 66 ++++----- .../workflows/engine.py | 68 +++++---- .../workflows/integration.py | 43 +++--- .../workflows/registry.py | 2 +- .../workflows/schema.py | 4 +- src/azure_functions_agents/workflows/tools.py | 110 ++++++++------- tests/test_config_fixtures.py | 2 +- tests/test_per_agent_workflows.py | 102 +++++++------- tests/test_per_agent_workflows_sample.py | 2 +- tests/test_per_agent_workflows_verify.py | 35 ++++- tests/test_registration_endpoints.py | 6 +- tests/test_registration_handlers.py | 12 +- tests/test_workflow_engine.py | 67 ++++----- tests/test_workflow_registry.py | 48 +++---- 24 files changed, 497 insertions(+), 382 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 04d778aa..53fd4317 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,11 +19,11 @@ flowchart LR 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
AgentCatalog (immutable)"] - G2 -->|"complete agent inventory"| W["workflows/integration.py
handler catalog + owner-policy catalog
(immutable)"] - W -->|"any owner?"| I["FunctionApp or DFApp"] + 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"] - W -->|"owner policy by slug"| H + 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 @@ -34,7 +34,7 @@ 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 owner-policy catalog. This makes both delegation and per-owner workflow +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: @@ -43,7 +43,7 @@ A few boundaries are worth calling out explicitly: - **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` builds the slug index and validates references, then - freezes the `AgentCatalog`, complete workflow-handler catalog, and per-owner + 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. @@ -53,7 +53,7 @@ A few boundaries are worth calling out explicitly: | Package/module | Role | Key entry points | | --- | --- | --- | -| `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 owner-policy catalog. It chooses `DFApp` when any agent enables workflows or explicit drain mode retains the runtime, registers the workflow runtime once, then registers each agent. | `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 or explicit drain mode retains the runtime, 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` | @@ -76,12 +76,12 @@ 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/integration.py` | Builds the complete immutable handler catalog, immutable slug-keyed owner-policy catalog, per-owner management tools/addenda, validates declared trigger support for enabled owners, and performs the one app-wide Durable registration. | `build_workflow_handler_catalog()`, `build_workflow_owner_policy_catalog()`, `build_owner_workflow_integration()`, `validate_workflow_owner_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 owner policy before complete-catalog dispatch. | `register_workflows()` | -| `azure_functions_agents/workflows/context.py` | Tracks invocation context by `(owner_slug, session_id)` and derives non-revealing 128-bit ownership prefixes for Durable instance IDs. | `session_instance_prefix()`, `new_workflow_instance_id()`, `session_owns_workflow()` | -| `azure_functions_agents/workflows/settings.py` | Parses the explicit workflow drain-mode app setting once during composition, rejecting invalid values. The result is captured in immutable owner policies so request execution cannot drift from the startup decision. | `workflow_drain_mode_enabled()` | +| `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/settings.py` | Parses the explicit workflow drain-mode app setting once during composition, rejecting invalid values. The result is captured in immutable workflow-agent policies so request execution cannot drift from the startup decision. | `workflow_drain_mode_enabled()` | | `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 owner-scoped management tools. Start-time validation and list/status/cancel/terminate operations use the captured owner policy and owner/session identity. | `WorkflowPlanPolicy`, `validate_plan()`, `build_workflow_tools()` | +| `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()` | @@ -112,12 +112,12 @@ When the host imports your app module and calls `create_function_app()`, control 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 enabled owner. -11. `app.py` creates a `DFApp` when the owner-policy catalog is non-empty + `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 owner slug and threading the catalogs + 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 @@ -166,56 +166,56 @@ 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()`, `src/azure_functions_agents/workflows/integration.py:validate_workflow_owner_trigger()` + - **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 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 ownership is independent: any agent may set `workflows.enabled: true`; if it declares a trigger, that trigger must support workflow startup. + - **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 that owner'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. + - **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 app-wide execution and owner-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_owner_policy_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:** immutable `AgentCatalog`, complete `WorkflowHandlerCatalog`, and immutable slug-keyed `WorkflowOwnerPolicyCatalog` - - **Notes:** the handler and Agent catalogs answer what exists app-wide. They do not grant an owner access. Each enabled owner receives a separate `WorkflowPlanPolicy` derived from its filtered workflow tools and independent `workflows.subagents` grants. This closes side-effect-free pass 1. + - **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` and `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` - - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one owner policy exists or drain mode is active, otherwise a plain `FunctionApp`) - - **Notes:** only one app object is created. When policies exist, the complete handler/Agent catalogs and owner policies are captured by one app-level Durable registration before agent registration begins. Drain mode deliberately performs the same registration with an empty policy catalog so instances from a removed final owner reach Activity reauthorization and fail closed; ordinary apps that never use workflows retain the lower-overhead plain `FunctionApp`. Active drain mode is emitted in the indexing summary and as a startup warning. + - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one workflow-agent policy exists or drain mode is active, 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. Drain mode deliberately performs the same registration with an empty policy catalog so instances from a removed final workflow-enabled agent reach Activity reauthorization and fail closed; ordinary apps that never use workflows retain the lower-overhead plain `FunctionApp`. Active drain mode is emitted in the indexing summary and as a startup warning. 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 and `register_builtin_endpoints()` when endpoints are enabled. Each lookup uses the agent slug's owner policy. Eligible trigger/chat API/MCP surfaces receive workflow guidance, owner-scoped tools, and a Durable client binding; debug UI alone is not a starter. Workflow-disabled handlers retain their original 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 each workflow owner, `workflows/integration.py` uses the cataloged immutable -`WorkflowPlanPolicy` to generate model guidance and owner-scoped management +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, owner slug, and policy. +handlers receive the trigger addendum, Durable client, workflow-agent slug, and policy. `start_workflow` validates against that policy. The orchestrator carries -`owner_slug`, and each tool/Sub Agent Activity reauthorizes against the currently +`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. -Ownership is `(owner_slug, session_id)`, encoded in instance IDs as a +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 owners do not share active limits or +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 owner index. +non-HTTP triggers generate an invocation session and no application-wide agent index. ### Registration paths in practice @@ -239,9 +239,9 @@ 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` contributes to that owner's +- `AgentCapabilities.filtered_workflow_tools` contributes to that agent's `WorkflowPlanPolicy`; it does not shrink the complete Activity handler catalog. -- `WorkflowIntegrationResult` supplies owner-scoped management tools and separate +- `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`. @@ -350,10 +350,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` / `WorkflowOwnerPolicyCatalog` — complete immutable - Activity handler inventory plus immutable per-owner authorization policies. +- `WorkflowHandlerCatalog` / `WorkflowAgentPolicyCatalog` — complete immutable + Activity handler inventory plus immutable per-agent authorization policies. Built once after `AgentCatalog`; consumed by one-time Durable registration and - owner-specific endpoint/trigger integration. + 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 @@ -364,7 +364,7 @@ In shorthand, the runtime's startup path is: `Path` --load--> `GlobalConfig` + `list[AgentSpec]` --compose--> `ResolvedAgent` --validate+filter--> `AgentCapabilities` --freeze--> `AgentCatalog` + handler -catalog + owner-policy catalog --choose/register--> `FunctionApp` or `DFApp` +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 ee32375c..2d821820 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -31,7 +31,7 @@ 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-ownership-and-isolation-addendum-pr-151) +[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. @@ -100,7 +100,7 @@ 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`. | +| 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. | @@ -127,7 +127,7 @@ workflows: names out of the effective workflow tool set. - Durable backend and task hub configuration stay in `host.json` and app settings, not frontmatter. -- No separate owner, role, or starter field is required. Invocation remains +- 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 @@ -317,7 +317,7 @@ 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 +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. @@ -416,7 +416,7 @@ 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 ownership and isolation addendum (PR #151) +### 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 @@ -424,9 +424,9 @@ not introduce new frontmatter or DAG syntax: every agent with workflows through whichever triggers or built-in endpoints it independently exposes. -The implementation calls such an agent a workflow *owner* internally because its -slug defines an authorization namespace. Customer documentation uses -*workflow-enabled agent*; `owner_slug` is not an authoring keyword. +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 @@ -447,7 +447,7 @@ unregister a handler another agent may use. `ResolvedAgent.slug` is the stable agent identity on chat, MCP, HTTP-trigger, and non-HTTP-trigger paths. Workflow management is scoped by -`(owner_slug, session_id)` internally. Durable instance IDs begin with a +`(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. @@ -464,7 +464,7 @@ Durable Functions or DTS tooling before upgrading. #### Activity-time reauthorization -Capability-bearing Activities carry `owner_slug` and check the currently +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`; @@ -501,7 +501,7 @@ sequenceDiagram ``` This differs from ordinary policy revocation: the work item cannot reach -`require_owner_policy()` and fail because the Function that executes that check +`require_workflow_agent_policy()` and fail because the Function that executes that check is absent. `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` retains the `DFApp`, orchestrator, and Activities while allowing the current policy catalog to be empty. It also omits `start_workflow` from agent tool sets and defensively @@ -564,8 +564,8 @@ that cross-agent status access returns 404. | 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; call it `owner_slug` only inside the authorization implementation | Agent | 2026-08-10 | -| 26 | Workflow isolation scope | Session only / agent only / agent plus session | Scope application management by `(owner_slug, session_id)` so equal session IDs across agents remain isolated | Human | 2026-08-10 | +| 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 | @@ -579,6 +579,7 @@ that cross-agent status access returns 404. | 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 | ## 6. Test plan @@ -690,7 +691,7 @@ that cross-agent status access returns 404. 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 diff --git a/docs/workflows.md b/docs/workflows.md index 83e55212..d03bf9c5 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -498,7 +498,7 @@ returns after the initial model turn; orchestration continues asynchronously. Each workflow is isolated by the workflow-enabled agent's canonical slug and the invocation `session_id`. Internally, Durable payloads call this pair -`(owner_slug, session_id)`; `owner_slug` is not a frontmatter field. The instance +`(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`, @@ -570,7 +570,7 @@ the retained runtime no longer polls. 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 `owner_slug`, so an in-flight legacy +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 @@ -580,7 +580,7 @@ control any legacy instances that remain. Each worker reconstructs the immutable agent-policy and handler catalogs from the same deployed agent project during app startup. Orchestrators persist -`owner_slug` in their input and pass it to Activities, so an Activity may safely +`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 diff --git a/eng/scripts/verify_per_agent_workflows.py b/eng/scripts/verify_per_agent_workflows.py index 67a4d530..d53161ab 100644 --- a/eng/scripts/verify_per_agent_workflows.py +++ b/eng/scripts/verify_per_agent_workflows.py @@ -1,4 +1,4 @@ -"""Verify both Engineering Operations Hub workflow owners end to end.""" +"""Verify both Engineering Operations Hub workflow-enabled agents end to end.""" from __future__ import annotations @@ -86,7 +86,7 @@ "results, and the whole specialist result. Return the workflow ID without polling." ) -Owner = Literal["incident_commander", "release_manager"] +WorkflowAgent = Literal["incident_commander", "release_manager"] class EmulatorCommands(NamedTuple): @@ -94,7 +94,7 @@ class EmulatorCommands(NamedTuple): dts: list[str] | None -OWNER_EXPECTATIONS: dict[str, dict[str, object]] = { +WORKFLOW_AGENT_EXPECTATIONS: dict[str, dict[str, object]] = { "incident_commander": { "marker": "INCIDENT_REPORT_READY", "report_type": "incident", @@ -236,19 +236,22 @@ def _walk_values(value: object) -> Iterator[tuple[str, object]]: yield from _walk_values(item) -def validate_terminal_result(owner: Owner, envelope: Mapping[str, object]) -> None: - """Validate terminal success, deterministic output, and owner capabilities.""" +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"{owner} workflow ended as {envelope.get('runtime_status')!r}: " + 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"{owner} workflow output has no results object") + raise RuntimeError(f"{workflow_agent} workflow output has no results object") - expected = OWNER_EXPECTATIONS[owner] + expected = WORKFLOW_AGENT_EXPECTATIONS[workflow_agent] marker = expected["marker"] report = next( ( @@ -259,7 +262,9 @@ def validate_terminal_result(owner: Owner, envelope: Mapping[str, object]) -> No None, ) if not isinstance(report, Mapping): - raise RuntimeError(f"{owner} output is missing terminal marker {marker}") + 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"]), @@ -267,10 +272,15 @@ def validate_terminal_result(owner: Owner, envelope: Mapping[str, object]) -> No ("decision", expected["decision"]), ): if report.get(key) != value: - raise RuntimeError(f"{owner} terminal report has invalid {key!r}") + raise RuntimeError( + f"{workflow_agent} terminal report has invalid {key!r}" + ) known = set().union( - *(set(item["allowed"]) for item in OWNER_EXPECTATIONS.values()) # type: ignore[arg-type] + *( + set(item["allowed"]) + for item in WORKFLOW_AGENT_EXPECTATIONS.values() + ) # type: ignore[arg-type] ) used = { value @@ -281,11 +291,14 @@ def validate_terminal_result(owner: Owner, envelope: Mapping[str, object]) -> No unauthorized = used - allowed if unauthorized: raise RuntimeError( - f"{owner} used unauthorized capabilities: {sorted(unauthorized)!r}" + f"{workflow_agent} used unauthorized capabilities: " + f"{sorted(unauthorized)!r}" ) missing = set(expected["required"]) - used # type: ignore[arg-type] if missing: - raise RuntimeError(f"{owner} did not use required capabilities: {sorted(missing)!r}") + raise RuntimeError( + f"{workflow_agent} did not use required capabilities: {sorted(missing)!r}" + ) identity_key = str(expected["identity_key"]) expected_identity = expected["identity"] @@ -301,11 +314,12 @@ def validate_terminal_result(owner: Owner, envelope: Mapping[str, object]) -> No or result.get("service") != "checkout-api" ): raise RuntimeError( - f"{owner} evidence {capability!r} has an invalid identity or service" + f"{workflow_agent} evidence {capability!r} has an invalid " + "identity or service" ) -def validate_owner_list( +def validate_workflow_agent_list( payload: Mapping[str, object], own_workflow_id: str, other_workflow_id: str, @@ -319,9 +333,11 @@ def validate_owner_list( if isinstance(item, Mapping) and isinstance(item.get("workflow_id"), str) } if other_workflow_id in ids: - raise RuntimeError("owner list exposed the other owner's workflow") + raise RuntimeError( + "workflow-agent list exposed another agent's workflow" + ) if own_workflow_id not in ids: - raise RuntimeError("owner list did not include its own workflow") + raise RuntimeError("workflow-agent list did not include its own workflow") def _run( @@ -459,6 +475,24 @@ def build_host_environment() -> dict[str, str]: 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( *, @@ -473,8 +507,7 @@ def _temporary_app( app_dir, ignore=shutil.ignore_patterns(".venv", "local.settings.json", "__pycache__"), ) - if backend == "dts": - shutil.copyfile(app_dir / "host.dts.json", app_dir / "host.json") + prepare_host_config(app_dir, backend) values = _provider_values() values.update({ "FUNCTIONS_WORKER_RUNTIME": "python", @@ -630,31 +663,41 @@ def _request_json( return status, decoded -def _start_owner(host: _FunctionHost, owner: Owner, prompt: str, *, timeout: float) -> str: +def _start_workflow_agent( + host: _FunctionHost, + workflow_agent: WorkflowAgent, + prompt: str, + *, + timeout: float, +) -> str: status, payload = _request_json( "POST", - f"{host.base_url}/agents/{owner}/chat", + f"{host.base_url}/agents/{workflow_agent}/chat", payload={"prompt": prompt}, timeout=timeout, ) if status != 200: - raise RuntimeError(f"{owner} chat returned HTTP {status}: {payload!r}") + raise RuntimeError( + f"{workflow_agent} chat returned HTTP {status}: {payload!r}" + ) try: return extract_workflow_id(payload) except RuntimeError as exc: - raise RuntimeError(f"{owner} chat response had no workflow ID: {payload!r}") from exc + raise RuntimeError( + f"{workflow_agent} chat response had no workflow ID: {payload!r}" + ) from exc -def _poll_owner( +def _poll_workflow_agent( host: _FunctionHost, - owner: Owner, + workflow_agent: WorkflowAgent, workflow_id: str, *, timeout: float, ) -> dict[str, Any]: deadline = time.monotonic() + timeout url = ( - f"{host.base_url}/agents/{owner}/workflow-status?" + f"{host.base_url}/agents/{workflow_agent}/workflow-status?" f"{urlencode({'workflow_id': workflow_id})}" ) last_status = "not observed" @@ -665,43 +708,47 @@ def _poll_owner( if last_status in TERMINAL_STATUSES: return payload elif status != 404: - raise RuntimeError(f"{owner} status route returned HTTP {status}: {payload!r}") + raise RuntimeError( + f"{workflow_agent} status route returned HTTP {status}: {payload!r}" + ) time.sleep(2) raise RuntimeError( - f"{owner} workflow {workflow_id} did not finish within {timeout:.0f}s " + f"{workflow_agent} workflow {workflow_id} did not finish within {timeout:.0f}s " f"(last status: {last_status})" ) def _assert_http_isolation( host: _FunctionHost, - owner: Owner, + workflow_agent: WorkflowAgent, own_id: str, other_id: str, *, timeout: float, ) -> None: status_url = ( - f"{host.base_url}/agents/{owner}/workflow-status?" + 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"{owner} status route exposed the other owner with HTTP {status}" + f"{workflow_agent} status route exposed another agent with HTTP {status}" ) status, payload = _request_json( "GET", - f"{host.base_url}/agents/{owner}/workflows", + f"{host.base_url}/agents/{workflow_agent}/workflows", timeout=timeout, ) if status != 200: - raise RuntimeError(f"{owner} list route returned HTTP {status}: {payload!r}") - validate_owner_list(payload, own_id, other_id) + 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 owners with one session and prove result and route isolation.""" + """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: @@ -746,10 +793,10 @@ def verify(*, backend: str, timeout: float, keep_services: bool) -> None: with _running_host(app_dir, timeout=timeout) as host: print("Starting incident and release workflows with one shared session...") try: - incident_id = _start_owner( + incident_id = _start_workflow_agent( host, "incident_commander", INCIDENT_PROMPT, timeout=timeout ) - release_id = _start_owner( + release_id = _start_workflow_agent( host, "release_manager", RELEASE_PROMPT, timeout=timeout ) except RuntimeError as exc: @@ -757,10 +804,10 @@ def verify(*, backend: str, timeout: float, keep_services: bool) -> None: f"{exc}\nFunctions host output:\n{host.output_tail()[-4000:]}" ) from exc - incident = _poll_owner( + incident = _poll_workflow_agent( host, "incident_commander", incident_id, timeout=timeout ) - release = _poll_owner( + release = _poll_workflow_agent( host, "release_manager", release_id, timeout=timeout ) validate_terminal_result("incident_commander", incident) @@ -791,8 +838,8 @@ def verify(*, backend: str, timeout: float, keep_services: bool) -> None: else "" ) print( - "PASS: both owner workflows completed with isolated capabilities, " - "cross-owner status returned 404, and lists remained private." + "PASS: both workflow-agent workflows completed with isolated " + "capabilities, cross-agent status returned 404, and lists remained private." f"{dashboard}" ) finally: diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index 53b6bb07..16502c6c 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -29,11 +29,11 @@ from .registration.endpoints import register_builtin_endpoints from .registration.triggers import register_agent from .workflows.integration import ( - build_owner_workflow_integration, + build_workflow_agent_integration, + build_workflow_agent_policy_catalog, build_workflow_handler_catalog, - build_workflow_owner_policy_catalog, register_workflow_runtime, - validate_workflow_owner_trigger, + validate_workflow_agent_trigger, ) from .workflows.settings import workflow_drain_mode_enabled @@ -185,7 +185,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_owner_trigger(resolved) + validate_workflow_agent_trigger(resolved) capabilities = build_capabilities( resolved, discovered_user_tools=user_tools, @@ -199,12 +199,12 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog: AgentCatalog = build_catalog(catalog_entries) workflow_handler_catalog = build_workflow_handler_catalog(workflow_tools) workflow_drain_mode = workflow_drain_mode_enabled() - workflow_owner_policies = build_workflow_owner_policy_catalog( + workflow_agent_policies = build_workflow_agent_policy_catalog( catalog, workflow_handler_catalog, starts_allowed=not workflow_drain_mode, ) - workflow_runtime_required = bool(workflow_owner_policies) or workflow_drain_mode + workflow_runtime_required = bool(workflow_agent_policies) or workflow_drain_mode app: func.FunctionApp = ( df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) if workflow_runtime_required @@ -217,13 +217,13 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: app, handler_catalog=workflow_handler_catalog, catalog=catalog, - owner_policies=workflow_owner_policies, + workflow_agent_policies=workflow_agent_policies, ) if workflow_drain_mode: logger.warning( "workflow drain mode active: new application-level workflow starts " - "are disabled; owner_policy_count=%d", - len(workflow_owner_policies), + "are disabled; workflow_agent_policy_count=%d", + len(workflow_agent_policies), ) for resolved in resolved_agents: @@ -232,9 +232,9 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: workflows_enabled = False workflow_system_addendum: str | None = None trigger_workflow_system_addendum: str | None = None - workflow_policy = workflow_owner_policies.get(resolved.slug) + workflow_policy = workflow_agent_policies.get(resolved.slug) if workflow_policy is not None: - workflow_integration = build_owner_workflow_integration( + workflow_integration = build_workflow_agent_integration( workflow_policy, workflow_handler_catalog, ) 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 7c33bddc..74fcca67 100644 --- a/src/azure_functions_agents/config/validation.py +++ b/src/azure_functions_agents/config/validation.py @@ -148,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 080d291f..b1e0b308 100644 --- a/src/azure_functions_agents/registration/_handlers.py +++ b/src/azure_functions_agents/registration/_handlers.py @@ -288,7 +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_owner_slug=resolved.slug, + workflow_agent_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, ) @@ -430,7 +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_owner_slug=resolved.slug, + workflow_agent_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, ) diff --git a/src/azure_functions_agents/registration/endpoints.py b/src/azure_functions_agents/registration/endpoints.py index 38a2eda0..86162260 100644 --- a/src/azure_functions_agents/registration/endpoints.py +++ b/src/azure_functions_agents/registration/endpoints.py @@ -173,7 +173,7 @@ async def _run_builtin_agent( system_addendum=workflow_system_addendum, workflow_enabled=workflows_enabled, workflow_durable_client=durable_client, - workflow_owner_slug=resolved.slug, + workflow_agent_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, subagents=resolved.subagents, @@ -209,7 +209,7 @@ def _run_builtin_agent_stream( system_addendum=workflow_system_addendum, workflow_enabled=workflows_enabled, workflow_durable_client=durable_client, - workflow_owner_slug=resolved.slug, + workflow_agent_slug=resolved.slug, workflow_policy=workflow_policy, agent_name=resolved.slug, # S1b: `_register_http_chat_stream`'s `handle_chat_stream` (unlike @@ -533,7 +533,7 @@ def _register_workflow_status_endpoints( app: func.FunctionApp, *, slug: str, - owner_slug: str, + workflow_agent_slug: str, base_function_name: str, auth: EndpointAuthConfig, ) -> None: @@ -555,9 +555,14 @@ async def list_session_workflows(req: Request, client: str) -> Response: media_type="application/json", ) try: - envelopes = await fetch_session_workflows(client, owner_slug, session_id) + envelopes = await fetch_session_workflows( + client, workflow_agent_slug, session_id + ) except Exception: - logger.exception("workflows list endpoint failed owner=%s", owner_slug) + logger.exception( + "workflows list endpoint failed workflow_agent=%s", + workflow_agent_slug, + ) return Response( json.dumps({"error": "failed to list workflows"}), status_code=500, @@ -591,12 +596,15 @@ async def get_session_workflow_status(req: Request, client: str) -> Response: try: envelope = await fetch_session_workflow_status( client, - owner_slug, + workflow_agent_slug, session_id, workflow_id, ) except Exception: - logger.exception("workflow status endpoint failed owner=%s", owner_slug) + 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, @@ -766,7 +774,7 @@ def register_builtin_endpoints( _register_workflow_status_endpoints( app, slug=slug, - owner_slug=resolved.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 76c4a489..81b1332c 100644 --- a/src/azure_functions_agents/runner.py +++ b/src/azure_functions_agents/runner.py @@ -408,7 +408,7 @@ def _build_role_agent( system_addendum: str | None, workflow_enabled: bool, workflow_durable_client: Any | None, - workflow_owner_slug: str | None = None, + workflow_agent_slug: str | None = None, agent_name: str | None, resolved_id: str | None, history_provider: ContextProvider | None, @@ -445,7 +445,7 @@ def _build_role_agent( resolved_tools.extend( build_workflow_tools( session_id=resolved_id or "", - owner_slug=workflow_owner_slug or agent_name or "main", + 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, @@ -508,7 +508,7 @@ def _build_delegated_agent( system_addendum=None, workflow_enabled=False, workflow_durable_client=None, - workflow_owner_slug=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. @@ -782,7 +782,7 @@ async def _build_agent_session_history( system_addendum: str | None, workflow_enabled: bool, workflow_durable_client: Any | None, - workflow_owner_slug: str | None, + workflow_agent_slug: str | None, agent_name: str | None, web_request_tools: list[Any] | None = None, subagents: list[SubagentRef] | None = None, @@ -841,7 +841,7 @@ async def _build_agent_session_history( system_addendum=system_addendum, workflow_enabled=workflow_enabled, workflow_durable_client=workflow_durable_client, - workflow_owner_slug=workflow_owner_slug, + workflow_agent_slug=workflow_agent_slug, agent_name=agent_name, resolved_id=resolved_id, history_provider=history_provider, @@ -928,7 +928,7 @@ async def run_agent( system_addendum: str | None = None, workflow_enabled: bool = False, workflow_durable_client: Any | None = None, - workflow_owner_slug: str | 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, @@ -1013,7 +1013,7 @@ async def run_agent( system_addendum=system_addendum, workflow_enabled=workflow_enabled, workflow_durable_client=workflow_durable_client, - workflow_owner_slug=workflow_owner_slug, + workflow_agent_slug=workflow_agent_slug, agent_name=agent_name, web_request_tools=web_request_tools, subagents=subagents, @@ -1122,7 +1122,7 @@ async def run_agent_stream( system_addendum: str | None = None, workflow_enabled: bool = False, workflow_durable_client: Any | None = None, - workflow_owner_slug: str | 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, @@ -1190,7 +1190,7 @@ async def run_agent_stream( system_addendum=system_addendum, workflow_enabled=workflow_enabled, workflow_durable_client=workflow_durable_client, - workflow_owner_slug=workflow_owner_slug, + 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 3308a063..ff1f2490 100644 --- a/src/azure_functions_agents/workflows/context.py +++ b/src/azure_functions_agents/workflows/context.py @@ -1,4 +1,4 @@ -"""Per-owner-session workflow context registry + instance-ID ownership scheme. +"""Per-workflow-agent-session context registry and instance-ID isolation scheme. Two concerns live here: @@ -7,11 +7,11 @@ original helper API. Its registration token is private bookkeeping and is not part of workflow session state. -2. **Instance-ID ownership.** Every workflow started via +2. **Instance-ID isolation.** Every workflow started via ``start_workflow`` receives an instance ID whose leading - :data:`OWNER_SESSION_PREFIX_LEN` hex characters are a SHA-256 prefix - over the owner slug and 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 @@ -27,45 +27,45 @@ from azure.durable_functions import DurableOrchestrationClient -OWNER_SESSION_PREFIX_LEN = 32 +AGENT_SESSION_PREFIX_LEN = 32 # Compatibility alias retained for callers that imported the original constant. -# Its value follows the current owner/session format, not the legacy 12-hex format. -SESSION_PREFIX_LEN = OWNER_SESSION_PREFIX_LEN +# 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(owner_slug: str, session_id: str) -> str: - """Return the fixed-length owner/session prefix embedded in workflow IDs. +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 owner/session prefix is treated as nonexistent. + calling workflow-agent/session prefix is treated as nonexistent. Hashing keeps the raw ``session_id`` out of Durable-visible metadata. """ digest = hashlib.sha256() - for value in (owner_slug, session_id): + 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()[:OWNER_SESSION_PREFIX_LEN] + return digest.hexdigest()[:AGENT_SESSION_PREFIX_LEN] -def new_workflow_instance_id(owner_slug: str, session_id: str) -> str: - """Generate a fresh workflow instance ID for an owner/session pair. +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: ``{32-hex-owner-session-hash}-{32-hex-uuid}``. + Shape: ``{32-hex-agent-session-hash}-{32-hex-uuid}``. """ - return f"{session_instance_prefix(owner_slug, session_id)}-{uuid.uuid4().hex}" + return f"{session_instance_prefix(workflow_agent_slug, session_id)}-{uuid.uuid4().hex}" -def session_owns_workflow( - owner_slug: str, +def workflow_matches_agent_session( + workflow_agent_slug: str, session_id: str, workflow_id: str, ) -> bool: - if not owner_slug or not session_id or not workflow_id: + if not workflow_agent_slug or not session_id or not workflow_id: return False return workflow_id.startswith( - session_instance_prefix(owner_slug, session_id) + "-" + session_instance_prefix(workflow_agent_slug, session_id) + "-" ) @@ -73,7 +73,7 @@ def session_owns_workflow( class WorkflowSessionContext: """Per-in-flight-request state needed by workflow tools.""" - owner_slug: str + workflow_agent_slug: str session_id: str agent_name: str durable_client: DurableOrchestrationClient @@ -90,7 +90,7 @@ class _WorkflowSessionRegistration: def register_workflow_session( - owner_slug: str, + workflow_agent_slug: str, session_id: str, agent_name: str, durable_client: DurableOrchestrationClient, @@ -102,13 +102,13 @@ def register_workflow_session( """ token = uuid.uuid4().hex context = WorkflowSessionContext( - owner_slug=owner_slug, + workflow_agent_slug=workflow_agent_slug, session_id=session_id, agent_name=agent_name, durable_client=durable_client, ) with _lock: - _registry[(owner_slug, session_id)] = _WorkflowSessionRegistration( + _registry[(workflow_agent_slug, session_id)] = _WorkflowSessionRegistration( context=context, token=token, ) @@ -116,7 +116,7 @@ def register_workflow_session( def unregister_workflow_session( - owner_slug: str, + workflow_agent_slug: str, session_id: str, token: str, ) -> None: @@ -126,31 +126,31 @@ def unregister_workflow_session( already replaced our slot — in both cases this is a no-op. """ with _lock: - key = (owner_slug, session_id) + key = (workflow_agent_slug, session_id) existing = _registry.get(key) if existing is not None and existing.token == token: _registry.pop(key, None) def get_workflow_session( - owner_slug: str | None, + workflow_agent_slug: str | None, session_id: str | None, ) -> WorkflowSessionContext | None: - if not owner_slug or not session_id: + if not workflow_agent_slug or not session_id: return None with _lock: - registration = _registry.get((owner_slug, session_id)) + registration = _registry.get((workflow_agent_slug, session_id)) return registration.context if registration is not None else None __all__ = [ - "OWNER_SESSION_PREFIX_LEN", + "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 9e13fa87..33705ab7 100644 --- a/src/azure_functions_agents/workflows/engine.py +++ b/src/azure_functions_agents/workflows/engine.py @@ -57,7 +57,7 @@ class _ActivityInputBase(TypedDict): id: str - owner_slug: str + workflow_agent_slug: str workflow_id: str @@ -125,7 +125,7 @@ def register_workflows( *, catalog: AgentCatalog | None = None, handler_catalog: registry.WorkflowHandlerCatalog | None = None, - owner_policies: Mapping[str, WorkflowPlanPolicy] | None = None, + workflow_agent_policies: Mapping[str, WorkflowPlanPolicy] | None = None, ) -> None: """Register the workflow orchestrator + activities on ``app``. @@ -135,34 +135,42 @@ def register_workflows( """ bp = df.Blueprint() - def require_owner_policy(task: _ActivityInput) -> tuple[str, WorkflowPlanPolicy]: - owner_slug = task["owner_slug"] - policy = owner_policies.get(owner_slug) if owner_policies is not None else None - if not owner_slug or policy is None: + 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 owner policy miss: workflow_id=%s node_id=%s owner=%s", + "workflow activity agent policy miss: " + "workflow_id=%s node_id=%s workflow_agent=%s", task["workflow_id"], task["id"], - owner_slug or "", + workflow_agent_slug or "", ) raise RuntimeError( - f"task {task['id']!r}: workflow owner policy is not available" + f"task {task['id']!r}: workflow agent policy is not available" ) - return owner_slug, policy + return workflow_agent_slug, policy @bp.activity_trigger(input_name="task") # type: ignore[untyped-decorator] def agents_workflow_run_tool(task: _ToolActivityInput) -> dict[str, Any]: task_id = task["id"] tool_name = task["tool"] args = task["args"] - owner_slug, policy = require_owner_policy(task) + 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 owner=%s tool=%s", + "workflow tool authorization denied: " + "workflow_id=%s node_id=%s workflow_agent=%s tool=%s", workflow_id, task_id, - owner_slug, + workflow_agent_slug, tool_name, ) raise RuntimeError( @@ -179,9 +187,10 @@ def agents_workflow_run_tool(task: _ToolActivityInput) -> dict[str, Any]: "in the workflow-safe tool registry" ) logger.info( - "workflow activity running: workflow_id=%s owner=%s id=%s tool=%s", + "workflow activity running: " + "workflow_id=%s workflow_agent=%s id=%s tool=%s", workflow_id, - owner_slug, + workflow_agent_slug, task_id, tool_name, ) @@ -189,9 +198,10 @@ def agents_workflow_run_tool(task: _ToolActivityInput) -> dict[str, Any]: result = entry.handler(args) except Exception: logger.exception( - "workflow activity failed: workflow_id=%s owner=%s id=%s tool=%s", + "workflow activity failed: " + "workflow_id=%s workflow_agent=%s id=%s tool=%s", workflow_id, - owner_slug, + workflow_agent_slug, task_id, tool_name, ) @@ -212,14 +222,14 @@ async def agents_workflow_run_sub_agent( task_id = task["id"] agent_slug = task["agent"] workflow_id = task["workflow_id"] - owner_slug, policy = require_owner_policy(task) + 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 owner=%s agent=%s", + "workflow_id=%s node_id=%s workflow_agent=%s agent=%s", workflow_id, task_id, - owner_slug, + workflow_agent_slug, agent_slug, ) raise RuntimeError( @@ -228,10 +238,10 @@ async def agents_workflow_run_sub_agent( if catalog is None or agent_slug not in catalog: logger.error( "workflow sub-agent catalog miss: " - "workflow_id=%s node_id=%s owner=%s agent=%s", + "workflow_id=%s node_id=%s workflow_agent=%s agent=%s", workflow_id, task_id, - owner_slug, + workflow_agent_slug, agent_slug, ) raise RuntimeError( @@ -241,10 +251,10 @@ async def agents_workflow_run_sub_agent( entry = catalog[agent_slug] logger.info( "workflow sub-agent activity running: " - "workflow_id=%s node_id=%s owner=%s agent=%s", + "workflow_id=%s node_id=%s workflow_agent=%s agent=%s", workflow_id, task_id, - owner_slug, + workflow_agent_slug, agent_slug, ) try: @@ -310,7 +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 []) - owner_slug = str(payload.get("owner_slug") 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]] = { @@ -367,7 +377,7 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any "id": tid, "tool": task["tool"], "args": resolved_args, - "owner_slug": owner_slug, + "workflow_agent_slug": workflow_agent_slug, "workflow_id": context.instance_id, }, ) @@ -392,7 +402,7 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any "agent": task["agent"], "task": resolved_task, "workflow_id": context.instance_id, - "owner_slug": owner_slug, + "workflow_agent_slug": workflow_agent_slug, }, ) ) @@ -431,9 +441,9 @@ def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any f"canceled at {len(results)}/{total} tasks done" ) logger.info( - "workflow canceled: instance=%s owner=%s reason=%r", + "workflow canceled: instance=%s workflow_agent=%s reason=%r", context.instance_id, - owner_slug, + workflow_agent_slug, reason, ) return { diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index 39550676..3bfa9285 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -9,8 +9,9 @@ when to reach for a workflow and which tools the workflow can call. The app factory builds one complete handler catalog and one immutable -owner-policy catalog, registers the Durable engine once, then builds each -owner's tools and addenda without mutating the app. ``build_workflow_integration`` +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. """ @@ -37,7 +38,7 @@ from .schema import WorkflowPlanPolicy from .tools import build_workflow_tools -type WorkflowOwnerPolicyCatalog = Mapping[str, WorkflowPlanPolicy] +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``) @@ -446,8 +447,8 @@ def _build_plan_policy( ) -def validate_workflow_owner_trigger(resolved: ResolvedAgent) -> None: - """Reject unsupported declared triggers for a workflow-enabled owner.""" +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 @@ -463,15 +464,15 @@ def validate_workflow_owner_trigger(resolved: ResolvedAgent) -> None: ) -def build_workflow_owner_policy_catalog( +def build_workflow_agent_policy_catalog( catalog: AgentCatalog, handler_catalog: registry.WorkflowHandlerCatalog, *, starts_allowed: bool = True, -) -> WorkflowOwnerPolicyCatalog: - """Freeze one independent workflow policy per enabled owner.""" +) -> WorkflowAgentPolicyCatalog: + """Freeze one independent workflow policy per workflow-enabled agent.""" policies: dict[str, WorkflowPlanPolicy] = {} - for owner_slug, entry in catalog.items(): + for workflow_agent_slug, entry in catalog.items(): resolved = entry.resolved if resolved.workflows is None or not resolved.workflows.enabled: continue @@ -483,7 +484,7 @@ def build_workflow_owner_policy_catalog( and handler.public ) ) - policies[owner_slug] = _build_plan_policy( + policies[workflow_agent_slug] = _build_plan_policy( allowed_tools, resolved.workflows.subagents, catalog, @@ -492,11 +493,11 @@ def build_workflow_owner_policy_catalog( return MappingProxyType(policies) -def build_owner_workflow_integration( +def build_workflow_agent_integration( policy: WorkflowPlanPolicy, handler_catalog: registry.WorkflowHandlerCatalog, ) -> WorkflowIntegrationResult: - """Build one owner's tools and prompt guidance without app mutation.""" + """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( @@ -518,14 +519,14 @@ def register_workflow_runtime( *, handler_catalog: registry.WorkflowHandlerCatalog, catalog: AgentCatalog, - owner_policies: WorkflowOwnerPolicyCatalog, + workflow_agent_policies: WorkflowAgentPolicyCatalog, ) -> None: """Register the app-wide Durable engine exactly once.""" register_workflows( app, catalog=catalog, handler_catalog=handler_catalog, - owner_policies=owner_policies, + workflow_agent_policies=workflow_agent_policies, ) @@ -537,7 +538,7 @@ def build_workflow_integration( workflow_subagents: Sequence[WorkflowSubagentRef] = (), catalog: AgentCatalog | None = None, ) -> WorkflowIntegrationResult: - """Compatibility helper that enables one owner's workflows on ``app``. + """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 @@ -564,7 +565,7 @@ def build_workflow_integration( app, catalog=catalog, handler_catalog=handler_catalog, - owner_policies=MappingProxyType({"main": policy}), + workflow_agent_policies=MappingProxyType({"main": policy}), ) registry.set_app_config(effective) logger.info( @@ -573,16 +574,16 @@ def build_workflow_integration( len(policy.allowed_subagents), ", ".join(sorted(effective)) or "", ) - return build_owner_workflow_integration(policy, handler_catalog) + return build_workflow_agent_integration(policy, handler_catalog) __all__ = [ + "WorkflowAgentPolicyCatalog", "WorkflowIntegrationResult", - "WorkflowOwnerPolicyCatalog", - "build_owner_workflow_integration", + "build_workflow_agent_integration", + "build_workflow_agent_policy_catalog", "build_workflow_handler_catalog", "build_workflow_integration", - "build_workflow_owner_policy_catalog", "register_workflow_runtime", - "validate_workflow_owner_trigger", + "validate_workflow_agent_trigger", ] diff --git a/src/azure_functions_agents/workflows/registry.py b/src/azure_functions_agents/workflows/registry.py index a70da8da..92ed3fca 100644 --- a/src/azure_functions_agents/workflows/registry.py +++ b/src/azure_functions_agents/workflows/registry.py @@ -14,7 +14,7 @@ Internal helpers like ``__echo`` are registered with ``public=False`` so they don't leak into agent-visible plans by accident. - **Effective allowlist**: retained only as a compatibility fallback for direct - helper callers. Production app construction passes an explicit owner policy. + 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 diff --git a/src/azure_functions_agents/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py index 74c82fda..37f02214 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] @@ -119,7 +119,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. diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index dc678fb2..aab27864 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -10,9 +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 a -128-bit SHA-256 prefix for ``(owner_slug, session_id)``; a mismatch returns -404 (same shape as "not found") to avoid leaking another owner's 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 @@ -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 ( @@ -230,10 +230,10 @@ def _is_active_status(status: Any) -> bool: async def fetch_session_workflows( durable_client: DurableOrchestrationClient, - owner_slug: str, + 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 @@ -245,8 +245,8 @@ 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( - owner_slug, 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)) @@ -259,7 +259,7 @@ async def fetch_session_workflows( async def count_active_session_workflows( durable_client: DurableOrchestrationClient, - owner_slug: str, + workflow_agent_slug: str, session_id: str, ) -> int: statuses = await durable_client.get_status_all() @@ -268,7 +268,9 @@ async def count_active_session_workflows( instance_id = getattr(status, "instance_id", None) if ( instance_id - and session_owns_workflow(owner_slug, session_id, instance_id) + and workflow_matches_agent_session( + workflow_agent_slug, session_id, instance_id + ) and _is_active_status(status) ): active += 1 @@ -279,14 +281,15 @@ async def count_active_session_workflows( async def fetch_session_workflow_status( durable_client: DurableOrchestrationClient, - owner_slug: str, + 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(owner_slug, 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) @@ -381,26 +384,27 @@ async def start_workflow( except PlanValidationError as exc: return _error(str(exc)) - owner = { - "owner_slug": session.owner_slug, + 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.owner_slug, + session.workflow_agent_slug, session.session_id, ) try: active_count = await count_active_session_workflows( session.durable_client, - session.owner_slug, + session.workflow_agent_slug, session.session_id, ) except Exception: logger.exception( - "start_workflow: client.get_status_all failed owner=%s session=%s", - session.owner_slug, + "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") @@ -417,14 +421,14 @@ async def start_workflow( instance_id=instance_id, client_input={ "tasks": plan_to_activity_inputs(plan), - "owner_slug": session.owner_slug, - "owner": owner, + "workflow_agent_slug": session.workflow_agent_slug, + "workflow_agent": workflow_agent, }, ) except Exception: logger.exception( - "start_workflow: client.start_new failed owner=%s session=%s", - session.owner_slug, + "start_workflow: client.start_new failed workflow_agent=%s session=%s", + session.workflow_agent_slug, session.session_id, ) return _error("failed to start workflow") @@ -438,9 +442,9 @@ async def start_workflow( instance_id, ) logger.info( - "workflow started: id=%s owner=%s session=%s", + "workflow started: id=%s workflow_agent=%s session=%s", instance_id, - session.owner_slug, + session.workflow_agent_slug, session.session_id, ) return json.dumps({"workflow_id": instance_id}) @@ -453,11 +457,11 @@ 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.owner_slug, + if not workflow_matches_agent_session( + session.workflow_agent_slug, session.session_id, params.workflow_id, ): @@ -470,8 +474,9 @@ async def get_workflow_status( status = await session.durable_client.get_status(params.workflow_id) except Exception: logger.exception( - "get_workflow_status: client.get_status failed owner=%s session=%s", - session.owner_slug, + "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") @@ -495,13 +500,14 @@ async def list_workflows( try: envelopes = await fetch_session_workflows( session.durable_client, - session.owner_slug, + session.workflow_agent_slug, session.session_id, ) except Exception: logger.exception( - "list_workflows: fetch_session_workflows failed owner=%s session=%s", - session.owner_slug, + "list_workflows: fetch_session_workflows failed " + "workflow_agent=%s session=%s", + session.workflow_agent_slug, session.session_id, ) return _error("failed to list workflows") @@ -516,8 +522,8 @@ async def terminate_workflow( if session is None: return _error(_NO_CLIENT_MESSAGE) - if not session_owns_workflow( - session.owner_slug, + if not workflow_matches_agent_session( + session.workflow_agent_slug, session.session_id, params.workflow_id, ): @@ -530,16 +536,17 @@ async def terminate_workflow( await session.durable_client.terminate(params.workflow_id, params.reason) except Exception: logger.exception( - "terminate_workflow: client.terminate failed owner=%s session=%s", - session.owner_slug, + "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 owner=%s session=%s reason=%r", + "workflow terminated: id=%s workflow_agent=%s session=%s reason=%r", params.workflow_id, - session.owner_slug, + session.workflow_agent_slug, session.session_id, params.reason, ) @@ -553,8 +560,8 @@ async def cancel_workflow( if session is None: return _error(_NO_CLIENT_MESSAGE) - if not session_owns_workflow( - session.owner_slug, + if not workflow_matches_agent_session( + session.workflow_agent_slug, session.session_id, params.workflow_id, ): @@ -569,16 +576,17 @@ async def cancel_workflow( ) except Exception: logger.exception( - "cancel_workflow: client.raise_event failed owner=%s session=%s", - session.owner_slug, + "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 owner=%s session=%s reason=%r", + "workflow cancel requested: id=%s workflow_agent=%s session=%s reason=%r", params.workflow_id, - session.owner_slug, + session.workflow_agent_slug, session.session_id, params.reason, ) @@ -588,7 +596,7 @@ async def cancel_workflow( def _build_session( - owner_slug: str, + workflow_agent_slug: str, session_id: str | None, agent_name: str, durable_client: DurableOrchestrationClient | None, @@ -596,7 +604,7 @@ def _build_session( if not session_id or durable_client is None: return None return WorkflowSessionContext( - owner_slug=owner_slug, + workflow_agent_slug=workflow_agent_slug, session_id=session_id, agent_name=agent_name, durable_client=durable_client, @@ -606,13 +614,15 @@ def _build_session( def build_workflow_tools( *, session_id: str | None = None, - owner_slug: str = "main", + workflow_agent_slug: str = "main", agent_name: str = "main", 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(owner_slug, 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/test_config_fixtures.py b/tests/test_config_fixtures.py index 3bdb6265..57e57bb4 100644 --- a/tests/test_config_fixtures.py +++ b/tests/test_config_fixtures.py @@ -762,7 +762,7 @@ def test_dynamic_workflow_subagents_fixture() -> None: # --------------------------------------------------------------------------- -# 18 — multiple workflow owners with distinct policies +# 18 — multiple workflow-enabled agents with distinct policies # --------------------------------------------------------------------------- diff --git a/tests/test_per_agent_workflows.py b/tests/test_per_agent_workflows.py index 8846021d..f299f5e7 100644 --- a/tests/test_per_agent_workflows.py +++ b/tests/test_per_agent_workflows.py @@ -41,7 +41,7 @@ def _registered_function(app: Any, name: str) -> Any: raise AssertionError(f"function {name!r} was not registered") -def test_non_main_workflow_owner_without_main_creates_dfapp(tmp_path) -> None: +def test_non_main_workflow_agent_without_main_creates_dfapp(tmp_path) -> None: _write_agent( tmp_path, "incident.agent.md", @@ -84,7 +84,7 @@ def test_non_workflow_app_remains_plain_function_app(tmp_path) -> None: assert engine.ORCHESTRATOR_NAME not in _function_names(app) -def test_drain_mode_retains_runtime_with_no_workflow_owners( +def test_drain_mode_retains_runtime_with_no_workflow_agents( tmp_path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -96,7 +96,7 @@ def test_drain_mode_retains_runtime_with_no_workflow_owners( "assistant.agent.md", """ name: Assistant -description: Handles chat after the final workflow owner was removed. +description: Handles chat after the final workflow-enabled agent was removed. builtin_endpoints: chat_api: true """, @@ -110,13 +110,13 @@ def test_drain_mode_retains_runtime_with_no_workflow_owners( assert names.count("agents_workflow_run_tool") == 1 assert names.count(engine.SUB_AGENT_ACTIVITY_NAME) == 1 activity = _registered_function(app, "agents_workflow_run_tool") - with pytest.raises(RuntimeError, match="owner policy"): + with pytest.raises(RuntimeError, match="agent policy"): activity( { "id": "pending", "tool": "removed_tool", "args": {}, - "owner_slug": "removed_owner", + "workflow_agent_slug": "removed_agent", "workflow_id": "workflow-1", } ) @@ -130,7 +130,7 @@ def test_drain_mode_disables_starts_for_existing_owner( ) -> None: monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "true") captured: dict[str, schema.WorkflowPlanPolicy] = {} - original_builder = integration.build_workflow_owner_policy_catalog + original_builder = integration.build_workflow_agent_policy_catalog def capture_policies(catalog, handler_catalog, *, starts_allowed=True): policies = original_builder( @@ -142,7 +142,7 @@ def capture_policies(catalog, handler_catalog, *, starts_allowed=True): return policies monkeypatch.setattr( - "azure_functions_agents.app.build_workflow_owner_policy_catalog", + "azure_functions_agents.app.build_workflow_agent_policy_catalog", capture_policies, ) _write_agent( @@ -164,18 +164,18 @@ def capture_policies(catalog, handler_catalog, *, starts_allowed=True): assert "agent_incident_builtin_chat" in _function_names(app) policy = captured["incident"] assert not policy.starts_allowed - owner_integration = integration.build_owner_workflow_integration( + agent_integration = integration.build_workflow_agent_integration( policy, MappingProxyType({}), ) - assert {tool.name for tool in owner_integration.workflow_tools} == { + assert {tool.name for tool in agent_integration.workflow_tools} == { "get_workflow_status", "list_workflows", "cancel_workflow", "terminate_workflow", } - assert "Workflow drain mode" in owner_integration.chat_system_addendum - assert "" in owner_integration.chat_system_addendum + assert "Workflow drain mode" in agent_integration.chat_system_addendum + assert "" in agent_integration.chat_system_addendum def test_invalid_drain_mode_fails_startup( @@ -202,7 +202,7 @@ def test_invalid_drain_mode_fails_startup( @pytest.mark.parametrize("chat_api", ['"true"', "1"]) -def test_workflow_owner_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None: +def test_workflow_agent_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None: _write_agent( tmp_path, "incident.agent.md", @@ -222,7 +222,7 @@ def test_workflow_owner_accepts_coercible_chat_api(tmp_path, chat_api: str) -> N assert "agent_incident_builtin_chat" in _function_names(app) -def test_multiple_workflow_owners_register_one_durable_blueprint(tmp_path) -> None: +def test_multiple_workflow_agents_register_one_durable_blueprint(tmp_path) -> None: for slug in ("incident", "release"): _write_agent( tmp_path, @@ -277,7 +277,7 @@ def test_shared_workflow_subagent_registers_one_durable_activity(tmp_path) -> No assert _function_names(app).count(engine.SUB_AGENT_ACTIVITY_NAME) == 1 -def test_mcp_only_workflow_owner_is_supported(tmp_path) -> None: +def test_mcp_only_workflow_agent_is_supported(tmp_path) -> None: _write_agent( tmp_path, "mcp_owner.agent.md", @@ -299,13 +299,13 @@ def test_mcp_only_workflow_owner_is_supported(tmp_path) -> None: assert names.count(engine.ORCHESTRATOR_NAME) == 1 -def test_unknown_trigger_workflow_owner_fails_composition(tmp_path) -> None: +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 owner. +description: Must not create an inert workflow-enabled agent. trigger: type: imaginary_trigger workflows: @@ -317,7 +317,7 @@ def test_unknown_trigger_workflow_owner_fails_composition(tmp_path) -> None: create_function_app(tmp_path) -def test_workflow_owner_preserves_actionable_trigger_alias_diagnostic(tmp_path) -> None: +def test_workflow_agent_preserves_actionable_trigger_alias_diagnostic(tmp_path) -> None: _write_agent( tmp_path, "route.agent.md", @@ -335,7 +335,7 @@ def test_workflow_owner_preserves_actionable_trigger_alias_diagnostic(tmp_path) create_function_app(tmp_path) -def test_callable_non_trigger_decorator_fails_workflow_owner_composition(tmp_path) -> None: +def test_callable_non_trigger_decorator_fails_workflow_agent_composition(tmp_path) -> None: _write_agent( tmp_path, "binding.agent.md", @@ -359,7 +359,7 @@ def test_internal_agent_can_enable_workflows_when_referenced_as_subagent(tmp_pat "coordinator.agent.md", """ name: Coordinator -description: Invokes the internal owner. +description: Invokes the internal workflow agent. builtin_endpoints: chat_api: true subagents: @@ -419,13 +419,13 @@ def _resolved( return resolved, AgentCapabilities(filtered_workflow_tools=workflow_tools) -def test_owner_policy_catalog_is_immutable_and_keeps_owner_grants_independent() -> None: +def test_agent_policy_catalog_is_immutable_and_keeps_agent_grants_independent() -> None: owner_a, capabilities_a = _resolved( - "owner_a", + "agent_a", tools_enabled=("shared",), subagents=("specialist_a",), ) - owner_b, capabilities_b = _resolved( + agent_b, capabilities_b = _resolved( "owner_b", tools_enabled=("shared", "only_b"), subagents=("specialist_b",), @@ -437,7 +437,7 @@ def test_owner_policy_catalog_is_immutable_and_keeps_owner_grants_independent() catalog = build_catalog( { "owner_a": CatalogEntry(owner_a, capabilities_a), - "owner_b": CatalogEntry(owner_b, capabilities_b), + "owner_b": CatalogEntry( agent_b, capabilities_b), "specialist_a": CatalogEntry(specialist_a, specialist_capabilities_a), "specialist_b": CatalogEntry(specialist_b, specialist_capabilities_b), } @@ -449,7 +449,7 @@ def test_owner_policy_catalog_is_immutable_and_keeps_owner_grants_independent() ] ) - policies = integration.build_workflow_owner_policy_catalog(catalog, handlers) + policies = integration.build_workflow_agent_policy_catalog(catalog, handlers) assert isinstance(policies, MappingProxyType) assert policies["owner_a"].allowed_tools == frozenset({"shared"}) @@ -495,20 +495,20 @@ def test_owner_addenda_render_only_owner_specific_tools_and_subagents() -> None: WorkflowTool("tool_b", "Tool B", lambda args: args), ] ) - policies = integration.build_workflow_owner_policy_catalog(catalog, handlers) + policies = integration.build_workflow_agent_policy_catalog(catalog, handlers) - owner_a_integration = integration.build_owner_workflow_integration( + agent_a_integration = integration.build_workflow_agent_integration( policies["owner_a"], handlers, ) - owner_b_integration = integration.build_owner_workflow_integration( + agent_b_integration = integration.build_workflow_agent_integration( policies["owner_b"], handlers, ) for addendum in ( - owner_a_integration.chat_system_addendum, - owner_a_integration.trigger_system_addendum, + agent_a_integration.chat_system_addendum, + agent_a_integration.trigger_system_addendum, ): assert addendum is not None assert "`tool_a`" in addendum @@ -516,8 +516,8 @@ def test_owner_addenda_render_only_owner_specific_tools_and_subagents() -> None: assert "`tool_b`" not in addendum assert "`specialist_b`" not in addendum for addendum in ( - owner_b_integration.chat_system_addendum, - owner_b_integration.trigger_system_addendum, + agent_b_integration.chat_system_addendum, + agent_b_integration.trigger_system_addendum, ): assert addendum is not None assert "`tool_b`" in addendum @@ -526,9 +526,9 @@ def test_owner_addenda_render_only_owner_specific_tools_and_subagents() -> None: assert "`specialist_a`" not in addendum -def test_owner_and_session_identity_uses_distinct_128_bit_prefixes() -> None: - first = context.new_workflow_instance_id("owner_a", "same-session") - second = context.new_workflow_instance_id("owner_b", "same-session") +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] @@ -538,9 +538,9 @@ def test_owner_and_session_identity_uses_distinct_128_bit_prefixes() -> None: assert context.session_instance_prefix("a", "bc") != context.session_instance_prefix( "ab", "c" ) - assert context.session_owns_workflow("owner_a", "same-session", first) - assert not context.session_owns_workflow("owner_b", "same-session", first) - assert not context.session_owns_workflow( + 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", @@ -578,31 +578,31 @@ def __init__(self, instance_id: str) -> None: @pytest.mark.asyncio -async def test_same_session_cross_owner_management_is_not_found() -> None: - workflow_id = context.new_workflow_instance_id("owner_a", "same-session") +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)]) - owner_b = context.WorkflowSessionContext( - owner_slug="owner_b", + agent_b = context.WorkflowSessionContext( + workflow_agent_slug="agent_b", session_id="same-session", - agent_name="Owner B", + agent_name="Agent B", durable_client=client, ) - assert await tools.fetch_session_workflows(client, "owner_b", "same-session") == [] + assert await tools.fetch_session_workflows(client, "agent_b", "same-session") == [] assert ( await tools.fetch_session_workflow_status( - client, "owner_b", "same-session", workflow_id + client, "agent_b", "same-session", workflow_id ) is None ) status = await tools.get_workflow_status( - tools.GetWorkflowStatusParams(workflow_id=workflow_id), owner_b + tools.GetWorkflowStatusParams(workflow_id=workflow_id), agent_b ) cancel = await tools.cancel_workflow( - tools.CancelWorkflowParams(workflow_id=workflow_id), owner_b + tools.CancelWorkflowParams(workflow_id=workflow_id), agent_b ) terminate = await tools.terminate_workflow( - tools.TerminateWorkflowParams(workflow_id=workflow_id), owner_b + tools.TerminateWorkflowParams(workflow_id=workflow_id), agent_b ) assert '"status": 404' in status @@ -613,15 +613,15 @@ async def test_same_session_cross_owner_management_is_not_found() -> None: @pytest.mark.asyncio -async def test_active_count_is_isolated_by_owner_under_shared_session() -> None: +async def test_active_count_is_isolated_by_agent_under_shared_session() -> None: client = _StatusClient( - [_Status(context.new_workflow_instance_id("owner_a", "same-session"))] + [_Status(context.new_workflow_instance_id("agent_a", "same-session"))] ) assert ( await tools.count_active_session_workflows( client, - "owner_a", + "agent_a", "same-session", ) == 1 @@ -629,7 +629,7 @@ async def test_active_count_is_isolated_by_owner_under_shared_session() -> None: assert ( await tools.count_active_session_workflows( client, - "owner_b", + "agent_b", "same-session", ) == 0 diff --git a/tests/test_per_agent_workflows_sample.py b/tests/test_per_agent_workflows_sample.py index 853b7a08..e973e7e2 100644 --- a/tests/test_per_agent_workflows_sample.py +++ b/tests/test_per_agent_workflows_sample.py @@ -40,7 +40,7 @@ def _workflow_tools() -> dict[str, Any]: } -def test_sample_has_two_non_main_workflow_owners_with_distinct_policies() -> None: +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} diff --git a/tests/test_per_agent_workflows_verify.py b/tests/test_per_agent_workflows_verify.py index fcce7bc3..d897ce4e 100644 --- a/tests/test_per_agent_workflows_verify.py +++ b/tests/test_per_agent_workflows_verify.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import json import os from pathlib import Path from types import ModuleType @@ -346,7 +347,7 @@ def test_validate_terminal_result_rejects_release_evidence_identity_mismatch( verify.validate_terminal_result("release_manager", envelope) -def test_validate_owner_list_rejects_cross_owner_exposure() -> None: +def test_validate_workflow_agent_list_rejects_cross_agent_exposure() -> None: verify = _load_verify_module() incident_id = ( "0123456789abcdef0123456789abcdef-12345678123412341234123456789abc" @@ -355,10 +356,36 @@ def test_validate_owner_list_rejects_cross_owner_exposure() -> None: "fedcba9876543210fedcba9876543210-12345678123412341234123456789abc" ) - verify.validate_owner_list({"workflows": [{"workflow_id": incident_id}]}, incident_id, release_id) - with pytest.raises(RuntimeError, match="exposed the other owner"): - verify.validate_owner_list( + 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_endpoints.py b/tests/test_registration_endpoints.py index 4adcb06c..7bcdb6fb 100644 --- a/tests/test_registration_endpoints.py +++ b/tests/test_registration_endpoints.py @@ -315,7 +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_owner_slug"] == resolved.slug + assert calls["run_agent"]["workflow_agent_slug"] == resolved.slug def test_run_builtin_agent_stream_generates_session_id_before_building_sandbox_tools( @@ -362,7 +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_owner_slug"] == resolved.slug + assert calls["run_agent_stream"]["workflow_agent_slug"] == resolved.slug assert calls["run_agent_stream"]["agent_name"] != resolved.name @@ -1338,7 +1338,7 @@ async def get_status_all(self) -> list[Any]: assert json.loads(response.body) == {"workflows": []} -def test_workflow_list_endpoint_uses_resolved_owner_slug_not_route_slug( +def test_workflow_list_endpoint_uses_resolved_workflow_agent_slug_not_route_slug( tmp_path: Path, ) -> None: class _Client: diff --git a/tests/test_registration_handlers.py b/tests/test_registration_handlers.py index 9c3976c6..a8bc1532 100644 --- a/tests/test_registration_handlers.py +++ b/tests/test_registration_handlers.py @@ -619,7 +619,9 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: assert captured["agent_name"] != resolved.name -def test_non_http_workflow_handler_threads_owner_slug(monkeypatch: Any) -> None: +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: @@ -645,7 +647,7 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: asyncio.run(handler({"message": "hello"}, client=durable_client)) - assert captured["workflow_owner_slug"] == "queue-owner" + assert captured["workflow_agent_slug"] == "queue-owner" assert captured["workflow_durable_client"] is durable_client @@ -673,7 +675,9 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: assert captured["agent_name"] != resolved.name -def test_http_workflow_handler_threads_owner_slug(monkeypatch: Any) -> None: +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: @@ -700,7 +704,7 @@ async def fake_run_agent(*args: Any, **kwargs: Any) -> Any: ) assert response.status_code == 200 - assert captured["workflow_owner_slug"] == "http-owner" + assert captured["workflow_agent_slug"] == "http-owner" assert captured["workflow_durable_client"] is durable_client diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index c9329451..290d8cdd 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -65,14 +65,14 @@ def _registered_function( name: str, *, catalog=None, - owner_policies=None, + workflow_agent_policies=None, handler_catalog=None, ) -> Callable[..., Any]: app = _FakeApp() engine.register_workflows( app, catalog=catalog, - owner_policies=owner_policies, + workflow_agent_policies=workflow_agent_policies, handler_catalog=handler_catalog, ) [blueprint] = app.blueprints @@ -108,7 +108,7 @@ async def run_leaf( activity = _registered_function( engine.SUB_AGENT_ACTIVITY_NAME, catalog=_catalog("pr_status_analyst"), - owner_policies={ + workflow_agent_policies={ "coordinator": WorkflowPlanPolicy( allowed_tools=frozenset(), allowed_subagents=frozenset({"pr_status_analyst"}), @@ -122,7 +122,7 @@ async def run_leaf( "agent": "pr_status_analyst", "task": "Analyze PR 117.", "workflow_id": "workflow-1", - "owner_slug": "coordinator", + "workflow_agent_slug": "coordinator", } ) @@ -148,7 +148,7 @@ async def test_sub_agent_activity_fails_closed_on_catalog_miss() -> None: activity = _registered_function( engine.SUB_AGENT_ACTIVITY_NAME, catalog=_catalog("known"), - owner_policies={ + workflow_agent_policies={ "coordinator": WorkflowPlanPolicy( allowed_tools=frozenset(), allowed_subagents=frozenset({"missing"}), @@ -163,7 +163,7 @@ async def test_sub_agent_activity_fails_closed_on_catalog_miss() -> None: "agent": "missing", "task": "Analyze PR 117.", "workflow_id": "workflow-1", - "owner_slug": "coordinator", + "workflow_agent_slug": "coordinator", } ) @@ -173,7 +173,7 @@ async def test_sub_agent_activity_rejects_revoked_owner_grant() -> None: activity = _registered_function( engine.SUB_AGENT_ACTIVITY_NAME, catalog=_catalog("pr_status_analyst"), - owner_policies={ + workflow_agent_policies={ "coordinator": WorkflowPlanPolicy( allowed_tools=frozenset(), allowed_subagents=frozenset(), @@ -188,30 +188,30 @@ async def test_sub_agent_activity_rejects_revoked_owner_grant() -> None: "agent": "pr_status_analyst", "task": "Analyze PR 117.", "workflow_id": "workflow-1", - "owner_slug": "coordinator", + "workflow_agent_slug": "coordinator", } ) @pytest.mark.asyncio -@pytest.mark.parametrize("owner_policies", [None, {}]) -async def test_sub_agent_activity_missing_owner_policy_fails_closed( - owner_policies, +@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"), - owner_policies=owner_policies, + workflow_agent_policies=workflow_agent_policies, ) - with pytest.raises(RuntimeError, match="owner policy"): + with pytest.raises(RuntimeError, match="agent policy"): await activity( { "id": "analyze_pr", "agent": "pr_status_analyst", "task": "Analyze PR 117.", "workflow_id": "workflow-1", - "owner_slug": "missing", + "workflow_agent_slug": "missing", } ) @@ -229,7 +229,7 @@ async def fail(*args: Any, **kwargs: Any) -> str: activity = _registered_function( engine.SUB_AGENT_ACTIVITY_NAME, catalog=_catalog("pr_status_analyst"), - owner_policies={ + workflow_agent_policies={ "coordinator": WorkflowPlanPolicy( allowed_tools=frozenset(), allowed_subagents=frozenset({"pr_status_analyst"}), @@ -244,7 +244,7 @@ async def fail(*args: Any, **kwargs: Any) -> str: "agent": "pr_status_analyst", "task": "Analyze PR 117.", "workflow_id": "workflow-1", - "owner_slug": "coordinator", + "workflow_agent_slug": "coordinator", } ) @@ -272,7 +272,7 @@ def __init__( result_for: Callable[[str, dict[str, Any]], dict[str, Any]], ) -> None: self.instance_id = "workflow-parent" - self._input = {"owner_slug": "coordinator", "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([]) @@ -404,7 +404,10 @@ 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["owner_slug"] == "coordinator" 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", @@ -413,7 +416,7 @@ def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: ] -def test_orchestrator_threads_owner_slug_to_tool_activity() -> None: +def test_orchestrator_threads_workflow_agent_slug_to_tool_activity() -> None: tasks = [ { "id": "publish", @@ -438,22 +441,22 @@ def test_orchestrator_threads_owner_slug_to_tool_activity() -> None: "id": "publish", "tool": "publish", "args": {}, - "owner_slug": "coordinator", + "workflow_agent_slug": "coordinator", "workflow_id": "workflow-parent", }, ) ] -def test_tool_activity_reauthorizes_current_owner_policy() -> None: +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, - owner_policies={ - "owner": WorkflowPlanPolicy( + workflow_agent_policies={ + "workflow-agent": WorkflowPlanPolicy( allowed_tools=frozenset({"publish"}), allowed_subagents=frozenset(), ) @@ -462,8 +465,8 @@ def test_tool_activity_reauthorizes_current_owner_policy() -> None: revoked = _registered_function( "agents_workflow_run_tool", handler_catalog=handler_catalog, - owner_policies={ - "owner": WorkflowPlanPolicy( + workflow_agent_policies={ + "workflow-agent": WorkflowPlanPolicy( allowed_tools=frozenset(), allowed_subagents=frozenset(), ) @@ -473,7 +476,7 @@ def test_tool_activity_reauthorizes_current_owner_policy() -> None: "id": "publish", "tool": "publish", "args": {"value": 1}, - "owner_slug": "owner", + "workflow_agent_slug": "workflow-agent", "workflow_id": "workflow-1", } @@ -485,24 +488,26 @@ def test_tool_activity_reauthorizes_current_owner_policy() -> None: revoked(payload) -@pytest.mark.parametrize("owner_policies", [None, {}]) -def test_tool_activity_missing_owner_policy_fails_closed(owner_policies) -> None: +@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, - owner_policies=owner_policies, + workflow_agent_policies=workflow_agent_policies, ) - with pytest.raises(RuntimeError, match="owner policy"): + with pytest.raises(RuntimeError, match="agent policy"): activity( { "id": "publish", "tool": "publish", "args": {}, - "owner_slug": "missing", + "workflow_agent_slug": "missing", "workflow_id": "workflow-1", } ) diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index 75ffb2be..d556a5c1 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -112,10 +112,12 @@ def failing_workflow_session(): def _registered_blueprint_function( name, *, - owner_policies=None, + workflow_agent_policies=None, ): app = _FakeApp() - engine.register_workflows(app, owner_policies=owner_policies) + engine.register_workflows( + app, workflow_agent_policies=workflow_agent_policies + ) [blueprint] = app.blueprints for builder in blueprint._function_builders: function = builder._function @@ -137,28 +139,28 @@ def test_compatibility_session_registry_does_not_expose_or_confuse_registration_ first_client = object() second_client = object() first_token = context.register_workflow_session( - "owner", + "workflow-agent", "session", - "Owner", + "Workflow Agent", first_client, ) second_token = context.register_workflow_session( - "owner", + "workflow-agent", "session", - "Owner", + "Workflow Agent", second_client, ) - registered = context.get_workflow_session("owner", "session") + 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("owner", "session", first_token) - assert context.get_workflow_session("owner", "session") is registered + context.unregister_workflow_session("workflow-agent", "session", first_token) + assert context.get_workflow_session("workflow-agent", "session") is registered - context.unregister_workflow_session("owner", "session", second_token) - assert context.get_workflow_session("owner", "session") is None + 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(): @@ -577,7 +579,7 @@ def exploding_tool(args): registry.register_workflow_tool("exploding", "Always fails.", exploding_tool) activity = _registered_blueprint_function( "agents_workflow_run_tool", - owner_policies={ + workflow_agent_policies={ "test-agent": schema.WorkflowPlanPolicy( allowed_tools=frozenset({"exploding"}), allowed_subagents=frozenset(), @@ -591,7 +593,7 @@ def exploding_tool(args): "id": "explode", "tool": "exploding", "args": {}, - "owner_slug": "test-agent", + "workflow_agent_slug": "test-agent", "workflow_id": "workflow-1", } ) @@ -602,7 +604,7 @@ def exploding_tool(args): record.message == ( "workflow activity failed: workflow_id=workflow-1 " - "owner=test-agent id=explode tool=exploding" + "workflow_agent=test-agent id=explode tool=exploding" ) and record.exc_info and secret_message in str(record.exc_info[1]) @@ -670,7 +672,7 @@ async def test_workflow_tools_log_durable_exceptions_without_returning_details( failing_workflow_session, ) session = context.WorkflowSessionContext( - owner_slug="test-agent", + workflow_agent_slug="test-agent", session_id=failing_workflow_session, agent_name="test-agent", durable_client=_FailingDurableClient(), @@ -701,7 +703,7 @@ async def test_start_workflow_rejects_new_workflow_when_session_active_cap_reach ] client = _CappedDurableClient(statuses) session = context.WorkflowSessionContext( - owner_slug="test-agent", + workflow_agent_slug="test-agent", session_id=session_id, agent_name="test-agent", durable_client=client, @@ -729,7 +731,7 @@ async def get_status_all(self): raise AssertionError("authorization must fail before Durable scheduling") session = context.WorkflowSessionContext( - owner_slug="coordinator", + workflow_agent_slug="coordinator", session_id="session-1", agent_name="coordinator", durable_client=_UnexpectedClient(), @@ -762,7 +764,7 @@ async def get_status_all(self): raise AssertionError("drain mode must reject before Durable scheduling") session = context.WorkflowSessionContext( - owner_slug="incident", + workflow_agent_slug="incident", session_id="session-1", agent_name="Incident", durable_client=_UnexpectedClient(), @@ -786,10 +788,10 @@ async def get_status_all(self): @pytest.mark.asyncio -async def test_start_workflow_threads_owner_slug_into_durable_input() -> None: +async def test_start_workflow_threads_workflow_agent_slug_into_durable_input() -> None: client = _CappedDurableClient([]) session = context.WorkflowSessionContext( - owner_slug="incident", + workflow_agent_slug="incident", session_id="session-1", agent_name="Incident", durable_client=client, @@ -808,9 +810,9 @@ async def test_start_workflow_threads_owner_slug_into_durable_input() -> None: ) assert "workflow_id" in json.loads(result) - assert client.start_kwargs["client_input"]["owner_slug"] == "incident" - assert client.start_kwargs["client_input"]["owner"] == { - "owner_slug": "incident", + 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", } From a264a833d8fd7471048ef323cc8e98eb58c33df8 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Thu, 13 Aug 2026 12:41:46 -0700 Subject: [PATCH 17/18] refactor: remove workflow drain mode Remove the unreleased drain-mode environment variable and its runtime, policy, test, and documentation surface. Record the superseding FRD decision and defer final-agent lifecycle ownership to the tracked issue. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- README.md | 1 - docs/architecture.md | 9 +- docs/frds/0004-dynamic-workflows.md | 49 +++----- docs/workflows.md | 69 +++-------- src/azure_functions_agents/app.py | 15 +-- .../workflows/integration.py | 22 ---- .../workflows/schema.py | 1 - .../workflows/settings.py | 27 ---- src/azure_functions_agents/workflows/tools.py | 10 +- tests/test_per_agent_workflows.py | 117 ------------------ tests/test_workflow_registry.py | 48 ------- 11 files changed, 42 insertions(+), 326 deletions(-) delete mode 100644 src/azure_functions_agents/workflows/settings.py diff --git a/README.md b/README.md index be2c3ff0..2dfe49b1 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,6 @@ correlation, `host.json` `telemetryMode: OpenTelemetry` is optional and additive | `AZURE_FUNCTIONS_AGENTS_MODEL` | Runtime-owned model fallback when no provider-specific model/deployment is set | | `AZURE_FUNCTIONS_AGENTS_REASONING_EFFORT` | Optional reasoning effort for supported reasoning models (valid values include `none`, `low`, `medium`, `high`, `xhigh`) | | `AZURE_FUNCTIONS_AGENTS_REASONING_SUMMARY` | Optional reasoning summary mode for supported reasoning models (valid values are `auto`, `concise`, `detailed`) | -| `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` | Retain the Durable workflow runtime while rejecting new workflow starts when removing the final workflow-enabled agent; keep enabled until Task Hub tooling confirms no non-terminal instances | ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 53fd4317..aff1d203 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ A few boundaries are worth calling out explicitly: | Package/module | Role | Key entry points | | --- | --- | --- | -| `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 or explicit drain mode retains the runtime, registers the workflow runtime once, then registers each agent. | `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` | @@ -79,7 +79,6 @@ A few boundaries are worth calling out explicitly: | `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/settings.py` | Parses the explicit workflow drain-mode app setting once during composition, rejecting invalid values. The result is captured in immutable workflow-agent policies so request execution cannot drift from the startup decision. | `workflow_drain_mode_enabled()` | | `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()` | @@ -185,9 +184,9 @@ The `create_function_app()` docstring in `src/azure_functions_agents/app.py:crea 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` and `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE` - - **Output:** `azure.functions.FunctionApp` (a Durable Functions `DFApp` when at least one workflow-agent policy exists or drain mode is active, 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. Drain mode deliberately performs the same registration with an empty policy catalog so instances from a removed final workflow-enabled agent reach Activity reauthorization and fail closed; ordinary apps that never use workflows retain the lower-overhead plain `FunctionApp`. Active drain mode is emitted in the indexing summary and as a startup warning. + - **Input:** startup defaults such as `http_auth_level=func.AuthLevel.FUNCTION` + - **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` diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 2d821820..6c60677f 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -479,7 +479,7 @@ 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 and drain mode +#### 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 @@ -501,32 +501,19 @@ sequenceDiagram ``` 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. `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` retains the -`DFApp`, orchestrator, and Activities while allowing the current policy catalog -to be empty. It also omits `start_workflow` from agent tool sets and defensively -rejects direct application-level starts. - -The safe transition is: - -1. Enable drain mode while the final workflow-enabled agent is still deployed, - and quiesce external starters. -2. Prefer to let existing instances reach terminal states. If the agent must be - removed first, keep drain mode enabled: retained Activities then execute and - fail explicitly against the missing policy instead of remaining queued - indefinitely. -3. Use Task Hub management tooling—not the session-scoped application list—to - confirm there are no `Pending`, `Running`, `Suspended`, or `ContinuedAsNew` - instances. Terminate any remainder when completion is no longer possible; - termination does not undo already-dispatched Activity side effects. -4. Only after confirmation, disable drain mode. An app with no workflow-enabled - agents then returns to a plain `FunctionApp`. - -Task Hub name, Storage or DTS connection, `host.json` Durable settings, and -extension bundle must continue to identify the same backend throughout the -drain. If the hub cannot be queried or termination cannot be confirmed, the -retained runtime must remain deployed. Direct Durable control-plane starts are -privileged operations outside the application-level start guard. +`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 @@ -580,6 +567,7 @@ that cross-agent status access returns 404. | 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 @@ -632,17 +620,16 @@ that cross-agent status access returns 404. 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 and final-agent drain +- [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; - - retain the Durable runtime with an empty policy catalog in drain mode and - reject new application-level starts; - - fail startup for invalid drain-mode values; - 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 diff --git a/docs/workflows.md b/docs/workflows.md index d03bf9c5..856abacd 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -182,9 +182,7 @@ 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` unless the -operator enables -[drain mode](#removing-the-final-workflow-enabled-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 @@ -514,56 +512,21 @@ nodes; they fail closed rather than continuing under a stale policy snapshot. ### Removing the final workflow-enabled agent -Removing the final workflow-enabled agent without retaining the Durable runtime -can strand pending instances. For example, 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 remains -non-terminal in the hub instead. Use this drain procedure: - -1. Set `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE=true` while the current - workflow deployment is still active. Drain mode removes `start_workflow` from - the agent's tool set and defensively rejects direct application-level start - calls before Durable scheduling, while list, status, cancel, terminate, - orchestrator, and Activity execution remain available. The management tools - can access only workflows started under the same agent and session ID; use - Durable Functions or DTS Task Hub tooling as the authoritative app-wide - management surface from the start of the drain. Startup emits a warning and - records drain mode in the indexing summary. -2. Stop or quiesce external trigger/chat traffic that could repeatedly ask the - agent to start workflows. Direct Durable control-plane starts are privileged - operations outside this application guard and must also stop. -3. Prefer to let existing instances finish before removing or disabling the - final workflow-enabled agent. If removal must happen first, keep drain mode - enabled. The app remains a `DFApp` with an empty agent-policy catalog, so - pending tool or Sub Agent Activities from the removed agent fail closed - instead of remaining queued indefinitely. The removed agent's chat tools and - `/agents/{slug}/workflows`/`workflow-status` endpoints no longer exist, so - Durable/DTS tooling is now the only complete management surface. -4. Use Durable Functions management tooling or the DTS dashboard to query the - whole Task Hub. The session-scoped application list endpoint is capped and - cannot discover every non-HTTP invocation. Drain is complete only when - repeated queries show no `Pending`, `Running`, `Suspended`, or - `ContinuedAsNew` instances. -5. If instances do not complete within the maintenance window, inspect their - history and terminate the remainder through Durable/DTS management tooling. - Already-dispatched Activity side effects are not rolled back. Confirm every - instance reaches a terminal status. -6. Only after that confirmation, remove - `AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE`. An app with no - workflow-enabled agents then returns to a plain `FunctionApp`. - -If the Task Hub cannot be queried, termination cannot be confirmed, or -non-terminal instances remain, keep drain mode and the Durable runtime deployed; -do not complete the final transition. Accepted true values are `true`, `1`, -`yes`, and `y`; false values are `false`, `0`, `no`, and `n`. Any other -non-empty value fails startup. - -Keep the Durable backend identity constant throughout the drain window: -`host.json` Durable configuration, Task Hub name, Azure Storage or DTS -connection settings, and extension bundle must continue pointing at the same -Task Hub. Changing those values during the drain can strand instances in a hub -the retained runtime no longer polls. +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 diff --git a/src/azure_functions_agents/app.py b/src/azure_functions_agents/app.py index 16502c6c..359489aa 100644 --- a/src/azure_functions_agents/app.py +++ b/src/azure_functions_agents/app.py @@ -35,7 +35,6 @@ register_workflow_runtime, validate_workflow_agent_trigger, ) -from .workflows.settings import workflow_drain_mode_enabled def _tool_name(tool: object) -> str: @@ -198,33 +197,24 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: catalog: AgentCatalog = build_catalog(catalog_entries) workflow_handler_catalog = build_workflow_handler_catalog(workflow_tools) - workflow_drain_mode = workflow_drain_mode_enabled() workflow_agent_policies = build_workflow_agent_policy_catalog( catalog, workflow_handler_catalog, - starts_allowed=not workflow_drain_mode, ) - workflow_runtime_required = bool(workflow_agent_policies) or workflow_drain_mode app: func.FunctionApp = ( df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) - if workflow_runtime_required + 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_runtime_required: + if workflow_agent_policies: register_workflow_runtime( app, handler_catalog=workflow_handler_catalog, catalog=catalog, workflow_agent_policies=workflow_agent_policies, ) - if workflow_drain_mode: - logger.warning( - "workflow drain mode active: new application-level workflow starts " - "are disabled; workflow_agent_policy_count=%d", - len(workflow_agent_policies), - ) for resolved in resolved_agents: capabilities = catalog[resolved.slug].capabilities @@ -318,7 +308,6 @@ def create_function_app(app_root: Path | None = None) -> func.FunctionApp: "agent_count": len(agent_specs), "agents": agents_summary, "system_tools": list(system_tools_used), - "workflow_drain_mode": workflow_drain_mode, "discovered_capabilities": { "mcp_servers": len(mcp_names), "skills": len(skill_names), diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index 3bfa9285..afc6e5be 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -140,16 +140,6 @@ "response format permits it." ) -_DRAIN_ADDENDUM = ( - "\n\n" - "## Workflow drain mode\n\n" - "This app is draining existing workflows. New workflow starts are disabled. " - "Do not attempt to call `start_workflow`; only use workflow status, list, " - "cancel, or terminate tools when the user explicitly asks to manage an " - "existing workflow.\n" -) - - @dataclass(frozen=True) class WorkflowIntegrationResult: """Workflow registration output for each invocation channel. @@ -408,12 +398,6 @@ def _build_addendum( trigger_invocation: bool, handler_catalog: registry.WorkflowHandlerCatalog | None = None, ) -> str: - if not policy.starts_allowed: - return ( - _DRAIN_ADDENDUM - if trigger_invocation - else _DRAIN_ADDENDUM + _CHAT_NOTIFICATION_ADDENDUM - ) channel_addendum = _TRIGGER_ADDENDUM if trigger_invocation else _CHAT_ADDENDUM return ( _SHARED_ADDENDUM @@ -427,8 +411,6 @@ def _build_plan_policy( allowed_tools: frozenset[str], workflow_subagents: Sequence[WorkflowSubagentRef], catalog: AgentCatalog | None, - *, - starts_allowed: bool = True, ) -> WorkflowPlanPolicy: guidance: list[tuple[str, str]] = [] for ref in workflow_subagents: @@ -443,7 +425,6 @@ def _build_plan_policy( allowed_tools=allowed_tools, allowed_subagents=frozenset(ref.agent for ref in workflow_subagents), subagent_guidance=tuple(guidance), - starts_allowed=starts_allowed, ) @@ -467,8 +448,6 @@ def validate_workflow_agent_trigger(resolved: ResolvedAgent) -> None: def build_workflow_agent_policy_catalog( catalog: AgentCatalog, handler_catalog: registry.WorkflowHandlerCatalog, - *, - starts_allowed: bool = True, ) -> WorkflowAgentPolicyCatalog: """Freeze one independent workflow policy per workflow-enabled agent.""" policies: dict[str, WorkflowPlanPolicy] = {} @@ -488,7 +467,6 @@ def build_workflow_agent_policy_catalog( allowed_tools, resolved.workflows.subagents, catalog, - starts_allowed=starts_allowed, ) return MappingProxyType(policies) diff --git a/src/azure_functions_agents/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py index 37f02214..d7e43bb0 100644 --- a/src/azure_functions_agents/workflows/schema.py +++ b/src/azure_functions_agents/workflows/schema.py @@ -60,7 +60,6 @@ class WorkflowPlanPolicy: allowed_tools: frozenset[str] allowed_subagents: frozenset[str] subagent_guidance: tuple[tuple[str, str], ...] = () - starts_allowed: bool = True class WorkflowTask(BaseModel): diff --git a/src/azure_functions_agents/workflows/settings.py b/src/azure_functions_agents/workflows/settings.py deleted file mode 100644 index 29d2da0f..00000000 --- a/src/azure_functions_agents/workflows/settings.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Operational settings for the Dynamic Workflows runtime.""" - -from azure_functions_agents.config.env import runtime_env_value - -WORKFLOW_DRAIN_MODE_ENV = "AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE" - -_TRUE_VALUES = frozenset({"true", "1", "yes", "y"}) -_FALSE_VALUES = frozenset({"false", "0", "no", "n"}) - - -def workflow_drain_mode_enabled() -> bool: - """Return whether the app should retain Durable runtime for workflow draining.""" - raw = runtime_env_value(WORKFLOW_DRAIN_MODE_ENV) - if not raw: - return False - normalized = raw.lower() - if normalized in _TRUE_VALUES: - return True - if normalized in _FALSE_VALUES: - return False - raise ValueError( - f"{WORKFLOW_DRAIN_MODE_ENV} must be a boolean " - "(true/false, 1/0, yes/no, or y/n)" - ) - - -__all__ = ["WORKFLOW_DRAIN_MODE_ENV", "workflow_drain_mode_enabled"] diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index aab27864..a1a7f505 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -355,10 +355,6 @@ async def start_workflow( ) -> str: if session is None: return _error(_NO_CLIENT_MESSAGE) - if policy is not None and not policy.starts_allowed: - return _error( - "workflow drain mode is active; new workflows cannot be started" - ) allowed_tools = registry.get_app_config() if policy is None else None if policy is None and allowed_tools is None: @@ -664,15 +660,13 @@ async def _cancel_workflow(params: CancelWorkflowParams) -> str: async def _terminate_workflow(params: TerminateWorkflowParams) -> str: return await terminate_workflow(params, session) - workflow_tools = [ + return [ + _start_workflow, _get_workflow_status, _list_workflows, _cancel_workflow, _terminate_workflow, ] - if policy is None or policy.starts_allowed: - workflow_tools.insert(0, _start_workflow) - return workflow_tools __all__ = [ diff --git a/tests/test_per_agent_workflows.py b/tests/test_per_agent_workflows.py index f299f5e7..7a37f548 100644 --- a/tests/test_per_agent_workflows.py +++ b/tests/test_per_agent_workflows.py @@ -84,123 +84,6 @@ def test_non_workflow_app_remains_plain_function_app(tmp_path) -> None: assert engine.ORCHESTRATOR_NAME not in _function_names(app) -def test_drain_mode_retains_runtime_with_no_workflow_agents( - tmp_path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "true") - caplog.set_level("INFO", logger="azure.functions.AgentRuntime") - _write_agent( - tmp_path, - "assistant.agent.md", - """ -name: Assistant -description: Handles chat after the final workflow-enabled agent was removed. -builtin_endpoints: - chat_api: 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 - activity = _registered_function(app, "agents_workflow_run_tool") - with pytest.raises(RuntimeError, match="agent policy"): - activity( - { - "id": "pending", - "tool": "removed_tool", - "args": {}, - "workflow_agent_slug": "removed_agent", - "workflow_id": "workflow-1", - } - ) - assert "workflow drain mode active" in caplog.text - assert '"workflow_drain_mode": true' in caplog.text - - -def test_drain_mode_disables_starts_for_existing_owner( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "true") - captured: dict[str, schema.WorkflowPlanPolicy] = {} - original_builder = integration.build_workflow_agent_policy_catalog - - def capture_policies(catalog, handler_catalog, *, starts_allowed=True): - policies = original_builder( - catalog, - handler_catalog, - starts_allowed=starts_allowed, - ) - captured.update(policies) - return policies - - monkeypatch.setattr( - "azure_functions_agents.app.build_workflow_agent_policy_catalog", - capture_policies, - ) - _write_agent( - tmp_path, - "incident.agent.md", - """ -name: Incident -description: Triage incidents while existing workflows drain. -builtin_endpoints: - chat_api: true -workflows: - enabled: true -""", - ) - - app = create_function_app(tmp_path) - - assert isinstance(app, df.DFApp) - assert "agent_incident_builtin_chat" in _function_names(app) - policy = captured["incident"] - assert not policy.starts_allowed - agent_integration = integration.build_workflow_agent_integration( - policy, - MappingProxyType({}), - ) - assert {tool.name for tool in agent_integration.workflow_tools} == { - "get_workflow_status", - "list_workflows", - "cancel_workflow", - "terminate_workflow", - } - assert "Workflow drain mode" in agent_integration.chat_system_addendum - assert "" in agent_integration.chat_system_addendum - - -def test_invalid_drain_mode_fails_startup( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", "sometimes") - _write_agent( - tmp_path, - "assistant.agent.md", - """ -name: Assistant -description: Handles chat without workflows. -builtin_endpoints: - chat_api: true -""", - ) - - with pytest.raises( - ValueError, - match="AZURE_FUNCTIONS_AGENTS_WORKFLOW_DRAIN_MODE", - ): - create_function_app(tmp_path) - - @pytest.mark.parametrize("chat_api", ['"true"', "1"]) def test_workflow_agent_accepts_coercible_chat_api(tmp_path, chat_api: str) -> None: _write_agent( diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index d556a5c1..fba5397d 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -178,23 +178,6 @@ def test_reserved_names_match_management_tools(): assert actual == set(registry.RESERVED_TOOL_NAMES) -def test_drain_policy_exposes_management_tools_without_start(): - policy = schema.WorkflowPlanPolicy( - allowed_tools=frozenset(), - allowed_subagents=frozenset(), - starts_allowed=False, - ) - - actual = {tool.name for tool in tools.build_workflow_tools(policy=policy)} - - assert actual == { - "get_workflow_status", - "list_workflows", - "cancel_workflow", - "terminate_workflow", - } - - def test_register_workflow_tool_rejects_async_handler(): async def async_handler(args): return {} @@ -756,37 +739,6 @@ async def get_status_all(self): assert "not authorized" in json.loads(result)["error"] -@pytest.mark.asyncio -async def test_start_workflow_rejects_new_instances_in_drain_mode( -) -> None: - class _UnexpectedClient: - async def get_status_all(self): - raise AssertionError("drain mode must reject before Durable scheduling") - - session = context.WorkflowSessionContext( - workflow_agent_slug="incident", - session_id="session-1", - agent_name="Incident", - durable_client=_UnexpectedClient(), - ) - - result = await tools.start_workflow( - tools.StartWorkflowParams( - tasks=[{"id": "pause", "type": "wait", "duration": "PT1S"}] - ), - session, - policy=schema.WorkflowPlanPolicy( - allowed_tools=frozenset(), - allowed_subagents=frozenset(), - starts_allowed=False, - ), - ) - - assert json.loads(result) == { - "error": "workflow drain mode is active; new workflows cannot be started" - } - - @pytest.mark.asyncio async def test_start_workflow_threads_workflow_agent_slug_into_durable_input() -> None: client = _CappedDurableClient([]) From 6c99724e6ae7e7030b3050d81a266a8be47a63d9 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 14 Aug 2026 11:21:50 -0700 Subject: [PATCH 18/18] test: relocate workflow E2E verifier Move the sample-only verifier from eng/scripts to tests/scripts and update its tests and design record to match the repository's script ownership conventions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ade5207c-a0f4-4865-82d6-1d0d61f18570 --- docs/frds/0004-dynamic-workflows.md | 2 +- {eng => tests}/scripts/verify_per_agent_workflows.py | 2 +- tests/test_per_agent_workflows_verify.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename {eng => tests}/scripts/verify_per_agent_workflows.py (99%) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 6c60677f..44a44c7e 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -561,7 +561,7 @@ that cross-agent status access returns 404. | 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 `eng/scripts` | 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 | diff --git a/eng/scripts/verify_per_agent_workflows.py b/tests/scripts/verify_per_agent_workflows.py similarity index 99% rename from eng/scripts/verify_per_agent_workflows.py rename to tests/scripts/verify_per_agent_workflows.py index d53161ab..30de4052 100644 --- a/eng/scripts/verify_per_agent_workflows.py +++ b/tests/scripts/verify_per_agent_workflows.py @@ -1,4 +1,4 @@ -"""Verify both Engineering Operations Hub workflow-enabled agents end to end.""" +"""Run the Engineering Operations Hub workflow E2E verifier.""" from __future__ import annotations diff --git a/tests/test_per_agent_workflows_verify.py b/tests/test_per_agent_workflows_verify.py index d897ce4e..6c0433f8 100644 --- a/tests/test_per_agent_workflows_verify.py +++ b/tests/test_per_agent_workflows_verify.py @@ -11,7 +11,7 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[1] -VERIFY_SCRIPT = REPO_ROOT / "eng" / "scripts" / "verify_per_agent_workflows.py" +VERIFY_SCRIPT = REPO_ROOT / "tests" / "scripts" / "verify_per_agent_workflows.py" def _load_verify_module() -> ModuleType: