From 9d3af310e205cd6c639d560e7028b094d7ba7e42 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Thu, 13 Aug 2026 19:30:05 -0700 Subject: [PATCH 1/5] docs: design dynamic workflow control flow Extend FRD 0004 with constrained conditions, bounded runtime fan-out, deterministic aggregation, status, limits, authorization, and stable failure contracts for planning issue 1276. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10f8ebc4-9f18-4717-983e-4e9b8ec5eb55 --- docs/frds/0004-dynamic-workflows.md | 382 +++++++++++++++++++++++++++- docs/frds/README.md | 2 +- 2 files changed, 380 insertions(+), 4 deletions(-) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 4e15cf1..311693a 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -1,11 +1,11 @@ --- frd: 0004 title: Dynamic workflows -status: Finalized +status: In review author: TsuyoshiUshio created: 2026-07-06 -updated: 2026-07-24 -issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108] +updated: 2026-08-13 +issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108, https://github.com/Azure/azure-functions-bucees-planning/issues/1276] 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] --- @@ -23,6 +23,10 @@ decorator; normal plain-function tool discovery remains backward compatible. 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. +The next evolution adds deterministic, data-driven control flow: a task can be +skipped by a constrained `when` predicate or expanded over a bounded JSON array +with `for_each`, while preserving Durable replay safety, owner authorization, +resource limits, deterministic fan-in, and observable node state. ## 2. Motivation / problem @@ -72,6 +76,14 @@ explicitly opt a function into the Durable Activity execution path. `main.agent.md` 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`. +- Add constrained conditional execution without embedding a general-purpose + expression language in workflow plans. +- Add bounded runtime fan-out over JSON arrays and deterministic fan-in over the + expanded results. +- Apply the existing workflow owner policy and runtime ceilings to every + materialized task instance. +- Expose skipped, expanded, running, and aggregated states through the shared + workflow status contract. **Non-goals** @@ -84,6 +96,12 @@ explicitly opt a function into the Durable Activity execution path. or cross-app workflow coordination. Stateless leaf Sub Agent tasks are in v1. - Changing normal MAF tool execution semantics. - Automatically promoting every compatible plain function into a workflow tool. +- General-purpose expressions, arbitrary code evaluation, loops other than bounded + array iteration, or a visual workflow designer. +- Retry, timeout, backoff, or continue-on-error policy; those are tracked by + planning issue #1278. +- Configurable resource ceilings and large-result offload; those are tracked by + planning issue #1279. ## 4. Proposed design @@ -409,6 +427,290 @@ 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. +### Data-driven control flow (Issue #1276; in review) + +The current workflow contract is an arbitrary but static DAG: every task id and +dependency edge exists when `start_workflow` validates the plan. Static roots can +already fan out and a later task can fan in through `depends_on`, but the model +must enumerate every item before submission. That prevents a workflow from +adapting to a bounded collection returned by a tool or Sub Agent and forces +irrelevant branches to run even when an upstream result makes them unnecessary. + +This extension keeps the LLM-authored DAG as the control plane and adds two +optional fields to each existing task type: + +- `when`: a constrained predicate that decides whether the logical task or + materialized task instance runs. +- `for_each`: a full-value reference to an upstream JSON array. The runtime + materializes one instance of the task per array element. + +No frontmatter field is added. Existing plans that omit both fields retain their +current validation, scheduling, result, and status behavior. The optional fields +are omitted with exclude-unset/exclude-none serialization when absent so static +plan model dumps and Durable wire payloads do not gain `null` fields. + +#### Pipeline mapping + +| Pipeline stage | Module(s) | Change | +| --- | --- | --- | +| discover | No change | Dynamic control flow does not discover new files or capabilities. | +| translate | `workflows/schema.py`, `workflows/tools.py` | Extend the agent-facing and runtime plan schemas with typed `when` and `for_each` fields. Validate syntax, upstream references, static tool/Sub Agent targets, and logical DAG structure before scheduling. | +| register | `workflows/integration.py` | Extend the runtime-owned prompt guidance and `start_workflow` tool schema. Durable blueprint registration and owner policy construction remain unchanged. | +| execute | `workflows/engine.py`, `workflows/tools.py`, `public/index.html` | Deterministically resolve collections and predicates, materialize bounded instances, schedule them under the existing parallelism cap, aggregate results in source order, publish structured progress, and normalize controlled failures into stable envelopes. | + +#### Constrained `when` contract + +`when` is an object rather than a string expression: + +```json +{ + "id": "notify", + "type": "tool", + "tool": "send_notification", + "args": {"incident": "${classify.result.incident}"}, + "depends_on": ["classify"], + "when": { + "ref": "${classify.result.should_notify}", + "operator": "equals", + "value": true + } +} +``` + +The contract is intentionally small: + +- `ref` must be one full reference to an upstream result or, inside `for_each`, + the current `${item}` / `${item.path}` / `${index}` local. +- `operator` is exactly `equals` or `not_equals`. +- `value` must be a JSON scalar (`null`, boolean, number, or string). +- Comparison is type-sensitive JSON scalar equality. There is no coercion, + truthiness, ordering, regex, boolean composition, function call, or access to + environment/runtime state. +- A missing path, malformed reference, non-scalar resolved value, or unsupported + operator is an error; it never silently evaluates to false. + +For a normal task, the predicate is evaluated once after all dependencies +complete. For a `for_each` task, the collection is resolved first and the +predicate is evaluated independently for each bound item. Evaluation order is: +resolve dependencies, resolve `for_each` when present, bind `${item}` / +`${index}`, evaluate `when`, and only for a true predicate resolve executable +`args` or Sub Agent `task` templates. A false predicate therefore does not +resolve unused executable value fields; it marks the corresponding logical task +or instance `skipped`, schedules no Activity/timer, and produces `null` for that +result position. A skipped task still satisfies downstream `depends_on` edges. + +Skip does not propagate automatically. A descendant that should be part of the +same conditional branch must declare its own `when`; this keeps branch behavior +visible in the authored plan and avoids an implicit dependency-reachability +language. A full `${skipped.result}` reference resolves to `null`. Traversing +below it, such as `${skipped.result.field}`, produces the controlled +`workflow_reference_unresolved` failure because `null` has no traversable path. + +#### Bounded `for_each` contract + +`for_each` is available on `tool` and `sub_agent` tasks and must be one full +upstream-result reference that resolves to a JSON array: + +```json +{ + "id": "analyze", + "type": "sub_agent", + "agent": "pr_status_analyst", + "task": "Analyze pull request ${item.url} at input index ${index}.", + "depends_on": ["discover"], + "for_each": "${discover.result.pull_requests}" +} +``` + +The task's target (`tool`, `agent`, or `wait`) remains static and is validated +against the owner's immutable policy before the workflow starts. Only value +fields (`args`, a Sub Agent's `task`, and `when.ref`) may use the fixed iteration +locals: + +- `${item}` returns the current element with its native JSON type. +- `${item.path.to.field}` traverses the current element using the same + deterministic dictionary/list path rules as upstream result templates. +- `${index}` returns the zero-based integer index. + +Aliases, nested `for_each`, cross-instance references, item-dependent +`depends_on`, and templated tool or Sub Agent names are not supported. An array +element may be any JSON value, although a referenced item path must be valid for +that element. `wait` tasks may use `when` but cannot use `for_each`: repeated +identical timers add no data-driven behavior because wait deadlines cannot +reference iteration locals. + +The template grammar, validation walker, and runtime resolver are extended to +recognize `${item}`, `${item.path}`, and `${index}`. Those forms are rejected +outside a `for_each` task, and the existing unmatched-token defense continues to +reject every other `${...}` shape. + +Materialized instance ids are runtime-owned and use +`[]`, for example `analyze[0]`. They are +visible in status and diagnostics but cannot appear in authored `depends_on` or +template references. Authored task ids continue to allow letters, numbers, +underscore, and hyphen only; `[` and `]` are rejected, reserving the rendered +instance-id namespace for the runtime. Materialization and scheduling always use the numeric +`(logical_task_id, index)` tuple as the ordering key, with logical task id as the +outer key when multiple tasks become ready together. The scheduler must not sort +the rendered instance-id strings because `analyze[10]` sorts before `analyze[2]` +lexicographically and would violate source-index wave selection even though that +string order is itself replay-deterministic. These rules make the same persisted +inputs and upstream results produce the same instance ids and Durable scheduling +history on replay. + +An empty array is valid: no instances run, the logical node immediately becomes +`aggregated`, and its result is `[]`. + +#### Deterministic fan-in + +A `for_each` logical node completes only after all of its materialized instances +have completed or been skipped. Its result is an array aligned with the source +collection: + +```json +[ + {"index": 0, "status": "completed", "result": {"summary": "ready"}}, + {"index": 1, "status": "skipped", "result": null} +] +``` + +The array is always ordered by source index, never by Activity completion order. +A downstream task depends on the logical id (`"depends_on": ["analyze"]`) and +can consume the complete collection with `${analyze.result}` or traverse a known +position with the existing dotted/list-index syntax. It cannot depend on or +reference an individual runtime-owned instance id. + +This is aggregation of already-completed instance results, not a new reducer +language. Domain-specific reduction remains an ordinary workflow tool or +authorized Sub Agent task. + +#### Limits and authorization + +The existing static plan cap still limits authored logical tasks. In addition, +the runtime maintains a materialized-node budget: + +- each non-iterated task consumes one node; +- each `for_each` array element consumes one node, including an element later + skipped by `when`; +- an empty expansion consumes no materialized nodes; +- before scheduling any instance from an expansion, the engine rejects the + whole expansion if it would make the workflow exceed `MAX_NODES`; +- individual ready instances are scheduled under the existing + `MAX_PARALLELISM` cap. + +Counting skipped instances prevents a large collection from bypassing the node +limit through a predicate. Runtime-configurable ceilings remain out of scope for +this extension and belong to planning issue #1279. + +Every materialized instance inherits the already-validated task type and static +target. Materialization re-applies the same immutable owner policy before +dispatch as defense in depth; collection data can change arguments or Sub Agent +instructions but cannot select a different tool or specialist. Dynamic control +flow therefore does not broaden the workflow's capability grant. + +#### Stable failures + +Submission and runtime-controlled failures use the same flat error fields. +`start_workflow` preserves the current top-level `"error": ""` field +for compatibility and adds `error_code` plus bounded context such as `node_id` +and `path`. Runtime-controlled failures add `failed: true` and partial `results` +to the same shape; the shared status adapter exposes that terminal output as +`runtime_status: "Failed"`: + +```json +{ + "failed": true, + "error": "Task 'analyze' for_each did not resolve to an array.", + "error_code": "workflow_iteration_not_array", + "node_id": "analyze", + "path": "${discover.result.pull_requests}", + "results": {"discover": {"pull_requests": "omitted from this example"}} +} +``` + +The failure phase and status behavior are fixed: + +| Code | Submission validation | Runtime resolution | Status behavior | +| --- | --- | --- | --- | +| `workflow_condition_invalid` | Malformed predicate, unsupported operator, invalid literal/reference shape | Resolved predicate value is not a JSON scalar | Submission returns the flat error directly; runtime output maps to `Failed` | +| `workflow_reference_unresolved` | Unknown/non-upstream task, iteration local outside `for_each`, malformed reference | Missing dict key, invalid/out-of-range list index, or traversal through a scalar/`null` | Submission returns the flat error directly; runtime output maps to `Failed` | +| `workflow_iteration_not_array` | N/A; result type is not knowable yet | `for_each` resolves to a non-array JSON value | Runtime output maps to `Failed` | +| `workflow_node_limit_exceeded` | Authored logical task count exceeds the static limit | A resolved expansion would exceed the materialized-node budget | Submission returns the flat error directly; runtime output maps to `Failed` | + +Submission failures occur before a Durable instance is created and therefore +have no `runtime_status`. Runtime failures are observable through +`get_workflow_status`, `list_workflows`, and the HTTP status endpoint as a normal +status envelope whose `runtime_status` is `Failed` and whose `output` is the flat +failure object above. Messages may improve over time; callers key on +`error_code`. `results` contains every logical result committed before the +failure. A per-instance failure uses the runtime-owned instance id in `node_id` +(`analyze[3]`), while collection materialization and aggregation failures use the +logical id (`analyze`). +Provider, model, and tool failures remain governed by the existing sanitized +failure behavior and the separate reliable-execution work in issue #1278. + +Runtime occurrences of the four controlled failures above are returned by the +orchestrator rather than raised. `status_envelope()` and `_is_active_status()` +map `output.failed is True` to `runtime_status: "Failed"`, mirroring the existing +cooperative-cancel mapping. Existing raise-based template-resolution paths are +migrated to this single returned envelope so an unresolved runtime reference has +one stable shape whether it occurs in normal args, a Sub Agent task, `when`, or +`for_each`. Unexpected engine invariants and Activity/provider failures continue +to raise and use native Durable failure behavior. Status consumers must check +`output.failed is True` before interpreting `output` as the controlled flat +schema; other `Failed` instances retain the native/opaque Durable failure output. + +#### Structured status + +The status envelope keeps its existing top-level fields, but `custom_status` +becomes a versioned JSON object for dynamically controlled workflows. The legacy +free-form string is status schema version 1; structured snapshots use version 2: + +```json +{ + "schema_version": 2, + "counts": { + "logical_total": 3, + "materialized_total": 4, + "completed": 2, + "skipped": 1, + "running": 1 + }, + "nodes": { + "discover": {"state": "completed"}, + "analyze": { + "state": "running", + "expanded_count": 3, + "instances": { + "analyze[0]": {"state": "completed"}, + "analyze[1]": {"state": "skipped"}, + "analyze[2]": {"state": "running"} + } + } + } +} +``` + +Logical node states are `pending`, `running`, `skipped`, `expanded`, +`aggregated`, `completed`, or `failed`; instance states omit `expanded` and +`aggregated`. A `for_each` node is `expanded` after materialization, `running` +while any runnable instance is in flight, and `aggregated` after its ordered +result array is committed. The shared status tools and HTTP endpoint pass this +object through unchanged, and the built-in UI renders the states rather than +parsing progress text. Static v1 workflows may continue returning their current +string `custom_status`; clients must accept either shape during the experimental +compatibility window. + +#### Sample + +The Dynamic Workflow sample for this extension must demonstrate: + +1. a discovery tool returning a bounded JSON array; +2. one `for_each` tool or Sub Agent node whose predicate skips at least one item; +3. a downstream task consuming the ordered aggregate via the logical node id; +4. status output showing expanded, running, skipped, and aggregated states; and +5. deterministic completion on both Azure Storage and DTS Durable backends. + ## 5. Decisions log | # | Decision | Options considered | Choice | Decided by | Date | @@ -436,6 +738,18 @@ 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 dynamic control flow | Create a separate FRD / evolve FRD 0004 | Evolve FRD 0004 because conditions and iteration extend the existing workflow plan and engine contract | Human | 2026-08-13 | +| 25 | Condition surface | General expression string / JSON predicate object / boolean-only reference | Use a constrained JSON predicate with scalar `equals` / `not_equals`; reject missing paths and type mismatches | Agent | 2026-08-13 | +| 26 | Iteration surface | Embedded loop expression / `for_each` full array reference / generated child plan | Use one `for_each` upstream-array reference with fixed `${item}` and `${index}` locals | Agent | 2026-08-13 | +| 27 | Dynamic instance identity | Value hash / random id / source index | Derive runtime-only `[]` ids from source order | Agent | 2026-08-13 | +| 28 | Fan-in result | Completion-order list / keyed object / source-aligned envelopes | Aggregate source-ordered `{index, status, result}` envelopes under the logical node id | Agent | 2026-08-13 | +| 29 | Skipped dependency behavior | Auto-propagate / block descendants / explicit descendant conditions | Do not auto-propagate; satisfy dependencies with `null`, require each conditional descendant to declare `when`, and fail controlled dotted traversal below `null` | Agent | 2026-08-13 | +| 30 | Dynamic resource accounting | Count only executed Activities / count every materialized item / separate unlimited expansion | Count every materialized item, including skipped items, against `MAX_NODES`; retain `MAX_PARALLELISM` | Agent | 2026-08-13 | +| 31 | Dynamic status contract | Continue free-form strings / event log / versioned structured snapshot | Add a versioned `custom_status` object while accepting legacy strings for static plans | Agent | 2026-08-13 | +| 32 | Controlled error compatibility | Replace the error shape / messages only / stable code alongside existing shape | Preserve the existing error message field and add stable codes plus bounded context | Agent | 2026-08-13 | +| 33 | Iterated wait tasks | Permit identical timers / template deadlines / reject iteration | Reject `for_each` on `wait`; keep `when` available for conditional waits | Agent | 2026-08-13 | +| 34 | Controlled runtime failure provenance | Raise native Durable failure / return envelope and status-map / Activity wrapper | Return one stable envelope and map `output.failed` to `Failed`; reserve native raises for unexpected and Activity failures | Agent | 2026-08-13 | +| 35 | Dynamic instance namespace | Permit all authored ids / escape collisions / reserve bracket suffixes | Restrict authored ids to letters, numbers, underscore, and hyphen; reserve `[index]` suffixes for runtime instances | Agent | 2026-08-13 | ## 6. Test plan @@ -491,6 +805,54 @@ 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. +- [ ] Evolution #1276: schema and validation + - accept optional `when` on every task type and `for_each` on tool/Sub Agent + tasks; + - reject unsupported operators, malformed/local references outside iteration, + non-upstream references, templated targets, nested iteration, and iterated + waits; + - reject authored task ids outside letters, numbers, underscore, and hyphen so + they cannot collide with runtime `[index]` instance ids; + - preserve unchanged model dumps and wire payloads for static v1 plans. +- [ ] Evolution #1276: deterministic execution + - replay produces identical instance ids, ordering, scheduling waves, skip + decisions, and aggregate results; + - numeric scheduling order remains source-aligned across index 9/10 and later + parallelism waves; + - empty, singleton, duplicate-value, mixed-type, and maximum-size arrays behave + deterministically; + - skip does not propagate, full skipped-result references resolve to `null`, + and dotted traversal below a skipped result fails with a stable code; + - `when` is evaluated before executable args/task templates, so invalid unused + fields on a skipped instance are not resolved; + - collection/type/path failures produce one returned controlled-failure + envelope and status-map to `Failed`. +- [ ] Evolution #1276: stable failure phases + - submission validation and runtime-controlled failures use the same flat + `error` / `error_code` / bounded-context fields; + - each stable code is exercised in every applicable phase, and no Durable + instance is created for submission failures; + - runtime-controlled failures are returned, mapped to `Failed`, and exposed + unchanged by tool and HTTP status surfaces; + - per-instance failures report the runtime instance id, preserve completed + logical results, and remain distinguishable from opaque native Durable + failures. +- [ ] Evolution #1276: limits and authorization + - expansion is rejected atomically before dispatch when the materialized node + budget would exceed `MAX_NODES`; + - skipped instances count against the node budget and runnable instances obey + `MAX_PARALLELISM`; + - every expanded tool and Sub Agent instance reuses the immutable owner policy + and cannot template its target. +- [ ] Evolution #1276: status and UI + - status snapshots expose skipped, expanded, running, aggregated, and failed + nodes/instances; + - tools, HTTP status routes, and the built-in UI accept both legacy string and + versioned object `custom_status` values. +- [ ] Evolution #1276: sample/E2E + - a sample discovers a collection, dynamically fans out, skips one item, and + aggregates results; + - the scenario completes with deterministic output on Azure Storage and DTS. ## 7. Docs impact @@ -511,6 +873,12 @@ 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. +- [ ] Evolution #1276: update `docs/workflows.md` with `when`, `for_each`, + iteration locals, fan-in, limits, stable failures, and status examples. +- [ ] Evolution #1276: update `docs/architecture.md` for runtime materialization, + deterministic scheduling, and structured status hand-off. +- [ ] Evolution #1276: update the selected workflow sample and its README with a + collection-driven fan-out/fan-in scenario. ## 8. Status & sign-off @@ -532,3 +900,11 @@ 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. +- **Dynamic control flow extension:** Drafted for planning issue #1276 on + 2026-08-13. An independent architecture review identified skip propagation, + numeric instance ordering, and controlled-failure provenance as blocking + ambiguities; this draft now defines each explicitly and also clarifies static + serialization, iteration-local parsing, status schema versioning, and iterated + waits. Human sign-off is pending; no product implementation may begin until + Decisions 25-35 are accepted or revised and this FRD returns to + `status: Finalized`. diff --git a/docs/frds/README.md b/docs/frds/README.md index 926b3fd..3f4062c 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -32,7 +32,7 @@ The full lifecycle that produces an FRD lives in [`AGENTS.md`](https://github.co | [0001](0001-agents-folder-indexing.md) | agents/ folder indexing | Finalized | | [0002](0002-skill-includes.md) | Skill file includes | Finalized | | [0003](0003-runtime-observability.md) | Runtime-owned observability (OpenTelemetry) | Finalized | -| [0004](0004-dynamic-workflows.md) | Dynamic workflows | Finalized | +| [0004](0004-dynamic-workflows.md) | Dynamic workflows | In review | | [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 | From 07c033218a7301e6a004561644944c3a3b9048d3 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Thu, 13 Aug 2026 19:30:40 -0700 Subject: [PATCH 2/5] docs: link dynamic control flow review Record the draft pull request in FRD 0004 metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10f8ebc4-9f18-4717-983e-4e9b8ec5eb55 --- docs/frds/0004-dynamic-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index 311693a..be9cf27 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -6,7 +6,7 @@ author: TsuyoshiUshio created: 2026-07-06 updated: 2026-08-13 issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108, https://github.com/Azure/azure-functions-bucees-planning/issues/1276] -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/163] --- # FRD 0004 — Dynamic workflows From 98d956e7ba92fc40ec1c278237e09b1b1658ffed Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 14 Aug 2026 16:28:32 -0700 Subject: [PATCH 3/5] docs: visualize workflow control flow Add a reviewer-oriented Mermaid comparison of existing static DAGs and the proposed data-driven for_each, when, and ordered fan-in flow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10f8ebc4-9f18-4717-983e-4e9b8ec5eb55 --- docs/frds/0004-dynamic-workflows.md | 73 +++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index be9cf27..a8008cb 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -449,6 +449,63 @@ current validation, scheduling, result, and status behavior. The optional fields are omitted with exclude-unset/exclude-none serialization when absent so static plan model dumps and Durable wire payloads do not gain `null` fields. +#### Before and after + +The diagram contrasts the static fan-out/fan-in already supported before this +extension with the data-driven flow proposed here. Blue nodes are existing +capabilities; green and amber nodes are new in Issue #1276. + +```mermaid +flowchart TB + subgraph BEFORE["Before Issue #1276 — static DAG (already supported)"] + direction LR + B0["LLM authors every task
and every dependency"]:::existing + B1["analyze_pr_a"]:::existing + B2["analyze_pr_b"]:::existing + B3["analyze_pr_c"]:::existing + B4["summarize
fixed fan-in"]:::existing + + B0 --> B1 + B0 --> B2 + B0 --> B3 + B1 --> B4 + B2 --> B4 + B3 --> B4 + end + + subgraph AFTER["With Issue #1276 — data-driven DAG"] + direction LR + A0["LLM authors logical tasks only
discover → analyze → summarize"]:::existing + A1["discover result
[PR A, PR B, PR C]"]:::existing + A2["Runtime resolves for_each
checks owner policy + node budget"]:::new + A3["analyze[0]
when = true → run"]:::new + A4["analyze[1]
when = false → skipped"]:::skipped + A5["analyze[2]
when = true → run"]:::new + A6["analyze logical result
ordered [0, 1, 2] aggregate"]:::new + A7["summarize
consumes ${analyze.result}"]:::existing + + A0 --> A1 --> A2 + A2 --> A3 + A2 --> A4 + A2 --> A5 + A3 --> A6 + A4 --> A6 + A5 --> A6 + A6 --> A7 + end + + classDef existing fill:#dbeafe,stroke:#2563eb,color:#172554 + classDef new fill:#dcfce7,stroke:#16a34a,color:#052e16 + classDef skipped fill:#fef3c7,stroke:#d97706,color:#451a03 +``` + +| Before this extension | Added by Issue #1276 | +| --- | --- | +| The LLM enumerates every concrete task id before submission. | The LLM authors one logical `for_each` task; the runtime creates bounded `[index]` instances. | +| Parallel roots and fixed `depends_on` fan-in are supported. | Fan-out size comes from an upstream JSON array at runtime. | +| Every ready task runs. | Constrained `when` predicates can skip a logical task or individual instance. | +| Downstream templates reference separately authored task results. | The logical task exposes one source-ordered aggregate, including explicit skipped positions. | + #### Pipeline mapping | Pipeline stage | Module(s) | Change | @@ -549,14 +606,14 @@ Materialized instance ids are runtime-owned and use visible in status and diagnostics but cannot appear in authored `depends_on` or template references. Authored task ids continue to allow letters, numbers, underscore, and hyphen only; `[` and `]` are rejected, reserving the rendered -instance-id namespace for the runtime. Materialization and scheduling always use the numeric -`(logical_task_id, index)` tuple as the ordering key, with logical task id as the -outer key when multiple tasks become ready together. The scheduler must not sort -the rendered instance-id strings because `analyze[10]` sorts before `analyze[2]` -lexicographically and would violate source-index wave selection even though that -string order is itself replay-deterministic. These rules make the same persisted -inputs and upstream results produce the same instance ids and Durable scheduling -history on replay. +instance-id namespace for the runtime. Materialization and scheduling always use +the numeric `(logical_task_id, index)` tuple as the ordering key, with logical +task id as the outer key when multiple tasks become ready together. The scheduler +must not sort the rendered instance-id strings because `analyze[10]` sorts before +`analyze[2]` lexicographically and would violate source-index wave selection even +though that string order is itself replay-deterministic. These rules make the +same persisted inputs and upstream results produce the same instance ids and +Durable scheduling history on replay. An empty array is valid: no instances run, the logical node immediately becomes `aggregated`, and its result is `[]`. From ce1520d3d9028cc2b35062723e14f4157a5f1d0b Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 14 Aug 2026 16:38:17 -0700 Subject: [PATCH 4/5] docs: finalize dynamic control flow design Record human approval of FRD 0004 Decisions 25-35 and open the implementation gate for planning issue 1276. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10f8ebc4-9f18-4717-983e-4e9b8ec5eb55 --- docs/frds/0004-dynamic-workflows.md | 13 ++++++++----- docs/frds/README.md | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/frds/0004-dynamic-workflows.md b/docs/frds/0004-dynamic-workflows.md index a8008cb..91e2e0f 100644 --- a/docs/frds/0004-dynamic-workflows.md +++ b/docs/frds/0004-dynamic-workflows.md @@ -1,10 +1,10 @@ --- frd: 0004 title: Dynamic workflows -status: In review +status: Finalized author: TsuyoshiUshio created: 2026-07-06 -updated: 2026-08-13 +updated: 2026-08-14 issues: [https://github.com/Azure/azure-functions-agents-runtime/issues/108, https://github.com/Azure/azure-functions-bucees-planning/issues/1276] 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/163] --- @@ -807,6 +807,7 @@ The Dynamic Workflow sample for this extension must demonstrate: | 33 | Iterated wait tasks | Permit identical timers / template deadlines / reject iteration | Reject `for_each` on `wait`; keep `when` available for conditional waits | Agent | 2026-08-13 | | 34 | Controlled runtime failure provenance | Raise native Durable failure / return envelope and status-map / Activity wrapper | Return one stable envelope and map `output.failed` to `Failed`; reserve native raises for unexpected and Activity failures | Agent | 2026-08-13 | | 35 | Dynamic instance namespace | Permit all authored ids / escape collisions / reserve bracket suffixes | Restrict authored ids to letters, numbers, underscore, and hyphen; reserve `[index]` suffixes for runtime instances | Agent | 2026-08-13 | +| 36 | Dynamic control-flow design approval | Revise individual Decisions 25-35 / approve the proposed set | Approve Decisions 25-35 as proposed and advance to implementation | Human (TsuyoshiUshio) | 2026-08-14 | ## 6. Test plan @@ -962,6 +963,8 @@ The Dynamic Workflow sample for this extension must demonstrate: numeric instance ordering, and controlled-failure provenance as blocking ambiguities; this draft now defines each explicitly and also clarifies static serialization, iteration-local parsing, status schema versioning, and iterated - waits. Human sign-off is pending; no product implementation may begin until - Decisions 25-35 are accepted or revised and this FRD returns to - `status: Finalized`. + waits. A final independent review found no blocking issues and deemed the + extension ready for human review. +- **Dynamic control flow human sign-off:** TsuyoshiUshio, 2026-08-14. Approved + Decisions 25-35 as proposed and authorized implementation and testing. FRD + status returned to `Finalized`. diff --git a/docs/frds/README.md b/docs/frds/README.md index 3f4062c..926b3fd 100644 --- a/docs/frds/README.md +++ b/docs/frds/README.md @@ -32,7 +32,7 @@ The full lifecycle that produces an FRD lives in [`AGENTS.md`](https://github.co | [0001](0001-agents-folder-indexing.md) | agents/ folder indexing | Finalized | | [0002](0002-skill-includes.md) | Skill file includes | Finalized | | [0003](0003-runtime-observability.md) | Runtime-owned observability (OpenTelemetry) | Finalized | -| [0004](0004-dynamic-workflows.md) | Dynamic workflows | In review | +| [0004](0004-dynamic-workflows.md) | Dynamic workflows | Finalized | | [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 | From 9cef985a2f5dc73286f70b68df6f56122275eb50 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 14 Aug 2026 17:51:48 -0700 Subject: [PATCH 5/5] feat: add dynamic workflow control flow Implement deterministic when and bounded for_each execution with ordered aggregation, structured status, stable failures, UI rendering, documentation, and sample coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10f8ebc4-9f18-4717-983e-4e9b8ec5eb55 --- docs/architecture.md | 46 +- docs/workflows.md | 234 ++++- samples/workflow-incident-triage/README.md | 50 +- .../src/main.agent.md | 39 +- .../src/tools/incident_tools.py | 175 ++++ src/azure_functions_agents/public/index.html | 75 +- .../workflows/engine.py | 849 +++++++++++++---- .../workflows/integration.py | 29 + .../workflows/schema.py | 418 ++++++++- src/azure_functions_agents/workflows/tools.py | 91 +- tests/test_chat_ui.py | 173 +++- tests/test_incident_tools.py | 80 ++ tests/test_workflow_engine.py | 860 +++++++++++++++++- tests/test_workflow_registry.py | 214 +++++ tests/test_workflow_schema.py | 298 ++++++ 15 files changed, 3374 insertions(+), 257 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 492114b..2998c6c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,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/*` | 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/*` | 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. Also owns data-driven control flow — `when` predicate evaluation and bounded `for_each` materialization, deterministic instance ordering, ordered aggregation, structured (`schema_version: 2`) status, and controlled-failure normalization to `runtime_status: "Failed"`. | `register_workflows()`, `build_workflow_integration()`, `WorkflowPlanPolicy`, `validate_plan()` | | `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()` | @@ -173,9 +173,13 @@ Registration does not run the agent itself. Instead, `registration/_handlers.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, +and is captured by `start_workflow` for runtime authorization. The policy is +also **persisted** with each submitted plan (it is serialized into the Durable +client input) so the orchestrator re-validates every materialized `for_each` +instance's static target against the identical owner boundary as defense in +depth — dynamic control flow never broadens the capability grant. 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. @@ -183,6 +187,40 @@ re-parse workflow metadata. 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. +### Static vs. data-driven execution + +The orchestrator serves two plan shapes from one Durable blueprint. A plan +with no `when` / `for_each` fields takes the **static** scheduler path +unchanged: wave-based `depends_on` scheduling and a legacy string +`custom_status`, exactly as before Issue #1276. A plan using either field +takes the **dynamic** path, which layers four deterministic stages over the +same DAG: + +- **materialize** — resolve each `for_each` value to a JSON array and create + one instance per element as `[]`; reject the whole + expansion atomically if it would exceed the materialized-node budget + (skipped instances still count). +- **evaluate** — bind `${item}` / `${item.path}` / `${index}`, evaluate the + `when` predicate *before* resolving executable `args` / Sub Agent `task` + templates, and mark false predicates `skipped` with a `null` result that + still satisfies downstream `depends_on`. +- **schedule** — dispatch runnable instances under `MAX_PARALLELISM`, ordered + by the numeric `(logical-id, index)` tuple (never the rendered string) so + replay reproduces identical waves. +- **aggregate** — once every instance of a logical node is terminal, commit + one source-ordered array of `{index, status, result}` envelopes under the + logical id for downstream consumption. + +Progress is published as a structured `schema_version: 2` `custom_status` +snapshot (logical node states plus per-instance state). The four controlled +control-flow failures (`workflow_condition_invalid`, +`workflow_reference_unresolved`, `workflow_iteration_not_array`, +`workflow_node_limit_exceeded`) are **returned** as a flat `failed: true` +envelope rather than raised; `status_envelope()` and `_is_active_status()` +normalize `output.failed is True` to `runtime_status: "Failed"`, while +unexpected engine invariants and Activity/provider errors keep Durable's +native failure behavior. + ### 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. diff --git a/docs/workflows.md b/docs/workflows.md index 7fac994..7c79f1d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -258,13 +258,122 @@ hardening controls. } ``` +Authored task ids allow letters, numbers, underscore, and hyphen only. +`[` and `]` are rejected — the runtime reserves the `[]` +namespace for the materialized `for_each` instance ids it renders (see +below), so you can neither author them nor reference them. + +### Data-driven control flow (`when` / `for_each`) + +Two optional fields let a plan react to data at runtime instead of the +model enumerating every task before submission. Plans that omit both keep +their exact prior validation, scheduling, result, and status behavior; the +fields are dropped from serialized plans when unset. + +- **`when`** — a constrained predicate that decides whether a logical task + (or one materialized `for_each` instance) runs. It is available on every + task type, including `wait` and `sub_agent`. +- **`for_each`** — a single full reference to an upstream JSON array. The + runtime materializes one instance of the task per element. It is available + on `tool` and `sub_agent` tasks only; `wait` tasks may use `when` but not + `for_each`. + +```json +{ + "tasks": [ + { "id": "discover", "type": "tool", "tool": "list_services" }, + { + "id": "inspect", + "type": "tool", + "tool": "inspect_service", + "args": {"service": "${item.name}", "position": "${index}"}, + "depends_on": ["discover"], + "for_each": "${discover.result.services}", + "when": {"ref": "${item.in_scope}", "operator": "equals", "value": true} + }, + { + "id": "summarize", + "type": "tool", + "tool": "summarize_scan", + "args": {"findings": "${inspect.result}"}, + "depends_on": ["inspect"] + } + ] +} +``` + +**`when` contract.** `when` is `{"ref", "operator", "value"}`: + +- `ref` is one full reference — an upstream `${node.result...}` or, inside a + `for_each` task, an iteration local (`${item}`, `${item.path}`, `${index}`). +- `operator` is exactly `equals` or `not_equals`. +- `value` is a JSON scalar (`null`, boolean, number, or string). +- Comparison is **strict, type-sensitive JSON scalar equality** — no + coercion, truthiness, ordering, regex, boolean composition, or runtime + state access. A missing path, malformed reference, non-scalar resolved + value, or unsupported operator is an error; it never silently evaluates + to false. + +`when` is evaluated *before* a task's executable `args` (or a Sub Agent's +`task`) template is resolved, so a skipped task never needs valid value +fields. A false predicate marks the task or instance **`skipped`**, +schedules no Activity/timer, and produces `null` for that result position. + +Skip does **not** propagate. A skipped task still satisfies downstream +`depends_on` edges, and a full `${skipped.result}` reference resolves to +`null`; a descendant that should also be conditional must declare its own +`when`. Traversing *below* a skipped result (`${skipped.result.field}`) +fails deterministically because `null` has no path. + +**`for_each` contract.** The value must resolve to a JSON array. The task's +target (`tool` or `agent`) stays static and is validated against the owner +policy before the workflow starts — collection data can change arguments or +a Sub Agent instruction but never selects a different tool or specialist. +Only value fields may use the iteration locals: + +- `${item}` — the current element with its native JSON type. +- `${item.path.to.field}` — a field of the element, using the same dotted + traversal rules as upstream result templates. +- `${index}` — the zero-based integer index. + +Iteration locals are rejected outside a `for_each` task. Nested `for_each`, +aliases, cross-instance references, item-dependent `depends_on`, and +templated tool/agent names are not supported. + +Materialized instance ids are runtime-owned and rendered as +`[]` (e.g. `inspect[0]`). They appear in status and +diagnostics but cannot be authored or referenced. Scheduling always orders +by the numeric `(logical-id, index)` tuple — never by the rendered string — +so `inspect[10]` never jumps ahead of `inspect[2]`. + +**Ordered aggregation.** A `for_each` logical node completes only after all +its instances complete or skip. Its result is one array aligned to the +source collection — never to completion order: + +```json +[ + {"index": 0, "status": "completed", "result": {"summary": "ready"}}, + {"index": 1, "status": "skipped", "result": null}, + {"index": 2, "status": "completed", "result": {"summary": "degraded"}} +] +``` + +A downstream task depends on the logical id (`"depends_on": ["inspect"]`) +and consumes the whole aggregate with `${inspect.result}`, or reads a known +position with the dotted/list-index syntax. It cannot depend on or reference +an individual instance id. An empty array is valid: no instances run, the +node becomes `aggregated` immediately, and its result is `[]`. This is +aggregation of already-completed results, not a reducer language — domain +reduction stays an ordinary tool or Sub Agent task. + ### Workflow Sub Agents The author grants access in `main.agent.md` 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 model then generates a `sub_agent` DAG node with `id`, `type`, `agent`, +`task`, optional `depends_on`, and the optional data-driven `when` / +`for_each` fields described above. A `sub_agent` node does not accept `tool`, +`args`, `duration`, or `until`. The runtime validates every specialist slug against the workflow owner's immutable grant before any node is scheduled and fails closed if the specialist is unavailable. @@ -320,6 +429,12 @@ time; if a key or list index is missing, the workflow fails with a deterministic template-resolution error that identifies the task and path segment that could not be resolved. +Inside a `for_each` task, value fields may additionally use the iteration +locals `${item}`, `${item.path.to.field}`, and `${index}` (see +[data-driven control flow](#data-driven-control-flow-when--for_each)). Every +other `${...}` shape is rejected, so an unmatched or malformed reference +fails loudly rather than passing through as a literal. + ### Caps Enforced during plan validation and at runtime: @@ -332,6 +447,15 @@ Enforced during plan validation and at runtime: | `max_active_workflows_per_session` | 10 | | `max_list_workflows_results` | 25 | +`max_nodes` limits authored logical tasks *and* materialized `for_each` +instances. Every array element consumes one node — **including an element +later skipped by `when`** — so a large collection cannot bypass the limit +through a predicate. Before scheduling any instance, the runtime rejects a +whole expansion atomically if it would exceed `max_nodes` +(`workflow_node_limit_exceeded`). An empty expansion consumes no nodes. Keep +iterated arrays bounded upstream. `max_parallelism` still caps how many ready +instances run concurrently. + Future v2 hardening adds configurable frontmatter caps, per-tool timeout caps, retry policy, storage hygiene, and large-output offloading. @@ -339,11 +463,52 @@ caps, retry policy, storage hygiene, and large-output offloading. The orchestrator holds these invariants: -- Ready tasks are scheduled in a deterministic order (sorted by task id). +- Ready tasks are scheduled in a deterministic order. Non-iterated tasks + sort by task id; `for_each` instances schedule by the numeric + `(logical-id, index)` tuple, never by the rendered instance-id string. +- The same persisted inputs and upstream results reproduce identical + instance ids, scheduling waves, skip decisions, and ordered aggregates on + replay. - Time-dependent logic uses `context.current_utc_datetime` only. - Activity results must be JSON-serializable; non-serializable results cause a hard, deterministic failure. -- Templating is evaluated over JSON-normalized prior outputs. +- Templating and `when` comparisons are evaluated over JSON-normalized prior + outputs. + +### Controlled runtime failures + +Four control-flow failures are **returned** by the orchestrator as a stable +flat object rather than raised, so they surface as an ordinary status +envelope. The object has `failed: true`, a human `error` message, a stable +`error_code`, bounded context (`node_id`, `path`), and the `results` +committed before the failure: + +```json +{ + "failed": true, + "error": "Task 'inspect' for_each did not resolve to an array.", + "error_code": "workflow_iteration_not_array", + "node_id": "inspect", + "path": "${discover.result.services}", + "results": {"discover": {"...": "..."}} +} +``` + +| `error_code` | Meaning | +|---|---| +| `workflow_condition_invalid` | Malformed predicate, unsupported operator, or a resolved predicate value that is not a JSON scalar. | +| `workflow_reference_unresolved` | Unknown/non-upstream reference, an iteration local outside `for_each`, a missing key/out-of-range index, or traversal through a scalar/`null`. | +| `workflow_iteration_not_array` | A `for_each` value resolved to a non-array. | +| `workflow_node_limit_exceeded` | A resolved expansion would exceed `max_nodes`. | + +The shared status adapter maps `output.failed is True` to +`runtime_status: "Failed"`. Callers key on `error_code` (messages may +change) and must check `output.failed is True` before reading `output` as +this flat schema — other `Failed` instances keep Durable's native opaque +output. A per-instance failure uses the runtime-owned instance id in +`node_id` (e.g. `inspect[3]`); materialization/aggregation failures use the +logical id. Provider, model, and tool failures keep their existing sanitized +behavior and are tracked separately by issue #1278. ## Status envelope @@ -367,6 +532,51 @@ consume a single contract: external poller render against. `output` is populated only when the workflow has reached a terminal state and (for cooperative cancel) includes any partial results gathered before the cancel signal landed. +For a controlled runtime failure, `output` is the flat failure object +documented under [controlled runtime failures](#controlled-runtime-failures) +and `runtime_status` is `Failed`. + +### `custom_status` schema versions + +`custom_status` has two accepted shapes; clients must accept **either** +during the experimental compatibility window: + +- **Schema version 1** — a free-form string, as shown above. Static plans + (no `when` / `for_each`) keep returning it. +- **Schema version 2** — a structured JSON object emitted by dynamically + controlled workflows. The status tools and HTTP endpoint pass it through + unchanged; the built-in UI renders its states rather than parsing text. + +```json +{ + "schema_version": 2, + "counts": { + "logical_total": 3, + "materialized_total": 4, + "completed": 2, + "skipped": 1, + "running": 1 + }, + "nodes": { + "discover": {"state": "completed"}, + "inspect": { + "state": "running", + "expanded_count": 3, + "instances": { + "inspect[0]": {"state": "completed"}, + "inspect[1]": {"state": "skipped"}, + "inspect[2]": {"state": "running"} + } + } + } +} +``` + +Logical node states are `pending`, `running`, `skipped`, `expanded`, +`aggregated`, `completed`, or `failed`; instance states omit `expanded` and +`aggregated`. A `for_each` node is `expanded` after materialization, +`running` while any instance is in flight, and `aggregated` once its ordered +result array is committed. ## Completion delivery @@ -484,8 +694,11 @@ existence cannot be probed by guessing IDs across sessions). `host.json` is configured with the DTS `storageProvider`, each workflow appears as a queryable instance with per-task state and retry history. -- **`customStatus`** — the orchestration emits a concise summary - (`"3/7 tasks done, current=summarize"`) for low-cost polling. +- **`custom_status`** — the orchestration emits a low-cost polling summary. + Static plans return a concise string (`"3/7 tasks done, current=summarize"`); + dynamically controlled plans return the structured `schema_version: 2` + snapshot (see [status envelope](#custom_status-schema-versions)) with + per-node and per-instance state. ## Requirements @@ -505,7 +718,12 @@ v1 includes: - 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`; -- result templating with `${node_id.result}` and dotted paths; +- data-driven control flow: constrained `when` predicates and bounded + `for_each` iteration with ordered `{index, status, result}` aggregation; +- result templating with `${node_id.result}`, dotted paths, and the + `${item}` / `${item.path}` / `${index}` iteration locals; +- structured `schema_version: 2` status snapshots alongside legacy string + `custom_status`; - cooperative cancel and hard terminate; - live progress in the built-in chat UI; - workflow starts from supported Markdown-declared triggers; diff --git a/samples/workflow-incident-triage/README.md b/samples/workflow-incident-triage/README.md index ff55946..3a60a24 100644 --- a/samples/workflow-incident-triage/README.md +++ b/samples/workflow-incident-triage/README.md @@ -17,8 +17,13 @@ feature design. This sample exercises the public experimental v1 workflow surface: workflow-safe custom tools, LLM-authored DAGs, fan-out/fan-in, result templating, durable timers, cooperative cancel, live-progress chat UI, and -the optional Durable Task Scheduler backend. It deliberately does not -demonstrate v2 features such as sub-orchestrations or sub-agent tasks. +the optional Durable Task Scheduler backend. It also demonstrates the +data-driven control flow from Issue #1276: a discovery tool returns a +bounded array, a per-service task fans out over it with `for_each`, an +item-level `when` predicate skips out-of-scope services, and a downstream +task consumes the ordered `{index, status, result}` aggregate. It +deliberately does not demonstrate v2 features such as sub-orchestrations or +sub-agent tasks. ## Run locally @@ -147,7 +152,7 @@ Restart `func start` after any swap so the host reloads `host.json`. ## Workflow-safe tools registered by this sample -`src/tools/incident_tools.py` defines four synthetic-but-realistic +`src/tools/incident_tools.py` defines seven 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`. @@ -161,11 +166,16 @@ from that module: | `fetch_metrics` | `{service, window_minutes?: int = 30}` | `{service, window_minutes, cpu_p99, memory_p99, latency_p99_ms, saturation}` | | `fetch_deploys` | `{service, lookback_hours?: int = 24}` | `{service, lookback_hours, deploys: [{id, actor, summary, minutes_ago}]}` | | `summarize_findings` | `{logs, metrics, deploys, service?}` (consume whole `${node.result}` values) | `{service, likely_cause, confidence: 'low'\|'medium'\|'high', evidence: [str], recommended_action}` | +| `discover_services` | `{incident}` | `{incident, count, services: [{name, tier, in_scope}]}` (bounded 3–5, low-tier items `in_scope: false`) | +| `inspect_service` | `{service, index?: int = 0}` | `{service, index, errors, saturation, healthy, headline}` | +| `summarize_scan` | `{incident?, findings}` (whole ordered `${node.result}` aggregate of `{index, status, result}` envelopes) | `{incident, scanned, skipped, unhealthy: [str], headline}` | Outputs are deterministic functions of inputs so the demo narrative is -reproducible across runs and replays. The summary tool deliberately -consumes the whole upstream result via `${node.result}` — there is no +reproducible across runs and replays. The summary tools deliberately +consume the whole upstream result via `${node.result}` — there is no need (and no benefit) to drill into nested paths from the plan. +`discover_services` always returns at least one out-of-scope service so +the data-driven `when` demo has an item to skip. ## Demo prompt @@ -198,6 +208,36 @@ to `Canceled`, and the auto-notification kicks in so the agent acknowledges the cancellation in its own turn (with whatever partial results were already gathered). +## Demo prompt (data-driven scan) + +To exercise the Issue #1276 control flow, don't name a service — let the +workflow discover and fan out over them: + +> *"Something is degrading across our platform but I'm not sure which +> services are involved. Discover the affected services, inspect each one +> that's in scope, and give me a consolidated summary."* + +The agent should: + +1. Author a three-task workflow: a `discover_services` task, one + `inspect_service` task fanned out with `for_each: ${discover.result.services}` + and an item-level `when: {ref: ${item.in_scope}, operator: equals, value: true}` + (using `${item.name}` / `${index}` for its args), and a final + `summarize_scan` task that depends on the logical `inspect` id and consumes + its whole ordered aggregate via `${inspect.result}`. +2. Call `start_workflow` and hand off to the live-progress card. The card's + structured status shows the fan-out expanding (e.g. `inspect: expanded (3/4)`) + with one instance `skipped` — the out-of-scope low-tier service. +3. `summarize_scan` receives the ordered `{index, status, result}` envelopes + (with `result: null` at the skipped position), reports how many services + were scanned vs. skipped, and the workflow reaches `Completed`. The same + auto-notification loop closes the summary inline. + +If a discovery, inspection, or aggregation step hits a controlled error +(for example an out-of-range fan-out or an unresolved reference), the engine +returns a stable failure envelope and the workflow surfaces as `Failed` +rather than crashing the host — the live card reflects that terminal state. + ## Demo dry-run script `scripts/demo.ps1` is a presenter aid. It auto-detects the active diff --git a/samples/workflow-incident-triage/src/main.agent.md b/samples/workflow-incident-triage/src/main.agent.md index 33542dc..1403c65 100644 --- a/samples/workflow-incident-triage/src/main.agent.md +++ b/samples/workflow-incident-triage/src/main.agent.md @@ -15,7 +15,11 @@ For each incident, think through: - how long to wait before looking — some signals only settle after in-flight work drains, - what the written deliverable should contain: likely cause, supporting evidence, confidence level, and a recommended next action. -When the work justifies it (multiple evidence sources, a settling delay, or a multi-step correlation), drive it as a workflow. Typical shape: +When the work justifies it (multiple evidence sources, a settling delay, or a multi-step correlation), drive it as a workflow. Two shapes are useful: + +### Static evidence gathering (single service) + +When the incident clearly names one service: 1. Fan out `fetch_logs`, `fetch_metrics`, and `fetch_deploys` for the affected service in parallel (no `depends_on` between them so they run concurrently). 2. If you want to let in-flight work drain before correlating, add a `wait` task with a short `duration` (e.g. `PT30S`) that depends on the three fetches. @@ -29,3 +33,36 @@ When the work justifies it (multiple evidence sources, a settling delay, or a mu ``` Do not pre-extract fields with `${...result.path}` — `summarize_findings` consumes the whole upstream result and unpacks them itself. + +### Collection-driven scan (unknown or multiple services) + +When you don't know which services are involved, let the workflow discover and fan out over them instead of naming each one: + +1. A `discover_services` task takes the `incident` text and returns a bounded `services` array; low-tier services come back with `in_scope: false`. +2. One logical `inspect_service` task fanned out with `for_each` over that array, skipping out-of-scope items with an item-level `when`. Reference the current element with `${item.*}` and `${index}`: + + ``` + id: inspect + type: tool + tool: inspect_service + depends_on: [discover] + for_each: ${discover.result.services} + when: { ref: ${item.in_scope}, operator: equals, value: true } + args: + service: ${item.name} + index: ${index} + ``` + +3. A final `summarize_scan` task depends on the logical `inspect` id and consumes its whole ordered aggregate — a list of `{index, status, result}` envelopes in source order, with `result: null` for skipped positions: + + ``` + id: summarize + type: tool + tool: summarize_scan + depends_on: [inspect] + args: + incident: + findings: ${inspect.result} + ``` + + Depend on the logical `for_each` id (`inspect`), never an individual `inspect[0]` instance — those are runtime-owned. Pass the whole `${inspect.result}` aggregate as a single value; `summarize_scan` walks the envelopes itself. diff --git a/samples/workflow-incident-triage/src/tools/incident_tools.py b/samples/workflow-incident-triage/src/tools/incident_tools.py index fcac9c4..85a1a3b 100644 --- a/samples/workflow-incident-triage/src/tools/incident_tools.py +++ b/samples/workflow-incident-triage/src/tools/incident_tools.py @@ -34,6 +34,17 @@ "confidence": "low"|"medium"|"high", "evidence": [str], "recommended_action": str}`` + +Collection (data-driven) tools — Issue #1276: + +- ``discover_services`` → ``{"incident": str, "count": int, + "services": [{"name": str, "tier": str, + "in_scope": bool}]}`` +- ``inspect_service`` → ``{"service": str, "index": int, "errors": int, + "saturation": str, "healthy": bool, + "headline": str}`` +- ``summarize_scan`` → ``{"incident": str, "scanned": int, "skipped": int, + "unhealthy": [str], "headline": str}`` """ from __future__ import annotations @@ -266,9 +277,173 @@ def summarize_findings(args: Dict[str, Any]) -> Dict[str, Any]: } +# --------------------------------------------------------------------------- +# Data-driven (collection) workflow tools — Issue #1276. +# +# These three tools demonstrate the dynamic control-flow surface: a +# discovery tool returns a bounded JSON array, a per-item inspection tool +# is fanned out with ``for_each`` (skipping out-of-scope items with +# ``when``), and a downstream tool consumes the ordered ``{index, status, +# result}`` aggregate the logical ``for_each`` node exposes. +# --------------------------------------------------------------------------- + +# A small, deterministic service catalog. ``marketing-site`` is always +# within the first three entries so every bounded slice includes at least +# one ``low`` tier service — the item a ``when`` predicate skips. +_SERVICE_CATALOG: List[Dict[str, Any]] = [ + {"name": "orders-api", "tier": "critical"}, + {"name": "marketing-site", "tier": "low"}, + {"name": "payments-api", "tier": "critical"}, + {"name": "inventory-service", "tier": "high"}, + {"name": "docs-site", "tier": "low"}, +] + +# Hard ceiling on the fan-out so a plan built from this discovery result +# always stays well under the workflow ``max_nodes`` budget. +_MAX_DISCOVERED_SERVICES = 5 +_MIN_DISCOVERED_SERVICES = 3 + + +@workflow_tool( + description=( + "Discover the services implicated by an incident. Args: " + "{incident: str}. Returns {incident, count, services: " + "[{name, tier: 'critical'|'high'|'low', in_scope: bool}]}. The array " + "is bounded (3-5 items) and deterministic; low-tier services come back " + "with in_scope=false so a for_each plan can skip them with a `when` " + "predicate on ${item.in_scope}. Use its `services` array as the " + "for_each source for a per-service inspection task." + ) +) +def discover_services(args: Dict[str, Any]) -> Dict[str, Any]: + incident = args.get("incident") + if not isinstance(incident, str) or not incident.strip(): + raise ValueError("discover_services: 'incident' arg (string) is required") + + # Deterministic bounded slice: between _MIN and _MAX entries, keyed off + # the incident text so the same incident always yields the same fan-out. + span = _MAX_DISCOVERED_SERVICES - _MIN_DISCOVERED_SERVICES + take = _MIN_DISCOVERED_SERVICES + _seeded_int(incident + ":count", 0, span) + take = min(take, len(_SERVICE_CATALOG)) + + services: List[Dict[str, Any]] = [ + { + "name": entry["name"], + "tier": entry["tier"], + # Low-tier services are intentionally out of scope: this is the + # item the workflow's `when` predicate skips. + "in_scope": entry["tier"] != "low", + } + for entry in _SERVICE_CATALOG[:take] + ] + return {"incident": incident, "count": len(services), "services": services} + + +@workflow_tool( + description=( + "Inspect a single service for incident signal. Args: " + "{service: str, index?: int}. Intended to be fanned out with " + "for_each over discover_services' `services` array, binding " + "service=${item.name} (and optionally index=${index}). Returns " + "{service, index, errors, saturation, healthy, headline}." + ) +) +def inspect_service(args: Dict[str, Any]) -> Dict[str, Any]: + service = _require_service(args, "inspect_service") + raw_index = args.get("index") + index = int(raw_index) if isinstance(raw_index, (int, str)) and str(raw_index).lstrip("-").isdigit() else 0 + + # Reuse the existing evidence tools so per-service inspection stays a + # deterministic function of the service name. + logs = fetch_logs({"service": service}) + metrics = fetch_metrics({"service": service}) + errors = int(logs.get("errors") or 0) + saturation = str(metrics.get("saturation") or "moderate") + healthy = errors < 8 and saturation != "high" + headline = ( + f"{service}: healthy" + if healthy + else f"{service}: {errors} errors, {saturation} saturation" + ) + return { + "service": service, + "index": index, + "errors": errors, + "saturation": saturation, + "healthy": healthy, + "headline": headline, + } + + +@workflow_tool( + description=( + "Summarize a for_each service scan. Args: {incident?: str, findings: " + "}. Pass the whole ordered aggregate via " + "${inspect_node.result} — a list of {index, status, result} envelopes " + "in source order, where skipped items have result=null. Returns " + "{incident, scanned, skipped, unhealthy: [str], headline}." + ) +) +def summarize_scan(args: Dict[str, Any]) -> Dict[str, Any]: + """Consume the ordered aggregate a logical ``for_each`` node exposes. + + The aggregate is a source-ordered list of ``{index, status, result}`` + envelopes: ``status`` is ``"completed"`` or ``"skipped"`` and a skipped + position carries ``result: null``. This tool must be passed the whole + aggregate as a single ``${node.result}`` value, so it validates the + shape loudly rather than silently degrading on an embedded template ref. + """ + findings = args.get("findings") + if not isinstance(findings, list): + raise ValueError( + "summarize_scan: 'findings' must be the whole for_each aggregate " + "(use \"${inspect_node.result}\" as the entire arg value); got " + f"{type(findings).__name__}" + ) + + incident = args.get("incident") + scanned = 0 + skipped = 0 + unhealthy: List[str] = [] + # Envelopes arrive in source order; preserve it in the report. + for envelope in findings: + if not isinstance(envelope, dict): + raise ValueError( + "summarize_scan: each finding must be an {index, status, result} " + f"envelope; got {type(envelope).__name__}" + ) + status = envelope.get("status") + if status == "skipped": + skipped += 1 + continue + scanned += 1 + result = envelope.get("result") + if isinstance(result, dict) and not result.get("healthy", True): + headline = result.get("headline") + unhealthy.append(str(headline) if headline else str(result.get("service"))) + + if not scanned: + headline = "no in-scope services were inspected" + elif unhealthy: + headline = f"{len(unhealthy)} of {scanned} inspected service(s) unhealthy" + else: + headline = f"all {scanned} inspected service(s) healthy" + + return { + "incident": str(incident) if isinstance(incident, str) else "unknown-incident", + "scanned": scanned, + "skipped": skipped, + "unhealthy": unhealthy, + "headline": headline, + } + + __all__ = [ + "discover_services", "fetch_deploys", "fetch_logs", "fetch_metrics", + "inspect_service", "summarize_findings", + "summarize_scan", ] diff --git a/src/azure_functions_agents/public/index.html b/src/azure_functions_agents/public/index.html index ddab93c..ca36599 100644 --- a/src/azure_functions_agents/public/index.html +++ b/src/azure_functions_agents/public/index.html @@ -1259,6 +1259,79 @@

Agent details

return `
    ${rows}
`; } + // Format a workflow's `custom_status` for the card's status line. + // Two shapes are accepted during the experimental compatibility + // window (see docs/workflows.md "Status envelope"): + // - schema version 1: a legacy free-form string, returned verbatim. + // - schema version 2: a structured snapshot object rendered as a + // concise counts + per-node state summary. + // Returns a plain string (never HTML). The caller is responsible for + // escaping it. Never throws: unknown/object shapes degrade to JSON or + // an empty string so a future or malformed status can't break polling. + function formatWorkflowStatus(customStatus) { + // Legacy v1 static plans keep returning a free-form string; pass + // it through unchanged so their cards render exactly as before. + if (typeof customStatus === "string") return customStatus; + if (customStatus === null || typeof customStatus !== "object") return ""; + try { + if (customStatus.schema_version === 2) { + return formatDynamicWorkflowStatus(customStatus); + } + // Unknown object shape (e.g. a future schema_version): degrade + // to compact JSON rather than the "[object Object]" you'd get + // from String(...). + return JSON.stringify(customStatus); + } catch (_e) { + // A formatter or serialization error must never bubble up into + // the poll loop; fall back to empty. + return ""; + } + } + + // Render a schema_version=2 structured status object into one concise + // line: aggregate counts, then a per-logical-node state list. For + // `for_each` nodes it also surfaces the expanded instance progress so + // expanded / running / skipped / aggregated states are all visible. + function formatDynamicWorkflowStatus(status) { + const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0); + const counts = (status.counts && typeof status.counts === "object") ? status.counts : {}; + const completed = num(counts.completed); + const materialized = num(counts.materialized_total); + const logicalTotal = num(counts.logical_total); + const running = num(counts.running); + const skipped = num(counts.skipped); + // Prefer the materialized denominator (it includes expanded + // instances); fall back to the logical count for plans that + // never expanded a collection. + const denom = materialized || logicalTotal; + const parts = [`${completed}/${denom} done`]; + if (running) parts.push(`${running} running`); + if (skipped) parts.push(`${skipped} skipped`); + let summary = parts.join(" · "); + + const nodes = (status.nodes && typeof status.nodes === "object") ? status.nodes : null; + if (nodes) { + const nodeBits = []; + for (const [nodeId, node] of Object.entries(nodes)) { + if (!node || typeof node !== "object") continue; + const state = typeof node.state === "string" ? node.state : "unknown"; + let bit = `${nodeId}: ${state}`; + if (typeof node.expanded_count === "number") { + const insts = (node.instances && typeof node.instances === "object") + ? Object.values(node.instances) + : []; + const done = insts.filter( + (i) => i && typeof i === "object" && i.state === "completed" + ).length; + bit += ` (${done}/${node.expanded_count})`; + } + nodeBits.push(bit); + } + if (nodeBits.length) summary += ` — ${nodeBits.join(", ")}`; + } + return summary; + } + function renderWorkflowCard(entry, envelope) { const status = String(envelope.runtime_status || "Pending"); const terminal = TERMINAL_WORKFLOW_STATES.has(status); @@ -1273,7 +1346,7 @@

Agent details

const statusClass = knownStatuses.has(status) ? status : "Unknown"; card.className = `workflow-card terminal-${statusClass}`; - const custom = String(envelope.custom_status ?? ""); + const custom = formatWorkflowStatus(envelope.custom_status); const taskResults = envelope.output && typeof envelope.output === "object" ? envelope.output.results : null; diff --git a/src/azure_functions_agents/workflows/engine.py b/src/azure_functions_agents/workflows/engine.py index 04d3d06..fae6aac 100644 --- a/src/azure_functions_agents/workflows/engine.py +++ b/src/azure_functions_agents/workflows/engine.py @@ -35,12 +35,15 @@ from . import registry from .schema import ( ECHO_TOOL_NAME, + MAX_NODES, MAX_PARALLELISM, MAX_WAIT_DURATION, SUB_AGENT_TASK_TYPE, TOOL_TASK_TYPE, WAIT_TASK_TYPE, TemplateResolutionError, + WorkflowCondition, + evaluate_condition, parse_iso8601_datetime, parse_iso8601_duration, resolve_template_value, @@ -100,6 +103,669 @@ def _wait_deadline(context: df.DurableOrchestrationContext, task: dict[str, Any] return deadline +def _plan_is_dynamic(tasks: list[dict[str, Any]]) -> bool: + """Return whether any task opts into data-driven control flow. + + A plan is *dynamic* if any task carries a ``when`` predicate or a + ``for_each`` expansion. Fully static plans (neither field on any task) + keep the original wave scheduler with its exact string ``custom_status`` + behavior, so existing regression coverage is unchanged. + """ + return any( + task.get("when") is not None or task.get("for_each") is not None + for task in tasks + ) + + +def _run_static_workflow( + context: df.DurableOrchestrationContext, + payload: dict[str, Any], + tasks: list[dict[str, Any]], +) -> Any: + """Execute a static-DAG plan in deterministic waves (pre-#1276 behavior). + + Input: ``{"tasks": [{"id", "type", "tool"?, "args"?, "duration"?, + "until"?, "depends_on"}, ...]}``. + + Return on success: ``{"results": {task_id: result, ...}}``. + Return on cooperative cancel: ``{"results": ..., "canceled": True, + "reason": , "completed_count": N, "total_count": M}``. + """ + by_id: dict[str, dict[str, Any]] = {t["id"]: t for t in tasks} + deps: dict[str, set[str]] = { + t["id"]: set(t.get("depends_on") or []) for t in tasks + } + results: dict[str, Any] = {} + remaining: set[str] = set(by_id) + total = len(tasks) + + cancel_task = context.wait_for_external_event(CANCEL_EVENT_NAME) + + while remaining: + ready = sorted( + tid for tid in remaining if not (deps[tid] - results.keys()) + ) + if not ready: + raise RuntimeError( + "workflow stalled: no tasks ready to run but " + f"{len(remaining)} task(s) remain. This indicates a " + "validation bug or an unsatisfiable dependency on the " + "submitted plan." + ) + + wave = ready[:MAX_PARALLELISM] + wave_specs: list[dict[str, Any]] = [] + wave_tasks: list[Any] = [] + for tid in wave: + task = by_id[tid] + ttype = task.get("type") or TOOL_TASK_TYPE + if ttype == TOOL_TASK_TYPE: + try: + resolved_args = resolve_template_value( + task.get("args") or {}, results + ) + except TemplateResolutionError as exc: + raise RuntimeError( + f"task {tid!r}: template resolution failed: {exc}" + ) from exc + wave_tasks.append( + context.call_activity( + _ACTIVITY_NAME, + { + "id": tid, + "tool": task["tool"], + "args": resolved_args, + }, + ) + ) + wave_specs.append({"id": tid, "type": TOOL_TASK_TYPE}) + elif ttype == SUB_AGENT_TASK_TYPE: + try: + resolved_task = resolve_template_value(task["task"], results) + except TemplateResolutionError as exc: + raise RuntimeError( + f"task {tid!r}: template resolution failed: {exc}" + ) from exc + if not isinstance(resolved_task, str): + raise RuntimeError( + f"task {tid!r}: resolved Sub Agent task must be a string" + ) + wave_tasks.append( + context.call_activity( + SUB_AGENT_ACTIVITY_NAME, + { + "id": tid, + "agent": task["agent"], + "task": resolved_task, + "workflow_id": context.instance_id, + }, + ) + ) + wave_specs.append({"id": tid, "type": SUB_AGENT_TASK_TYPE}) + elif ttype == WAIT_TASK_TYPE: + deadline = _wait_deadline(context, task) + wave_tasks.append(context.create_timer(deadline)) + wave_specs.append( + { + "id": tid, + "type": WAIT_TASK_TYPE, + "deadline": deadline.isoformat(), + } + ) + else: + raise RuntimeError( + f"task {tid!r}: unsupported task type {ttype!r}" + ) + + context.set_custom_status( + f"{len(results)}/{total} tasks done, running={','.join(wave)}" + ) + wave_task = context.task_all(wave_tasks) + winner = yield context.task_any([cancel_task, wave_task]) + if winner is cancel_task: + reason = cancel_task.result + for spec, t in zip(wave_specs, wave_tasks, strict=True): + if spec["type"] == WAIT_TASK_TYPE and not t.is_completed: + t.cancel() + context.set_custom_status( + f"canceled at {len(results)}/{total} tasks done" + ) + logger.info( + "workflow canceled: instance=%s reason=%r", + context.instance_id, + reason, + ) + return { + "results": results, + "canceled": True, + "reason": reason, + "completed_count": len(results), + "total_count": total, + } + + wave_results = wave_task.result + 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}: + results[tid] = raw["result"] + else: + results[tid] = {"waited_until": spec["deadline"]} + remaining.discard(tid) + + running_id = "" + next_ready = sorted( + tid for tid in remaining if not (deps[tid] - results.keys()) + ) + if next_ready: + running_id = next_ready[0] + done = len(results) + if running_id: + context.set_custom_status( + f"{done}/{total} tasks done, next={running_id}" + ) + else: + context.set_custom_status(f"{done}/{total} tasks done") + + return {"results": results} + + +# --------------------------------------------------------------------------- +# Dynamic (data-driven) orchestration — Issue #1276. +# --------------------------------------------------------------------------- + + +def _failure_envelope( + *, + error: str, + error_code: str, + node_id: str, + path: str | None, + results: dict[str, Any], +) -> dict[str, Any]: + """Build the flat controlled-failure output the status adapter maps to Failed.""" + return { + "failed": True, + "error": error, + "error_code": error_code, + "node_id": node_id, + "path": path, + "results": results, + } + + +def _materialized_total(node_instances: dict[str, list[dict[str, Any]]]) -> int: + return sum(len(insts) for insts in node_instances.values()) + + +def _dynamic_status( + by_id: dict[str, dict[str, Any]], + logical_state: dict[str, str], + node_instances: dict[str, list[dict[str, Any]]], + expanded_count: dict[str, int], +) -> dict[str, Any]: + """Build the versioned (schema_version=2) structured ``custom_status`` object. + + ``counts`` are instance-level for completed/skipped/running and node-level + for ``logical_total``; ``materialized_total`` counts every materialized + instance (including skipped ones). ``nodes`` renders logical node state, + plus per-instance state for expanded ``for_each`` nodes. + """ + completed = skipped = running = 0 + for insts in node_instances.values(): + for inst in insts: + state = inst["state"] + if state == "completed": + completed += 1 + elif state == "skipped": + skipped += 1 + elif state == "running": + running += 1 + + nodes: dict[str, Any] = {} + for lid, task in by_id.items(): + node: dict[str, Any] = {"state": logical_state[lid]} + if task.get("for_each") is not None and lid in expanded_count: + node["expanded_count"] = expanded_count[lid] + node["instances"] = { + inst["instance_id"]: {"state": inst["state"]} + for inst in node_instances.get(lid, []) + } + nodes[lid] = node + + return { + "schema_version": 2, + "counts": { + "logical_total": len(by_id), + "materialized_total": _materialized_total(node_instances), + "completed": completed, + "skipped": skipped, + "running": running, + }, + "nodes": nodes, + } + + +_UNBOUND = object() + + +def _resolve_dynamic_args( + task: dict[str, Any], + results: dict[str, Any], + *, + item: Any = _UNBOUND, + index: int | None = None, +) -> Any: + """Resolve the executable value field for a tool/sub_agent task or instance. + + When ``item`` is left unbound (normal, non-iterated task) the iteration + locals are not passed through, so ``resolve_template_value`` uses its own + unbound sentinel. Iterated instances pass the bound ``item`` / ``index``. + """ + kwargs: dict[str, Any] = {} + if item is not _UNBOUND: + kwargs["item"] = item + kwargs["index"] = index + ttype = task.get("type") or TOOL_TASK_TYPE + if ttype == TOOL_TASK_TYPE: + return resolve_template_value(task.get("args") or {}, results, **kwargs) + resolved_task = resolve_template_value(task["task"], results, **kwargs) + if not isinstance(resolved_task, str): + raise TemplateResolutionError( + f"resolved Sub Agent task must be a string, got " + f"{type(resolved_task).__name__}" + ) + return resolved_task + + +def _run_dynamic_workflow( + context: df.DurableOrchestrationContext, + payload: dict[str, Any], + tasks: list[dict[str, Any]], +) -> Any: + """Execute a data-driven plan with ``when`` / ``for_each`` control flow. + + Operates on the logical DAG, materializing ``for_each`` instances + deterministically by ``(logical_id, numeric index)``. Controlled runtime + failures are *returned* as a flat ``{failed: true, ...}`` envelope (mapped + to ``Failed`` by the status adapter); unexpected invariants and policy + violations still raise natively. + """ + by_id: dict[str, dict[str, Any]] = {t["id"]: t for t in tasks} + deps: dict[str, set[str]] = { + t["id"]: set(t.get("depends_on") or []) for t in tasks + } + + policy_input = payload.get("policy") or {} + allowed_tools = frozenset(policy_input.get("allowed_tools") or []) + allowed_subagents = frozenset(policy_input.get("allowed_subagents") or []) + + results: dict[str, Any] = {} + logical_state: dict[str, str] = {tid: "pending" for tid in by_id} + # ``node_instances[lid]`` holds every materialized instance for a node in + # source order. Normal nodes have exactly one instance whose id is the + # logical id and whose index is None; for_each nodes have one per element. + node_instances: dict[str, list[dict[str, Any]]] = {} + expanded_count: dict[str, int] = {} + + # Node budget: reserve one node per non-for_each logical task up front. + budget_used = sum(1 for t in tasks if t.get("for_each") is None) + + def publish() -> None: + context.set_custom_status( + _dynamic_status(by_id, logical_state, node_instances, expanded_count) + ) + + def fail( + *, + error: str, + error_code: str, + node_id: str, + path: str | None, + lid: str, + ) -> dict[str, Any]: + logical_state[lid] = "failed" + publish() + logger.info( + "workflow failed: instance=%s node=%s code=%s", + context.instance_id, + node_id, + error_code, + ) + return _failure_envelope( + error=error, + error_code=error_code, + node_id=node_id, + path=path, + results=results, + ) + + def deps_ready(lid: str) -> bool: + return not (deps[lid] - results.keys()) + + def aggregate(lid: str) -> None: + insts = sorted(node_instances[lid], key=lambda i: i["index"]) + results[lid] = [ + {"index": i["index"], "status": i["state"], "result": i["result"]} + for i in insts + ] + logical_state[lid] = "aggregated" + + cancel_task = context.wait_for_external_event(CANCEL_EVENT_NAME) + + while True: + # --- Phase A: resolve all progress that needs no Activity (skips, + # expansions, aggregations) to a fixpoint. Deterministic: pending + # nodes are visited in sorted logical-id order so budget accounting + # and expansion order do not depend on dict iteration order. + progressed = True + while progressed: + progressed = False + for lid in sorted(t for t in by_id if logical_state[t] == "pending"): + if not deps_ready(lid): + continue + task = by_id[lid] + + if task.get("for_each") is not None: + # Resolve the full upstream array reference. + ref = task["for_each"] + try: + collection = resolve_template_value(ref, results) + except TemplateResolutionError as exc: + return fail( + error=str(exc), + error_code=exc.error_code, + node_id=lid, + path=ref, + lid=lid, + ) + if not isinstance(collection, list): + return fail( + error=( + f"task {lid!r}: for_each did not resolve to an " + f"array (got {type(collection).__name__})" + ), + error_code="workflow_iteration_not_array", + node_id=lid, + path=ref, + lid=lid, + ) + count = len(collection) + if budget_used + count > MAX_NODES: + return fail( + error=( + f"task {lid!r}: expanding for_each over " + f"{count} element(s) would exceed the " + f"materialized-node limit of {MAX_NODES}" + ), + error_code="workflow_node_limit_exceeded", + node_id=lid, + path=ref, + lid=lid, + ) + budget_used += count + expanded_count[lid] = count + logical_state[lid] = "expanded" + instances: list[dict[str, Any]] = [] + when = task.get("when") + for index, element in enumerate(collection): + instance_id = f"{lid}[{index}]" + if when is not None: + try: + run = evaluate_condition( + WorkflowCondition.model_validate(when), + results, + item=element, + index=index, + ) + except TemplateResolutionError as exc: + node_instances[lid] = instances + return fail( + error=str(exc), + error_code=exc.error_code, + node_id=instance_id, + path=when["ref"], + lid=lid, + ) + if not run: + instances.append({ + "logical_id": lid, + "index": index, + "instance_id": instance_id, + "state": "skipped", + "result": None, + }) + continue + try: + resolved = _resolve_dynamic_args( + task, results, item=element, index=index + ) + except TemplateResolutionError as exc: + node_instances[lid] = instances + return fail( + error=str(exc), + error_code=exc.error_code, + node_id=instance_id, + path=None, + lid=lid, + ) + instances.append({ + "logical_id": lid, + "index": index, + "instance_id": instance_id, + "state": "pending", + "result": None, + "resolved": resolved, + }) + node_instances[lid] = instances + # Empty expansion, or every element skipped, aggregates now. + if not any(i["state"] == "pending" for i in instances): + aggregate(lid) + # Publish a meaningful snapshot at expansion time so the + # transient ``expanded`` state (and per-instance skips) are + # observable before Phase B flips runnable instances to + # ``running``. + publish() + progressed = True + continue + + # Normal (non-for_each) node. + when = task.get("when") + if when is not None: + try: + run = evaluate_condition( + WorkflowCondition.model_validate(when), results + ) + except TemplateResolutionError as exc: + return fail( + error=str(exc), + error_code=exc.error_code, + node_id=lid, + path=when["ref"], + lid=lid, + ) + if not run: + results[lid] = None + logical_state[lid] = "skipped" + node_instances[lid] = [{ + "logical_id": lid, + "index": None, + "instance_id": lid, + "state": "skipped", + "result": None, + }] + progressed = True + continue + + ttype = task.get("type") or TOOL_TASK_TYPE + resolved_value: Any = None + if ttype in {TOOL_TASK_TYPE, SUB_AGENT_TASK_TYPE}: + try: + resolved_value = _resolve_dynamic_args(task, results) + except TemplateResolutionError as exc: + return fail( + error=str(exc), + error_code=exc.error_code, + node_id=lid, + path=None, + lid=lid, + ) + node_instances[lid] = [{ + "logical_id": lid, + "index": None, + "instance_id": lid, + "state": "pending", + "result": None, + "resolved": resolved_value, + }] + logical_state[lid] = "running" + progressed = True + + # Aggregate for_each nodes whose instances are all terminal. + for lid in sorted( + t + for t in by_id + if by_id[t].get("for_each") is not None + and logical_state[t] in {"expanded", "running"} + ): + insts = node_instances.get(lid, []) + if insts and all( + i["state"] in {"completed", "skipped"} for i in insts + ): + aggregate(lid) + progressed = True + + if all( + logical_state[t] in {"completed", "skipped", "aggregated"} + for t in by_id + ): + break + + # --- Phase B: gather runnable instances across every logical node and + # schedule up to MAX_PARALLELISM. Ordering key is the numeric + # (logical_id, index) tuple, never the rendered instance-id string + # (so analyze[10] runs after analyze[2]). + runnable: list[dict[str, Any]] = [] + for insts in node_instances.values(): + for inst in insts: + if inst["state"] == "pending": + runnable.append(inst) + if not runnable: + raise RuntimeError( + "workflow stalled: no runnable instances but " + f"{sum(1 for s in logical_state.values() if s not in {'completed', 'skipped', 'aggregated'})}" + " logical node(s) are not terminal. This indicates a " + "scheduler invariant violation." + ) + runnable.sort( + key=lambda inst: ( + inst["logical_id"], + inst["index"] if inst["index"] is not None else -1, + ) + ) + wave = runnable[:MAX_PARALLELISM] + + wave_tasks: list[Any] = [] + wave_specs: list[dict[str, Any]] = [] + for inst in wave: + lid = inst["logical_id"] + task = by_id[lid] + ttype = task.get("type") or TOOL_TASK_TYPE + if ttype == TOOL_TASK_TYPE: + if task["tool"] not in allowed_tools: + raise RuntimeError( + f"task {inst['instance_id']!r}: tool {task['tool']!r} is " + "outside the persisted workflow owner policy" + ) + wave_tasks.append( + context.call_activity( + _ACTIVITY_NAME, + { + "id": inst["instance_id"], + "tool": task["tool"], + "args": inst["resolved"], + }, + ) + ) + inst["kind"] = "activity" + elif ttype == SUB_AGENT_TASK_TYPE: + if task["agent"] not in allowed_subagents: + raise RuntimeError( + f"task {inst['instance_id']!r}: Sub Agent " + f"{task['agent']!r} is outside the persisted workflow " + "owner policy" + ) + wave_tasks.append( + context.call_activity( + SUB_AGENT_ACTIVITY_NAME, + { + "id": inst["instance_id"], + "agent": task["agent"], + "task": inst["resolved"], + "workflow_id": context.instance_id, + }, + ) + ) + inst["kind"] = "activity" + elif ttype == WAIT_TASK_TYPE: + deadline = _wait_deadline(context, task) + inst["deadline"] = deadline.isoformat() + wave_tasks.append(context.create_timer(deadline)) + inst["kind"] = "timer" + else: + raise RuntimeError( + f"task {inst['instance_id']!r}: unsupported task type {ttype!r}" + ) + inst["state"] = "running" + if logical_state[lid] == "expanded": + logical_state[lid] = "running" + wave_specs.append(inst) + + publish() + wave_task = context.task_all(wave_tasks) + winner = yield context.task_any([cancel_task, wave_task]) + if winner is cancel_task: + reason = cancel_task.result + for inst, t in zip(wave_specs, wave_tasks, strict=True): + if inst.get("kind") == "timer" and not t.is_completed: + t.cancel() + inst["state"] = "pending" + for lid in list(logical_state): + if logical_state[lid] == "running" and by_id[lid].get( + "for_each" + ) is not None: + logical_state[lid] = "expanded" + publish() + logger.info( + "workflow canceled: instance=%s reason=%r", + context.instance_id, + reason, + ) + return { + "results": results, + "canceled": True, + "reason": reason, + "completed_count": len(results), + "total_count": len(by_id), + } + + wave_results = wave_task.result + for inst, raw in zip(wave_specs, wave_results, strict=True): + if inst.get("kind") == "timer": + inst["result"] = {"waited_until": inst["deadline"]} + else: + inst["result"] = raw["result"] + inst["state"] = "completed" + if inst["index"] is None: + lid = inst["logical_id"] + results[lid] = inst["result"] + logical_state[lid] = "completed" + + publish() + + publish() + return {"results": results} + + def register_workflows( app: func.FunctionApp, *, @@ -208,180 +874,27 @@ async def agents_workflow_run_sub_agent(task) -> dict[str, Any]: # type: ignore @bp.orchestration_trigger(context_name="context") # type: ignore[untyped-decorator] def agents_workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any: - """Execute an arbitrary-DAG workflow plan in deterministic waves. - - Input: ``{"tasks": [{"id", "type", "tool"?, "args"?, "duration"?, - "until"?, "depends_on"}, ...]}``. - - Return on success: ``{"results": {task_id: result, ...}}``. - Return on cooperative cancel: ``{"results": ..., "canceled": True, - "reason": , "completed_count": N, "total_count": M}``. - - Determinism contract: - - ``ready`` set sorted by task id before each ``task_all`` wave. - - Templates resolved against the JSON-normalized ``results`` dict - using only deterministic Python. + """Execute a workflow plan, selecting the static or dynamic scheduler. + + A plan is *static* when no task carries a ``when`` predicate or a + ``for_each`` expansion; it runs through :func:`_run_static_workflow` + with its exact pre-#1276 wave scheduling and string ``custom_status`` + behavior. Any ``when`` / ``for_each`` selects + :func:`_run_dynamic_workflow`, which materializes instances, aggregates + results, and publishes structured (schema_version=2) status. + + Determinism contract (both paths): + - Ready/runnable sets ordered deterministically before each wave. + - Templates resolved against the JSON-normalized ``results`` dict. - Time read only via ``context.current_utc_datetime``. - No I/O outside ``call_activity`` / ``create_timer`` / ``wait_for_external_event``. """ payload: dict[str, Any] = context.get_input() or {} tasks: list[dict[str, Any]] = list(payload.get("tasks") or []) - - by_id: dict[str, dict[str, Any]] = {t["id"]: t for t in tasks} - deps: dict[str, set[str]] = { - t["id"]: set(t.get("depends_on") or []) for t in tasks - } - results: dict[str, Any] = {} - remaining: set[str] = set(by_id) - total = len(tasks) - - # Single long-lived cancel listener. Reusing the same Task across - # iterations of task_any is the canonical Durable pattern; once the - # event fires, every subsequent task_any sees it as already-complete. - # Note (asymmetry with timers): a wait_for_external_event Task does - # NOT need to be .cancel()-ed before the orchestrator returns — that - # method is only defined on TimerTask in the Durable Python SDK, and - # an unfired external-event listener does not block completion. Only - # in-flight timers must be explicitly cancelled (see below). - cancel_task = context.wait_for_external_event(CANCEL_EVENT_NAME) - - while remaining: - ready = sorted( - tid for tid in remaining if not (deps[tid] - results.keys()) - ) - if not ready: - # Validation rejects cycles, so this means the wire payload - # was tampered with or has dangling deps. Fail loudly so the - # workflow ends up in Failed state with a clear cause. - raise RuntimeError( - "workflow stalled: no tasks ready to run but " - f"{len(remaining)} task(s) remain. This indicates a " - "validation bug or an unsatisfiable dependency on the " - "submitted plan." - ) - - wave = ready[:MAX_PARALLELISM] - wave_specs: list[dict[str, Any]] = [] - wave_tasks: list[Any] = [] - for tid in wave: - task = by_id[tid] - ttype = task.get("type") or TOOL_TASK_TYPE - if ttype == TOOL_TASK_TYPE: - try: - resolved_args = resolve_template_value( - task.get("args") or {}, results - ) - except TemplateResolutionError as exc: - raise RuntimeError( - f"task {tid!r}: template resolution failed: {exc}" - ) from exc - wave_tasks.append( - context.call_activity( - _ACTIVITY_NAME, - { - "id": tid, - "tool": task["tool"], - "args": resolved_args, - }, - ) - ) - wave_specs.append({"id": tid, "type": TOOL_TASK_TYPE}) - elif ttype == SUB_AGENT_TASK_TYPE: - try: - resolved_task = resolve_template_value(task["task"], results) - except TemplateResolutionError as exc: - raise RuntimeError( - f"task {tid!r}: template resolution failed: {exc}" - ) from exc - if not isinstance(resolved_task, str): - raise RuntimeError( - f"task {tid!r}: resolved Sub Agent task must be a string" - ) - wave_tasks.append( - context.call_activity( - SUB_AGENT_ACTIVITY_NAME, - { - "id": tid, - "agent": task["agent"], - "task": resolved_task, - "workflow_id": context.instance_id, - }, - ) - ) - wave_specs.append({"id": tid, "type": SUB_AGENT_TASK_TYPE}) - elif ttype == WAIT_TASK_TYPE: - deadline = _wait_deadline(context, task) - wave_tasks.append(context.create_timer(deadline)) - wave_specs.append( - { - "id": tid, - "type": WAIT_TASK_TYPE, - "deadline": deadline.isoformat(), - } - ) - else: - # Validator should have rejected this; defend anyway. - raise RuntimeError( - f"task {tid!r}: unsupported task type {ttype!r}" - ) - - context.set_custom_status( - f"{len(results)}/{total} tasks done, running={','.join(wave)}" - ) - wave_task = context.task_all(wave_tasks) - winner = yield context.task_any([cancel_task, wave_task]) - if winner is cancel_task: - reason = cancel_task.result - # Durable requires every pending timer to be cancelled before - # the orchestration can complete; otherwise the instance stays - # in Running until the timer naturally fires. Cancel any timers - # in the current wave that haven't completed yet. - for spec, t in zip(wave_specs, wave_tasks, strict=True): - if spec["type"] == WAIT_TASK_TYPE and not t.is_completed: - t.cancel() - context.set_custom_status( - f"canceled at {len(results)}/{total} tasks done" - ) - logger.info( - "workflow canceled: instance=%s reason=%r", - context.instance_id, - reason, - ) - return { - "results": results, - "canceled": True, - "reason": reason, - "completed_count": len(results), - "total_count": total, - } - - wave_results = wave_task.result - 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}: - results[tid] = raw["result"] - else: - # Timer tasks resolve to None; we synthesize a result so - # downstream template refs to ``${tid.result}`` are useful. - results[tid] = {"waited_until": spec["deadline"]} - remaining.discard(tid) - - running_id = "" - next_ready = sorted( - tid for tid in remaining if not (deps[tid] - results.keys()) - ) - if next_ready: - running_id = next_ready[0] - done = len(results) - if running_id: - context.set_custom_status( - f"{done}/{total} tasks done, next={running_id}" - ) - else: - context.set_custom_status(f"{done}/{total} tasks done") - - return {"results": results} + if _plan_is_dynamic(tasks): + return (yield from _run_dynamic_workflow(context, payload, tasks)) + return (yield from _run_static_workflow(context, payload, tasks)) app.register_blueprint(bp) diff --git a/src/azure_functions_agents/workflows/integration.py b/src/azure_functions_agents/workflows/integration.py index 42f9497..6d99edd 100644 --- a/src/azure_functions_agents/workflows/integration.py +++ b/src/azure_functions_agents/workflows/integration.py @@ -68,6 +68,35 @@ "Do not return large raw evidence blobs, logs, " "or per-item lists as the final workflow output unless the request explicitly " "requires raw data; summarize the useful signal inside the workflow.\n\n" + "### Data-driven control flow (optional)\n\n" + "Two optional task fields let a plan adapt to data instead of enumerating " + "every task up front:\n\n" + "- `for_each` (on a `tool` or `sub_agent` task only — never `wait`): a single " + "full reference to an upstream JSON array, e.g. " + "`\"for_each\": \"${discover.result.items}\"`. The runtime materializes one " + "instance per element. Inside that task's value fields (`args`, a Sub Agent's " + "`task`, and `when.ref`) use the fixed iteration locals `${item}` (the whole " + "element), `${item.path.to.field}` (a field of it), and `${index}` (the " + "zero-based position). Those locals are valid only inside a `for_each` task. " + "Keep the target tool/agent name static — only value fields vary per item.\n" + "- `when`: a small predicate deciding whether a task (or one `for_each` " + "instance) runs. It is an object " + "`{\"ref\": \"${...}\", \"operator\": \"equals\" | \"not_equals\", " + "\"value\": }`. `ref` is one full upstream or iteration reference; " + "`value` is a JSON scalar (null, boolean, number, or string). Comparison is " + "exact typed equality only — no truthiness, ordering, regex, or boolean " + "composition. A false predicate skips the task, schedules nothing, and yields " + "`null` for that result position; skip does not propagate, so a downstream " + "conditional task must declare its own `when`.\n\n" + "The condition is evaluated before a task's executable `args` / `task` " + "templates are resolved, so a skipped task never needs valid value fields. " + "A `for_each` task exposes one ordered aggregate under its logical id: an " + "array of `{index, status, result}` envelopes in source order, with " + "`result: null` for skipped positions. Depend on the logical id and consume " + "the whole aggregate with `${node_id.result}`; you cannot reference an " + "individual instance. Iterate over collections that are already bounded — " + "filter or cap the array upstream — so the plan stays within workflow " + "limits.\n\n" ) _CHAT_ADDENDUM = ( diff --git a/src/azure_functions_agents/workflows/schema.py b/src/azure_functions_agents/workflows/schema.py index e132252..800f102 100644 --- a/src/azure_functions_agents/workflows/schema.py +++ b/src/azure_functions_agents/workflows/schema.py @@ -22,13 +22,14 @@ from __future__ import annotations import json +import math import re from collections.abc import Collection from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator class PlanValidationError(ValueError): @@ -38,9 +39,35 @@ class PlanValidationError(ValueError): self-correct and resubmit. """ + def __init__( + self, + message: str, + *, + error_code: str | None = None, + node_id: str | None = None, + path: str | None = None, + ) -> None: + super().__init__(message) + self.error_code = error_code + self.node_id = node_id + self.path = path + class TemplateResolutionError(ValueError): - """Raised at orchestration time when a template path cannot be resolved.""" + """Raised at orchestration time when a template path cannot be resolved. + + ``error_code`` lets the orchestrator map a runtime resolution failure to + one of the stable controlled failure codes without inspecting the message + text. Unresolved references default to ``workflow_reference_unresolved``; + :func:`evaluate_condition` raises ``workflow_condition_invalid`` when a + predicate resolves to a non-scalar value. + """ + + def __init__( + self, message: str, *, error_code: str = "workflow_reference_unresolved" + ) -> None: + super().__init__(message) + self.error_code = error_code TOOL_TASK_TYPE: str = "tool" @@ -62,10 +89,37 @@ class WorkflowPlanPolicy: subagent_guidance: tuple[tuple[str, str], ...] = () +type JsonScalar = str | int | float | bool | None + + +def _is_json_scalar(value: object) -> bool: + """Return whether ``value`` is a JSON scalar with a finite numeric value.""" + return value is None or type(value) in (str, int, bool) or ( + type(value) is float and math.isfinite(value) + ) + + +class WorkflowCondition(BaseModel): + """A deliberately small, replay-safe predicate for a workflow task.""" + + model_config = ConfigDict(extra="forbid") + + ref: str + operator: Literal["equals", "not_equals"] + value: JsonScalar + + @field_validator("value", mode="before") + @classmethod + def validate_scalar_value(cls, value: object) -> object: + if not _is_json_scalar(value): + raise ValueError("condition value must be a JSON scalar") + return value + + class WorkflowTask(BaseModel): model_config = ConfigDict(extra="forbid") - id: str = Field(..., min_length=1, max_length=64) + id: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$") type: str = Field(default=TOOL_TASK_TYPE) # ``tool`` is required for type=tool, must be omitted for type=wait. tool: str | None = Field(default=None) @@ -78,6 +132,8 @@ class WorkflowTask(BaseModel): until: str | None = Field(default=None) agent: str | None = Field(default=None) task: str | None = Field(default=None) + when: WorkflowCondition | None = Field(default=None, exclude_if=lambda value: value is None) + for_each: str | None = Field(default=None, exclude_if=lambda value: value is None) class WorkflowPlan(BaseModel): @@ -108,6 +164,10 @@ class WorkflowPlan(BaseModel): # _TEMPLATE_LIKE_RE requires the closing brace and would silently miss # unterminated refs like ``"${a.result"``. _TEMPLATE_UNCLOSED_RE = re.compile(r"\$\{[^}]*\Z") +_ITERATION_TEMPLATE_RE = re.compile( + r"\$\{(?:(item)(?:\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*))?|(index))\}" +) +_ITERATION_UNBOUND = object() def validate_plan( @@ -137,13 +197,19 @@ def validate_plan( try: plan = WorkflowPlan.model_validate(raw) except ValidationError as exc: - raise PlanValidationError(f"plan does not match schema: {exc}") from exc + metadata = _schema_validation_metadata(raw, exc) + raise PlanValidationError( + f"plan does not match schema: {exc}", + **metadata, + ) from exc if len(plan.tasks) > MAX_NODES: raise PlanValidationError( f"plan has {len(plan.tasks)} tasks but the per-plan limit is " f"{MAX_NODES}. Break the work into smaller workflows or reduce " - "the number of nodes." + "the number of nodes.", + error_code="workflow_node_limit_exceeded", + path="tasks", ) seen: set[str] = set() @@ -164,6 +230,7 @@ def validate_plan( f"task {task.id!r}: 'tool' field is required for " "type=tool tasks" ) + _validate_static_target(task, task.tool, "tool") if task.tool not in policy.allowed_tools: raise PlanValidationError( f"task {task.id!r}: tool {task.tool!r} is not workflow-safe. " @@ -189,6 +256,13 @@ def validate_plan( f"task {task.id!r}: 'args' is not valid on type=wait tasks " "(use 'duration' or 'until' instead)" ) + if task.for_each is not None: + raise PlanValidationError( + f"task {task.id!r}: 'for_each' is not valid on type=wait tasks", + error_code="workflow_reference_unresolved", + node_id=task.id, + path="for_each", + ) if "agent" in task.model_fields_set or "task" in task.model_fields_set: raise PlanValidationError( f"task {task.id!r}: 'agent' and 'task' are only valid on " @@ -245,6 +319,7 @@ def validate_plan( f"task {task.id!r}: 'agent' field is required and must be " "non-empty for type=sub_agent tasks" ) + _validate_static_target(task, task.agent, "agent") if not task.task or not task.task.strip(): raise PlanValidationError( f"task {task.id!r}: 'task' field is required and must be " @@ -298,10 +373,51 @@ def validate_plan( upstream = _upstream_closure(plan) for task in plan.tasks: _validate_task_templates(task, upstream[task.id], by_id) + _validate_for_each(task, upstream[task.id], by_id) + _validate_when(task, upstream[task.id], by_id) return plan +def _validate_static_target(task: WorkflowTask, target: str, field: str) -> None: + if _TEMPLATE_LIKE_RE.search(target) or _TEMPLATE_UNCLOSED_RE.search(target): + raise PlanValidationError( + f"task {task.id!r}: '{field}' target must be static and cannot contain " + "template references", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=field, + ) + + +def _schema_validation_metadata( + raw: dict[str, Any], exc: ValidationError +) -> dict[str, str]: + """Map newly introduced plan fields to their stable submission error code.""" + for error in exc.errors(): + loc = error["loc"] + if len(loc) < 3 or loc[0] != "tasks" or not isinstance(loc[1], int): + continue + field = loc[2] + if field not in {"when", "for_each"}: + continue + node_id: str | None = None + tasks = raw.get("tasks") + if isinstance(tasks, list) and loc[1] < len(tasks): + candidate = tasks[loc[1]] + if isinstance(candidate, dict) and isinstance(candidate.get("id"), str): + node_id = candidate["id"] + path = ".".join(str(segment) for segment in loc[2:]) + if field == "when": + metadata = {"error_code": "workflow_condition_invalid", "path": path} + else: + metadata = {"error_code": "workflow_reference_unresolved", "path": path} + if node_id is not None: + metadata["node_id"] = node_id + return metadata + return {} + + def _detect_cycle(plan: WorkflowPlan) -> list[str] | None: """Return a cycle as an ordered task-id list if present, else None.""" white, gray, black = 0, 1, 2 @@ -397,40 +513,162 @@ def _validate_task_templates( template_root = "task" if task.type == SUB_AGENT_TASK_TYPE else "args" template_value: Any = task.task if task.type == SUB_AGENT_TASK_TYPE else task.args for path, value in _walk_strings(template_value, ()): - # Catch unterminated ``${`` (no closing brace before end of string) - # before the inner finditer loop, which only sees balanced ``${...}``. - if _TEMPLATE_UNCLOSED_RE.search(value): + _validate_template_string( + task, + value, + root=template_root, + value_path=_format_value_path(template_root, path), + upstream_ids=upstream_ids, + by_id=by_id, + allow_iteration=task.for_each is not None, + ) + + +def _validate_for_each( + task: WorkflowTask, + upstream_ids: set[str], + by_id: dict[str, WorkflowTask], +) -> None: + if task.for_each is None: + return + if task.type == WAIT_TASK_TYPE: + return + ref_match = _TEMPLATE_RE.fullmatch(task.for_each) + if ref_match is None: + raise PlanValidationError( + f"task {task.id!r}: 'for_each' must be one full upstream " + "reference like '${node_id.result.items}'", + error_code="workflow_reference_unresolved", + node_id=task.id, + path="for_each", + ) + _validate_upstream_reference( + task, + ref_match.group(1), + upstream_ids, + by_id, + root="for_each", + value_path="for_each", + ) + + +def _validate_when( + task: WorkflowTask, + upstream_ids: set[str], + by_id: dict[str, WorkflowTask], +) -> None: + if task.when is None: + return + ref = task.when.ref + iteration_match = _ITERATION_TEMPLATE_RE.fullmatch(ref) + if iteration_match is not None and task.for_each is not None: + return + upstream_match = _TEMPLATE_RE.fullmatch(ref) + if upstream_match is not None: + _validate_upstream_reference( + task, + upstream_match.group(1), + upstream_ids, + by_id, + root="when", + value_path="when.ref", + ) + return + if iteration_match is not None: + raise PlanValidationError( + f"task {task.id!r}: iteration local {ref!r} at when.ref is only " + "available on for_each tasks", + error_code="workflow_reference_unresolved", + node_id=task.id, + path="when.ref", + ) + raise PlanValidationError( + f"task {task.id!r}: 'when.ref' must be one full upstream or iteration " + "reference", + error_code="workflow_condition_invalid", + node_id=task.id, + path="when.ref", + ) + + +def _validate_template_string( + task: WorkflowTask, + value: str, + *, + root: str, + value_path: str, + upstream_ids: set[str], + by_id: dict[str, WorkflowTask], + allow_iteration: bool, +) -> None: + if _TEMPLATE_UNCLOSED_RE.search(value): + raise PlanValidationError( + f"task {task.id!r}: unterminated template reference at " + f"{root} path {value_path} — missing closing '}}'", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=value_path, + ) + for like_match in _TEMPLATE_LIKE_RE.finditer(value): + literal = like_match.group(0) + iteration_match = _ITERATION_TEMPLATE_RE.fullmatch(literal) + if iteration_match is not None and allow_iteration: + continue + ref_match = _TEMPLATE_RE.fullmatch(literal) + if ref_match is not None: + _validate_upstream_reference( + task, + ref_match.group(1), + upstream_ids, + by_id, + root=root, + value_path=value_path, + ) + continue + if iteration_match is not None: raise PlanValidationError( - f"task {task.id!r}: unterminated template reference at " - f"{template_root} path {_format_value_path(template_root, path)} " - "— missing closing '}'" + f"task {task.id!r}: iteration local {literal!r} at " + f"{root} path {value_path} is only available on for_each tasks", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=value_path, ) - # Catch ``${...}`` literals that don't match the strict template - # regex — silently leaving these in args would defeat the point of - # templating. - for like_match in _TEMPLATE_LIKE_RE.finditer(value): - literal = like_match.group(0) - if not _TEMPLATE_RE.fullmatch(literal): - raise PlanValidationError( - f"task {task.id!r}: malformed template " - f"reference {literal!r} at {template_root} path " - f"{_format_value_path(template_root, path)} — expected " - "${{node_id.result}} or ${{node_id.result.path}}" - ) - for ref_match in _TEMPLATE_RE.finditer(value): - ref_id = ref_match.group(1) - if ref_id not in by_id: - raise PlanValidationError( - f"task {task.id!r}: template references unknown task " - f"{ref_id!r} at {template_root} path " - f"{_format_value_path(template_root, path)}" - ) - if ref_id not in upstream_ids: - raise PlanValidationError( - f"task {task.id!r}: template references {ref_id!r} which " - "is not an upstream dependency. Add it to depends_on or " - "remove the reference." - ) + raise PlanValidationError( + f"task {task.id!r}: malformed template " + f"reference {literal!r} at {root} path {value_path} — expected " + "${{node_id.result}} or ${{node_id.result.path}}", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=value_path, + ) + + +def _validate_upstream_reference( + task: WorkflowTask, + ref_id: str, + upstream_ids: set[str], + by_id: dict[str, WorkflowTask], + *, + root: str, + value_path: str, +) -> None: + if ref_id not in by_id: + raise PlanValidationError( + f"task {task.id!r}: template references unknown task " + f"{ref_id!r} at {root} path {value_path}", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=value_path, + ) + if ref_id not in upstream_ids: + raise PlanValidationError( + f"task {task.id!r}: template references {ref_id!r} which " + "is not an upstream dependency. Add it to depends_on or " + "remove the reference.", + error_code="workflow_reference_unresolved", + node_id=task.id, + path=value_path, + ) def _walk_strings( @@ -458,7 +696,13 @@ def _format_value_path(root: str, path: tuple[Any, ...]) -> str: return root + "".join(parts) -def resolve_template_value(value: Any, results: dict[str, Any]) -> Any: +def resolve_template_value( + value: Any, + results: dict[str, Any], + *, + item: Any = _ITERATION_UNBOUND, + index: int | None = None, +) -> Any: """Substitute template refs in ``value`` against ``results``. Used by the orchestrator immediately before scheduling each task. The @@ -469,7 +713,17 @@ def resolve_template_value(value: Any, results: dict[str, Any]) -> Any: cannot be traversed. """ if isinstance(value, str): + iteration_full = _ITERATION_TEMPLATE_RE.fullmatch(value) + iteration_bound = item is not _ITERATION_UNBOUND or index is not None full = _TEMPLATE_RE.fullmatch(value) + if iteration_full is not None and (iteration_bound or full is None): + return _resolve_iteration_ref( + iteration_full.group(1), + iteration_full.group(2), + iteration_full.group(3), + item, + index, + ) if full is not None: return _resolve_ref(full.group(1), full.group(2), results) any_like = _TEMPLATE_LIKE_RE.search(value) @@ -477,12 +731,26 @@ def resolve_template_value(value: Any, results: dict[str, Any]) -> Any: return value def repl(match: re.Match[str]) -> str: - resolved = _resolve_ref(match.group(1), match.group(2), results) + literal = match.group(0) + iteration_match = _ITERATION_TEMPLATE_RE.fullmatch(literal) + ref_match = _TEMPLATE_RE.fullmatch(literal) + if iteration_match is not None and (iteration_bound or ref_match is None): + resolved = _resolve_iteration_ref( + iteration_match.group(1), + iteration_match.group(2), + iteration_match.group(3), + item, + index, + ) + else: + if ref_match is None: + return literal + resolved = _resolve_ref(ref_match.group(1), ref_match.group(2), results) if isinstance(resolved, str): return resolved return json.dumps(resolved, sort_keys=True) - substituted = _TEMPLATE_RE.sub(repl, value) + substituted = _TEMPLATE_LIKE_RE.sub(repl, value) # Defense-in-depth: if validation was bypassed, an unmatched # ``${...}`` token could survive substitution. Surface that as a # deterministic failure rather than passing a half-resolved string @@ -497,9 +765,12 @@ def repl(match: re.Match[str]) -> str: ) return substituted if isinstance(value, dict): - return {k: resolve_template_value(v, results) for k, v in value.items()} + return { + k: resolve_template_value(v, results, item=item, index=index) + for k, v in value.items() + } if isinstance(value, list): - return [resolve_template_value(v, results) for v in value] + return [resolve_template_value(v, results, item=item, index=index) for v in value] return value @@ -512,12 +783,41 @@ def _resolve_ref(node_id: str, dotted_path: str | None, results: dict[str, Any]) cur: Any = results[node_id] if not dotted_path: return cur + return _resolve_path(f"{node_id}.result", dotted_path, cur) + + +def _resolve_iteration_ref( + item_name: str | None, + dotted_path: str | None, + index_name: str | None, + item: Any, + index: int | None, +) -> Any: + if index_name is not None: + if index is None: + raise TemplateResolutionError( + "template references '${index}' but no iteration index is bound" + ) + return index + if item_name is None: + raise TemplateResolutionError("invalid iteration template reference") + if item is _ITERATION_UNBOUND: + raise TemplateResolutionError( + "template references '${item}' but no iteration item is bound" + ) + if dotted_path is None: + return item + return _resolve_path("item", dotted_path, item) + + +def _resolve_path(root: str, dotted_path: str, value: Any) -> Any: + cur: Any = value parts = dotted_path.split(".") for i, part in enumerate(parts): if isinstance(cur, dict): if part not in cur: raise TemplateResolutionError( - f"template path ${{{node_id}.result.{dotted_path}}} " + f"template path ${{{root}.{dotted_path}}} " f"failed at segment {part!r}: key not present" ) cur = cur[part] @@ -526,25 +826,43 @@ def _resolve_ref(node_id: str, dotted_path: str | None, results: dict[str, Any]) idx = int(part) except ValueError as exc: raise TemplateResolutionError( - f"template path ${{{node_id}.result.{dotted_path}}} " + f"template path ${{{root}.{dotted_path}}} " f"failed at segment {part!r}: list index must be an integer" ) from exc if idx < 0 or idx >= len(cur): raise TemplateResolutionError( - f"template path ${{{node_id}.result.{dotted_path}}} " + f"template path ${{{root}.{dotted_path}}} " f"failed at segment {part!r}: index out of range" ) cur = cur[idx] else: traversed = ".".join(parts[:i]) raise TemplateResolutionError( - f"template path ${{{node_id}.result.{dotted_path}}} " + f"template path ${{{root}.{dotted_path}}} " f"failed at segment {part!r}: parent value at " f"{traversed or ''} is not a dict or list" ) return cur +def evaluate_condition( + condition: WorkflowCondition, + results: dict[str, Any], + *, + item: Any = _ITERATION_UNBOUND, + index: int | None = None, +) -> bool: + """Evaluate a validated condition with type-sensitive equality semantics.""" + resolved = resolve_template_value(condition.ref, results, item=item, index=index) + if not _is_json_scalar(resolved): + raise TemplateResolutionError( + f"condition reference {condition.ref!r} resolved to a non-scalar value", + error_code="workflow_condition_invalid", + ) + equals = type(resolved) is type(condition.value) and resolved == condition.value + return equals if condition.operator == "equals" else not equals + + def plan_to_activity_inputs(plan: WorkflowPlan) -> list[dict[str, Any]]: """Flatten a validated plan into the JSON list the orchestrator iterates. @@ -570,6 +888,10 @@ def plan_to_activity_inputs(plan: WorkflowPlan) -> list[dict[str, Any]]: else: entry["agent"] = t.agent entry["task"] = t.task + if t.when is not None: + entry["when"] = t.when.model_dump() + if t.for_each is not None: + entry["for_each"] = t.for_each out.append(entry) return out @@ -667,9 +989,11 @@ def parse_iso8601_datetime(text: str) -> datetime: "WAIT_TASK_TYPE", "PlanValidationError", "TemplateResolutionError", + "WorkflowCondition", "WorkflowPlan", "WorkflowPlanPolicy", "WorkflowTask", + "evaluate_condition", "parse_iso8601_datetime", "parse_iso8601_duration", "plan_to_activity_inputs", diff --git a/src/azure_functions_agents/workflows/tools.py b/src/azure_functions_agents/workflows/tools.py index 1decefd..d522284 100644 --- a/src/azure_functions_agents/workflows/tools.py +++ b/src/azure_functions_agents/workflows/tools.py @@ -50,6 +50,14 @@ }) +class _ConditionSpec(BaseModel): + model_config = ConfigDict(extra="forbid") + + ref: str + operator: Literal["equals", "not_equals"] + value: str | int | float | bool | None + + class _TaskSpecBase(BaseModel): model_config = ConfigDict(extra="forbid") @@ -63,6 +71,15 @@ class _TaskSpecBase(BaseModel): "self-references are rejected at validation time." ), ) + when: _ConditionSpec | None = Field( + default=None, + exclude_if=lambda value: value is None, + description=( + "Optional predicate with ref, operator ('equals' or 'not_equals'), and a " + "JSON-scalar value. The ref must be one full upstream-result reference; " + "for_each tasks may instead use ${item}, ${item.path}, or ${index}." + ), + ) class _ToolTaskSpec(_TaskSpecBase): @@ -79,6 +96,14 @@ class _ToolTaskSpec(_TaskSpecBase): default_factory=dict, description="JSON-serializable arguments passed to the tool.", ) + for_each: str | None = Field( + default=None, + exclude_if=lambda value: value is None, + description=( + "Optional full upstream-result reference resolving to a JSON array. " + "One task instance is created for each array item." + ), + ) class _WaitTaskSpec(_TaskSpecBase): @@ -115,6 +140,14 @@ class _SubAgentTaskSpec(_TaskSpecBase): "upstream results." ), ) + for_each: str | None = Field( + default=None, + exclude_if=lambda value: value is None, + description=( + "Optional full upstream-result reference resolving to a JSON array. " + "One Sub Agent task is created for each array item." + ), + ) type _TaskSpec = Annotated[ @@ -181,25 +214,40 @@ class CancelWorkflowParams(BaseModel): ) +def _effective_runtime_status(status: Any) -> str: + """Return the tool-facing runtime status for a Durable instance. + + Durable has no first-class cooperative-cancel or controlled-failure + terminal state: the orchestrator returns a normal ``Completed`` output + whose payload signals the outcome. We translate a ``canceled`` output to + ``Canceled`` and a controlled ``failed`` output to ``Failed`` so callers + can branch on ``runtime_status`` alone. A native Durable ``Failed`` (raised + invariant / Activity/provider failure) keeps its opaque output untouched. + """ + runtime_status = _runtime_status_name(status) + output = getattr(status, "output", None) + if runtime_status == "Completed" and isinstance(output, dict): + if output.get("canceled") is True: + return "Canceled" + if output.get("failed") is True: + return "Failed" + return runtime_status + + def status_envelope(status: Any) -> dict[str, Any]: """Normalize a Durable instance status into the tool-facing envelope. Translates a successfully-returned cooperative-cancel output into - ``runtime_status="Canceled"`` so callers (LLM tools, UI cards, drain - endpoint) can distinguish cooperative cancel from clean success - without inspecting the output payload. Hard ``terminate`` is left as - Durable's native ``Terminated`` status. + ``runtime_status="Canceled"`` and a controlled runtime failure output + (``output.failed is True``) into ``runtime_status="Failed"`` so callers + (LLM tools, UI cards, drain endpoint) can distinguish them from clean + success without inspecting the output payload. Hard ``terminate`` is left + as Durable's native ``Terminated`` status. """ if status is None: return {"workflow_id": None, "runtime_status": "not_found"} - runtime_status = _runtime_status_name(status) + runtime_status = _effective_runtime_status(status) output = status.output - if ( - runtime_status == "Completed" - and isinstance(output, dict) - and output.get("canceled") is True - ): - runtime_status = "Canceled" return { "workflow_id": status.instance_id, "runtime_status": runtime_status, @@ -221,15 +269,7 @@ def _runtime_status_name(status: Any) -> str: def _is_active_status(status: Any) -> bool: - runtime_status = _runtime_status_name(status) - output = getattr(status, "output", None) - if ( - runtime_status == "Completed" - and isinstance(output, dict) - and output.get("canceled") is True - ): - runtime_status = "Canceled" - return runtime_status not in _TERMINAL_RUNTIME_STATUSES + return _effective_runtime_status(status) not in _TERMINAL_RUNTIME_STATUSES async def fetch_session_workflows( @@ -368,7 +408,12 @@ async def start_workflow( policy=policy, ) except PlanValidationError as exc: - return _error(str(exc)) + metadata: dict[str, str | None] = {} + if exc.error_code is not None: + metadata["error_code"] = exc.error_code + metadata["node_id"] = exc.node_id + metadata["path"] = exc.path + return _error(str(exc), **metadata) owner = { "session_id": session.session_id, @@ -397,6 +442,10 @@ async def start_workflow( client_input={ "tasks": plan_to_activity_inputs(plan), "owner": owner, + "policy": { + "allowed_tools": sorted(policy.allowed_tools), + "allowed_subagents": sorted(policy.allowed_subagents), + }, }, ) except Exception: diff --git a/tests/test_chat_ui.py b/tests/test_chat_ui.py index dae0225..aab3c33 100644 --- a/tests/test_chat_ui.py +++ b/tests/test_chat_ui.py @@ -123,7 +123,6 @@ def test_delayed_history_response_cannot_replace_newer_chat_activity() -> None: _run_node(harness) - @pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is not installed") def test_delayed_workflow_response_is_discarded_after_session_switch() -> None: script = _script_text() @@ -203,3 +202,175 @@ def test_session_changes_invalidate_history_and_workflow_identity() -> None: poll_block = script[poll_start:poll_end] assert "const epoch = workflowEpoch;" in poll_block assert poll_block.count("workflowIdentityIsCurrent(") == 3 + + +def _status_formatter_functions(script: str) -> str: + """Extract the two pure `custom_status` formatter helpers. + + ``formatWorkflowStatus`` and ``formatDynamicWorkflowStatus`` live + contiguously just above ``renderWorkflowCard`` so they can be lifted + into a Node harness without their DOM-touching caller. + """ + start = script.index("function formatWorkflowStatus(") + end = script.index("function renderWorkflowCard(", start) + return script[start:end] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is not installed") +def test_format_workflow_status_passes_legacy_string_through_verbatim() -> None: + functions = _status_formatter_functions(_script_text()) + harness = textwrap.dedent( + """ + __STATUS_FUNCTIONS__ + + // Schema version 1: a legacy free-form string must render exactly + // as authored, byte-for-byte, with no reformatting. + const legacy = "3/7 tasks done, current=summarize"; + if (formatWorkflowStatus(legacy) !== legacy) { + throw new Error("legacy string custom_status was not preserved verbatim"); + } + // Non-string, non-object inputs collapse to an empty status line. + for (const value of [null, undefined, 42]) { + if (formatWorkflowStatus(value) !== "") { + throw new Error("non-string/non-object custom_status did not degrade to empty"); + } + } + // An unknown object shape (future schema_version) degrades to JSON, + // never "[object Object]", and never throws. + const unknown = { schema_version: 99, foo: "bar" }; + const rendered = formatWorkflowStatus(unknown); + if (rendered.includes("[object Object]")) { + throw new Error("unknown object shape stringified to [object Object]"); + } + if (rendered !== JSON.stringify(unknown)) { + throw new Error("unknown object shape did not degrade to JSON"); + } + // A cyclic object can't be JSON-serialized; the formatter must still + // return a string rather than throwing. + const cyclic = { schema_version: 5 }; + cyclic.self = cyclic; + if (typeof formatWorkflowStatus(cyclic) !== "string") { + throw new Error("unserializable object shape threw instead of degrading"); + } + """ + ).replace("__STATUS_FUNCTIONS__", functions) + + _run_node(harness) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is not installed") +def test_format_workflow_status_renders_v2_dynamic_snapshot() -> None: + functions = _status_formatter_functions(_script_text()) + harness = textwrap.dedent( + """ + __STATUS_FUNCTIONS__ + + // A running for_each expansion: expanded logical node with completed, + // skipped, and running instances alongside static nodes. + const running = { + schema_version: 2, + counts: { + logical_total: 3, + materialized_total: 5, + completed: 2, + skipped: 1, + running: 1, + }, + nodes: { + discover: { state: "completed" }, + analyze: { + state: "running", + expanded_count: 3, + instances: { + "analyze[0]": { state: "completed" }, + "analyze[1]": { state: "skipped" }, + "analyze[2]": { state: "running" }, + }, + }, + summarize: { state: "pending" }, + }, + }; + const runningLine = formatWorkflowStatus(running); + const expectedRunning = + "2/5 done \u00b7 1 running \u00b7 1 skipped — " + + "discover: completed, analyze: running (1/3), summarize: pending"; + if (runningLine !== expectedRunning) { + throw new Error("running v2 snapshot rendered as: " + runningLine); + } + + // An aggregated for_each node after its ordered result committed. + const aggregated = { + schema_version: 2, + counts: { + logical_total: 2, + materialized_total: 2, + completed: 1, + skipped: 1, + running: 0, + }, + nodes: { + analyze: { + state: "aggregated", + expanded_count: 2, + instances: { + "analyze[0]": { state: "completed" }, + "analyze[1]": { state: "skipped" }, + }, + }, + summarize: { state: "completed" }, + }, + }; + const aggregatedLine = formatWorkflowStatus(aggregated); + if (aggregatedLine !== + "1/2 done \u00b7 1 skipped — analyze: aggregated (1/2), summarize: completed") { + throw new Error("aggregated v2 snapshot rendered as: " + aggregatedLine); + } + + // A freshly-expanded node (materialized, nothing running yet) must + // surface the `expanded` state and a 0/N instance progress. + const expanded = { + schema_version: 2, + counts: { + logical_total: 2, + materialized_total: 3, + completed: 0, + skipped: 0, + running: 0, + }, + nodes: { + analyze: { + state: "expanded", + expanded_count: 2, + instances: { + "analyze[0]": { state: "pending" }, + "analyze[1]": { state: "pending" }, + }, + }, + }, + }; + const expandedLine = formatWorkflowStatus(expanded); + if (!expandedLine.includes("analyze: expanded (0/2)")) { + throw new Error("expanded v2 snapshot missing instance progress: " + expandedLine); + } + if (!expandedLine.startsWith("0/3 done")) { + throw new Error("expanded v2 snapshot miscounted: " + expandedLine); + } + + // Malformed / partial v2 objects must degrade, not throw. + for (const partial of [ + { schema_version: 2 }, + { schema_version: 2, counts: null, nodes: "oops" }, + { schema_version: 2, counts: {}, nodes: { a: null } }, + ]) { + const out = formatWorkflowStatus(partial); + if (typeof out !== "string") { + throw new Error("partial v2 snapshot did not degrade to a string"); + } + if (out.includes("[object Object]")) { + throw new Error("partial v2 snapshot leaked [object Object]"); + } + } + """ + ).replace("__STATUS_FUNCTIONS__", functions) + + _run_node(harness) diff --git a/tests/test_incident_tools.py b/tests/test_incident_tools.py index 05e1543..9c8c1e4 100644 --- a/tests/test_incident_tools.py +++ b/tests/test_incident_tools.py @@ -97,4 +97,84 @@ def test_sample_workflow_tools_are_auto_discovered(): "fetch_metrics", "fetch_deploys", "summarize_findings", + "discover_services", + "inspect_service", + "summarize_scan", } + + +def test_discover_services_is_deterministic_and_bounded(): + first = incident_tools.discover_services({"incident": "latency on orders-api"}) + second = incident_tools.discover_services({"incident": "latency on orders-api"}) + assert first == second # deterministic function of the incident text + services = first["services"] + assert first["count"] == len(services) + # Bounded fan-out stays well under the workflow max_nodes budget. + assert 3 <= len(services) <= 5 + names = [s["name"] for s in services] + assert len(names) == len(set(names)) # no duplicate instances + assert all({"name", "tier", "in_scope"} <= set(s) for s in services) + + +def test_discover_services_always_marks_at_least_one_item_for_skip(): + # Across a range of incidents the discovery result must always include a + # skip candidate (in_scope=false) so the for_each `when` demo has an item + # to skip regardless of the bounded slice size. + for incident in ("orders-api p99 spike", "checkout 502s", "queue backlog", "x"): + services = incident_tools.discover_services({"incident": incident})["services"] + skipped = [s for s in services if not s["in_scope"]] + assert skipped, incident + assert all(s["tier"] == "low" for s in skipped) + + +def test_discover_services_requires_incident(): + with pytest.raises(ValueError, match="incident"): + incident_tools.discover_services({}) + + +def test_inspect_service_shape_and_determinism(): + first = incident_tools.inspect_service({"service": "orders-api", "index": 2}) + second = incident_tools.inspect_service({"service": "orders-api", "index": 2}) + assert first == second + assert first["service"] == "orders-api" + assert first["index"] == 2 + assert isinstance(first["healthy"], bool) + assert first["saturation"] in ("moderate", "high") + assert first["service"] in first["headline"] + + +def test_summarize_scan_consumes_ordered_aggregate_with_skips(): + # Shape mirrors the ordered {index, status, result} aggregate a logical + # for_each node exposes: skipped positions carry result=null. + aggregate = [ + { + "index": 0, + "status": "completed", + "result": incident_tools.inspect_service({"service": "orders-api"}), + }, + {"index": 1, "status": "skipped", "result": None}, + { + "index": 2, + "status": "completed", + "result": {"service": "payments-api", "healthy": False, "headline": "payments-api: down"}, + }, + ] + out = incident_tools.summarize_scan( + {"incident": "orders latency", "findings": aggregate} + ) + assert out["scanned"] == 2 + assert out["skipped"] == 1 + assert "payments-api: down" in out["unhealthy"] + assert out["incident"] == "orders latency" + + +def test_summarize_scan_rejects_non_aggregate_findings(): + # If the LLM passes an embedded template ref, the substitutor stringifies + # the array; the handler must reject that loudly, not silently degrade. + with pytest.raises(ValueError, match="whole for_each aggregate"): + incident_tools.summarize_scan({"findings": '[{"index": 0}]'}) + + +def test_summarize_scan_rejects_malformed_envelope(): + with pytest.raises(ValueError, match="envelope"): + incident_tools.summarize_scan({"findings": ["not-a-dict"]}) diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index 6640f04..a93f377 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import UTC, datetime from typing import Any import pytest @@ -13,7 +14,13 @@ 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.schema import ( + MAX_NODES, + MAX_PARALLELISM, + SUB_AGENT_TASK_TYPE, + TOOL_TASK_TYPE, + WAIT_TASK_TYPE, +) class _FakeApp: @@ -301,3 +308,854 @@ def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: "2/3 tasks done, running=report", "3/3 tasks done", ] + + +# --------------------------------------------------------------------------- +# Dynamic (data-driven) orchestration — Issue #1276. +# --------------------------------------------------------------------------- + + +class _DynamicContext(_FakeOrchestrationContext): + """Fake context that also supports timers, a clock, and a persisted policy.""" + + def __init__( + self, + tasks: list[dict[str, Any]], + result_for: Callable[[str, dict[str, Any]], dict[str, Any]], + *, + policy: dict[str, Any] | None = None, + now: datetime | None = None, + ) -> None: + super().__init__(tasks, result_for) + self._input["policy"] = policy or {} + self._now = now or datetime(2024, 1, 1, tzinfo=UTC) + self.timers: list[_Task] = [] + + @property + def current_utc_datetime(self) -> datetime: + return self._now + + def create_timer(self, deadline: datetime) -> _Task: + timer = _Task() + timer.is_completed = False + self.timers.append(timer) + return timer + + +def _run_dynamic( + tasks: list[dict[str, Any]], + *, + policy: dict[str, Any], + result_for: Callable[[str, dict[str, Any]], dict[str, Any]], + now: datetime | None = None, +) -> tuple[dict[str, Any], _DynamicContext]: + context = _DynamicContext(tasks, result_for, policy=policy, now=now) + orchestrator = _registered_function(engine.ORCHESTRATOR_NAME) + result = _run_orchestrator(orchestrator, context) + return result, context + + +def _activity_ids(context: _FakeOrchestrationContext, name: str) -> list[str]: + return [payload["id"] for called, payload in context.calls if called == name] + + +# --- Static-path preservation --------------------------------------------- + + +def test_plan_is_dynamic_detection() -> None: + static = [{"id": "a", "type": TOOL_TASK_TYPE, "tool": "t", "depends_on": []}] + with_when = [ + { + "id": "a", + "type": TOOL_TASK_TYPE, + "tool": "t", + "depends_on": [], + "when": {"ref": "${b.result.x}", "operator": "equals", "value": 1}, + } + ] + with_for_each = [ + { + "id": "a", + "type": TOOL_TASK_TYPE, + "tool": "t", + "depends_on": [], + "for_each": "${b.result.items}", + } + ] + assert engine._plan_is_dynamic(static) is False + assert engine._plan_is_dynamic(with_when) is True + assert engine._plan_is_dynamic(with_for_each) is True + + +def test_static_plan_keeps_exact_string_custom_status() -> None: + tasks = [ + {"id": "a", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "b", + "type": TOOL_TASK_TYPE, + "tool": "collect", + "args": {}, + "depends_on": ["a"], + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": payload["id"], "result": {"ok": payload["id"]}} + + context = _FakeOrchestrationContext(tasks, result_for) + orchestrator = _registered_function(engine.ORCHESTRATOR_NAME) + result = _run_orchestrator(orchestrator, context) + + assert result == {"results": {"a": {"ok": "a"}, "b": {"ok": "b"}}} + # Static path publishes plain strings, never structured dict snapshots. + assert all(isinstance(status, str) for status in context.statuses) + assert context.statuses == [ + "0/2 tasks done, running=a", + "1/2 tasks done, next=b", + "1/2 tasks done, running=b", + "2/2 tasks done", + ] + + +# --- Conditions ------------------------------------------------------------ + + +def test_condition_true_resolves_args_and_runs() -> None: + tasks = [ + {"id": "src", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "act", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {"echoed": "${src.result.val}"}, + "depends_on": ["src"], + "when": {"ref": "${src.result.flag}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "src": + return {"id": "src", "result": {"flag": True, "val": "hi"}} + return {"id": payload["id"], "result": {"ok": True}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["act"] == {"ok": True} + act_call = next(p for _, p in context.calls if p["id"] == "act") + assert act_call["args"] == {"echoed": "hi"} + + +def test_condition_false_skips_before_resolving_args() -> None: + # ``act`` references a missing path in its args; if the predicate were + # evaluated after args (or not at all) this plan would fail. Predicate + # runs first, the task is skipped, and the bad args are never resolved. + tasks = [ + {"id": "src", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "act", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {"x": "${src.result.MISSING}"}, + "depends_on": ["src"], + "when": {"ref": "${src.result.flag}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "src", "result": {"flag": False}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["act"] is None + assert "noop" not in [p.get("tool") for _, p in context.calls] + assert result.get("failed") is None + final = context.statuses[-1] + assert final["nodes"]["act"]["state"] == "skipped" + + +def test_normal_skip_unlocks_descendants() -> None: + tasks = [ + {"id": "src", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "b", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {}, + "depends_on": ["src"], + "when": {"ref": "${src.result.flag}", "operator": "equals", "value": True}, + }, + { + "id": "c", + "type": TOOL_TASK_TYPE, + "tool": "finish", + "args": {}, + "depends_on": ["b"], + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "src": + return {"id": "src", "result": {"flag": False}} + return {"id": payload["id"], "result": {"done": payload["id"]}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop", "finish"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["b"] is None + assert result["results"]["c"] == {"done": "c"} + # A skipped node satisfies the dependency without dispatching an activity. + assert "noop" not in [p.get("tool") for _, p in context.calls] + + +def test_full_reference_to_skipped_result_resolves_to_null() -> None: + tasks = [ + {"id": "src", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "skipped", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {}, + "depends_on": ["src"], + "when": {"ref": "${src.result.run}", "operator": "equals", "value": True}, + }, + { + "id": "sink", + "type": TOOL_TASK_TYPE, + "tool": "finish", + "args": {"value": "${skipped.result}"}, + "depends_on": ["skipped"], + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "src": + return {"id": "src", "result": {"run": False}} + return { + "id": payload["id"], + "result": {"seen": payload["args"]["value"]}, + } + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop", "finish"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["skipped"] is None + assert result["results"]["sink"] == {"seen": None} + sink_call = next(payload for _, payload in context.calls if payload["id"] == "sink") + assert sink_call["args"] == {"value": None} + + +def test_dotted_reference_below_skipped_result_is_controlled_failure() -> None: + tasks = [ + {"id": "src", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "skipped", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {}, + "depends_on": ["src"], + "when": {"ref": "${src.result.run}", "operator": "equals", "value": True}, + }, + { + "id": "sink", + "type": TOOL_TASK_TYPE, + "tool": "finish", + "args": {"value": "${skipped.result.field}"}, + "depends_on": ["skipped"], + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "src", "result": {"run": False}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop", "finish"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_reference_unresolved" + assert result["node_id"] == "sink" + assert result["path"] is None + assert result["results"] == {"src": {"run": False}, "skipped": None} + assert context.statuses[-1]["nodes"]["sink"]["state"] == "failed" + assert "finish" not in [payload.get("tool") for _, payload in context.calls] + + +def test_non_scalar_condition_is_condition_invalid() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "act", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {}, + "depends_on": ["disc"], + "when": {"ref": "${disc.result.obj}", "operator": "equals", "value": "x"}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"obj": {"a": 1}}} + + result, _ = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "noop"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_condition_invalid" + assert result["node_id"] == "act" + + +# --- for_each expansion ---------------------------------------------------- + + +def test_expanded_mixed_run_skip_aggregate_source_order() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {"i": "${index}"}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + "when": {"ref": "${item.open}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return { + "id": "disc", + "result": {"items": [{"open": True}, {"open": False}, {"open": True}]}, + } + return {"id": payload["id"], "result": {"idx": payload["args"]["i"]}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["analyze"] == [ + {"index": 0, "status": "completed", "result": {"idx": 0}}, + {"index": 1, "status": "skipped", "result": None}, + {"index": 2, "status": "completed", "result": {"idx": 2}}, + ] + # The skipped element (index 1) never dispatches an activity. + assert _activity_ids(context, engine._ACTIVITY_NAME) == [ + "disc", + "analyze[0]", + "analyze[2]", + ] + + +def test_empty_expansion_aggregates_immediately() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + { + "id": "sink", + "type": TOOL_TASK_TYPE, + "tool": "noop", + "args": {"all": "${analyze.result}"}, + "depends_on": ["analyze"], + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return {"id": "disc", "result": {"items": []}} + return {"id": payload["id"], "result": {"ok": True}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at", "noop"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["analyze"] == [] + assert "at" not in [p.get("tool") for _, p in context.calls] + sink_call = next(p for _, p in context.calls if p["id"] == "sink") + assert sink_call["args"] == {"all": []} + + +def test_all_skipped_expansion_aggregates_without_dispatch() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {"i": "${index}"}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + "when": {"ref": "${item.open}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "id": "disc", + "result": {"items": [{"open": False}, {"open": False}]}, + } + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["results"]["analyze"] == [ + {"index": 0, "status": "skipped", "result": None}, + {"index": 1, "status": "skipped", "result": None}, + ] + assert _activity_ids(context, engine._ACTIVITY_NAME) == ["disc"] + assert context.statuses[-1]["nodes"]["analyze"]["state"] == "aggregated" + + +def test_numeric_scheduling_under_parallel_cap() -> None: + count = 12 + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {"i": "${index}"}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return {"id": "disc", "result": {"items": [{} for _ in range(count)]}} + return {"id": payload["id"], "result": {"idx": payload["args"]["i"]}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + # Numeric (not lexical) order: analyze[10] follows analyze[2], not analyze[1]. + assert _activity_ids(context, engine._ACTIVITY_NAME) == [ + "disc", + *[f"analyze[{i}]" for i in range(count)], + ] + assert len(result["results"]["analyze"]) == count + # Parallelism cap: a wave never runs more than MAX_PARALLELISM instances, + # and the first analyze wave saturates the cap (proving a second wave ran). + running_peaks = [ + s["counts"]["running"] for s in context.statuses if isinstance(s, dict) + ] + assert max(running_peaks) <= MAX_PARALLELISM + assert MAX_PARALLELISM in running_peaks + + +def test_multiple_expansions_run_in_sorted_logical_id_order() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "grp_a", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.a}", + }, + { + "id": "grp_b", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.b}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return {"id": "disc", "result": {"a": [{}], "b": [{}]}} + return {"id": payload["id"], "result": {"ok": payload["id"]}} + + _, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert _activity_ids(context, engine._ACTIVITY_NAME) == [ + "disc", + "grp_a[0]", + "grp_b[0]", + ] + + +def test_node_limit_atomic_rejection_counts_skipped_items() -> None: + # 50 elements + 1 reserved non-for_each node (disc) exceeds MAX_NODES. + # Every element would be skipped by the predicate, yet the plan is + # rejected before any instance is created — skipped items still consume + # the budget, and the rejection is atomic (no partial materialization). + over = MAX_NODES # 50 elements → 1 + 50 > 50 + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + "when": {"ref": "${item.open}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": [{"open": False} for _ in range(over)]}} + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_node_limit_exceeded" + assert result["node_id"] == "analyze" + # Atomic: only ``disc`` ran; no analyze instances were dispatched. + assert _activity_ids(context, engine._ACTIVITY_NAME) == ["disc"] + + +def test_node_limit_is_cumulative_across_expansions() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "grp_a", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.a}", + }, + { + "id": "grp_b", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.b}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "id": "disc", + "result": {"a": [{} for _ in range(24)], "b": [{} for _ in range(26)]}, + } + + result, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_node_limit_exceeded" + assert result["node_id"] == "grp_b" + assert result["results"] == { + "disc": {"a": [{} for _ in range(24)], "b": [{} for _ in range(26)]} + } + assert _activity_ids(context, engine._ACTIVITY_NAME) == ["disc"] + + +def test_dynamic_replay_produces_identical_calls_statuses_and_results() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {"i": "${index}"}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return {"id": "disc", "result": {"items": [{}, {}, {}]}} + return {"id": payload["id"], "result": {"idx": payload["args"]["i"]}} + + first_result, first_context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + replay_result, replay_context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert replay_result == first_result + assert replay_context.calls == first_context.calls + assert replay_context.statuses == first_context.statuses + + +def test_for_each_non_array_is_iteration_not_array() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": {"not": "a list"}}} + + result, _ = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + # Stable, flat controlled-failure envelope shape. + assert set(result) == {"failed", "error", "error_code", "node_id", "path", "results"} + assert result["failed"] is True + assert result["error_code"] == "workflow_iteration_not_array" + assert result["node_id"] == "analyze" + assert result["path"] == "${disc.result.items}" + # Committed logical results are preserved through the failure. + assert result["results"]["disc"] == {"items": {"not": "a list"}} + + +def test_for_each_missing_upstream_path_is_reference_unresolved() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.MISSING}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": [{}]}} + + result, _ = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_reference_unresolved" + assert result["node_id"] == "analyze" + + +def test_expanded_missing_item_path_uses_instance_node_id() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + "when": {"ref": "${item.missing}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": [{}]}} + + result, _ = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + assert result["failed"] is True + assert result["error_code"] == "workflow_reference_unresolved" + assert result["node_id"] == "analyze[0]" + assert result["path"] == "${item.missing}" + + +# --- Immutable owner policy (fail closed) ---------------------------------- + + +def test_expanded_tool_outside_policy_raises() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "restricted", + "args": {}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": [{}]}} + + with pytest.raises(RuntimeError, match="outside the persisted workflow owner policy"): + _run_dynamic( + tasks, + policy={"allowed_tools": ["collect"], "allowed_subagents": []}, + result_for=result_for, + ) + + +def test_expanded_sub_agent_outside_policy_raises() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": SUB_AGENT_TASK_TYPE, + "agent": "unlisted", + "task": "Analyze ${item}.", + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "disc", "result": {"items": ["x"]}} + + with pytest.raises(RuntimeError, match="outside the persisted workflow owner policy"): + _run_dynamic( + tasks, + policy={"allowed_tools": ["collect"], "allowed_subagents": ["known"]}, + result_for=result_for, + ) + + +# --- Structured status snapshots ------------------------------------------- + + +def test_dynamic_status_snapshots_track_states_and_counts() -> None: + tasks = [ + {"id": "disc", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "analyze", + "type": TOOL_TASK_TYPE, + "tool": "at", + "args": {"i": "${index}"}, + "depends_on": ["disc"], + "for_each": "${disc.result.items}", + "when": {"ref": "${item.open}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload["id"] == "disc": + return { + "id": "disc", + "result": {"items": [{"open": True}, {"open": False}, {"open": True}]}, + } + return {"id": payload["id"], "result": {"idx": payload["args"]["i"]}} + + _, context = _run_dynamic( + tasks, + policy={"allowed_tools": ["collect", "at"], "allowed_subagents": []}, + result_for=result_for, + ) + + snapshots = [s for s in context.statuses if isinstance(s, dict)] + assert snapshots, "dynamic path must publish structured snapshots" + assert all(s["schema_version"] == 2 for s in snapshots) + + analyze_states = [s["nodes"]["analyze"]["state"] for s in snapshots] + assert "expanded" in analyze_states + assert "running" in analyze_states + + final = snapshots[-1] + assert final["nodes"]["analyze"]["state"] == "aggregated" + assert final["counts"] == { + "logical_total": 2, + "materialized_total": 4, # disc + 3 analyze instances (incl. skipped) + "completed": 3, # disc + analyze[0] + analyze[2] + "skipped": 1, # analyze[1] + "running": 0, + } + + +# --- Cancellation with a dynamic timer ------------------------------------- + + +def test_dynamic_cancellation_cancels_timer_and_returns_partial() -> None: + tasks = [ + {"id": "t1", "type": TOOL_TASK_TYPE, "tool": "collect", "args": {}, "depends_on": []}, + { + "id": "w1", + "type": WAIT_TASK_TYPE, + "duration": "PT1H", + "depends_on": ["t1"], + "when": {"ref": "${t1.result.go}", "operator": "equals", "value": True}, + }, + ] + + def result_for(name: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": "t1", "result": {"go": True}} + + context = _DynamicContext( + tasks, + result_for, + policy={"allowed_tools": ["collect"], "allowed_subagents": []}, + ) + context.cancel_task.result = "user-request" + orchestrator = _registered_function(engine.ORCHESTRATOR_NAME) + + gen = orchestrator(context) + next(gen) # yields the t1 wave + gen.send(context.last_wave) # completes t1, expands w1, dispatches its timer + result: dict[str, Any] = {} + try: + gen.send(context.cancel_task) # cancel while the timer is pending + except StopIteration as stop: + result = stop.value + + assert result["canceled"] is True + assert result["reason"] == "user-request" + assert result["results"]["t1"] == {"go": True} + assert result["completed_count"] == 1 + assert result["total_count"] == 2 + # The pending durable timer was cancelled. + assert context.timers and all(timer.cancelled for timer in context.timers) diff --git a/tests/test_workflow_registry.py b/tests/test_workflow_registry.py index ccb8c29..06ed24d 100644 --- a/tests/test_workflow_registry.py +++ b/tests/test_workflow_registry.py @@ -345,6 +345,48 @@ def test_addendum_includes_per_tool_descriptions(): assert "Sample tool for the addendum-rendering test." in addendum +def test_addendum_documents_data_driven_control_flow_grammar(): + """Regression guard: the shared addendum teaches the deterministic + `when` / `for_each` control-flow grammar and authoring rules so the + model can author dynamic plans (FRD 0004, Issue #1276). Both channels + inherit the shared section. + """ + result = integration.build_workflow_integration( + _FakeApp(), + _enable_metadata(), + workflow_tools=[_workflow_tool("alpha", "alpha desc")], + ) + for addendum in (result.chat_system_addendum, result.trigger_system_addendum): + # for_each: tool/sub_agent only, full upstream array ref. + assert "`for_each`" in addendum + assert "tool` or `sub_agent`" in addendum + assert "never `wait`" in addendum + assert "${discover.result.items}" in addendum + # Iteration locals. + assert "${item}" in addendum + assert "${item.path.to.field}" in addendum + assert "${index}" in addendum + assert "only inside a `for_each` task" in addendum + # Static targets; only value fields vary. + assert "Keep the target tool/agent name static" in addendum + # when: object with ref/operator/scalar value, strict equality. + assert "`when`" in addendum + assert '"operator": "equals" | "not_equals"' in addendum + assert "JSON scalar" in addendum + assert "exact typed equality" in addendum + # Skip semantics: null, does not propagate, own `when`. + assert "skips the task" in addendum + assert "skip does not propagate" in addendum + # Condition evaluated before executable templates resolve. + assert "evaluated before" in addendum + # Ordered aggregation of {index, status, result} envelopes. + assert "{index, status, result}" in addendum + assert "source order" in addendum + assert "${node_id.result}" in addendum + # Bounded arrays / caps guidance. + assert "already bounded" in addendum + + def test_integration_builds_owner_specific_policy_and_sub_agent_guidance() -> None: result = integration.build_workflow_integration( _FakeApp(), @@ -718,6 +760,73 @@ def test_start_workflow_params_survive_framework_default_materialization() -> No } +def test_start_workflow_params_serialize_dynamic_task_fields_when_supplied() -> None: + params = tools.StartWorkflowParams( + tasks=[ + { + "id": "discover", + "tool": "discover_pull_requests", + }, + { + "id": "analyze", + "tool": "analyze_pull_request", + "depends_on": ["discover"], + "for_each": "${discover.result.items}", + "when": { + "ref": "${item.open}", + "operator": "equals", + "value": True, + }, + "args": {"url": "${item.url}", "index": "${index}"}, + }, + ] + ) + + assert params.model_dump()["tasks"][1] == { + "id": "analyze", + "depends_on": ["discover"], + "when": {"ref": "${item.open}", "operator": "equals", "value": True}, + "type": "tool", + "tool": "analyze_pull_request", + "args": {"url": "${item.url}", "index": "${index}"}, + "for_each": "${discover.result.items}", + } + + +@pytest.mark.asyncio +async def test_start_workflow_serializes_stable_reference_validation_metadata() -> None: + class _UnexpectedClient: + async def get_status_all(self): + raise AssertionError("validation must fail before Durable scheduling") + + session = context.WorkflowSessionContext( + session_id="session-1", + agent_name="coordinator", + durable_client=_UnexpectedClient(), + token="", + ) + params = tools.StartWorkflowParams( + tasks=[ + { + "id": "target", + "tool": "__echo", + "args": {"value": "${missing.result.value}"}, + } + ] + ) + policy = schema.WorkflowPlanPolicy( + allowed_tools=frozenset({"__echo"}), + allowed_subagents=frozenset(), + ) + + result = json.loads(await tools.start_workflow(params, session, policy=policy)) + + assert result["error_code"] == "workflow_reference_unresolved" + assert result["node_id"] == "target" + assert result["path"] == "args.value" + assert "unknown task" in result["error"] + + @pytest.mark.asyncio async def test_fetch_session_workflows_returns_newest_session_workflows_up_to_v1_cap(): session_id = "session-1" @@ -749,3 +858,108 @@ async def test_fetch_session_workflows_returns_newest_session_workflows_up_to_v1 ) assert envelopes[0]["last_updated_time"].endswith("00:00:29+00:00") assert envelopes[-1]["last_updated_time"].endswith("00:00:05+00:00") + + +# --- Controlled-failure status mapping (Issue #1276) ----------------------- + + +def _failed_output() -> dict: + return { + "failed": True, + "error": "task 'analyze': for_each did not resolve to an array", + "error_code": "workflow_iteration_not_array", + "node_id": "analyze", + "path": "${disc.result.items}", + "results": {"disc": {"items": {"not": "a list"}}}, + } + + +def test_status_envelope_maps_failed_output_to_failed_runtime_status(): + status = _FakeStatus( + "wf-1", + "Completed", + output=_failed_output(), + custom_status={"schema_version": 2}, + ) + + envelope = tools.status_envelope(status) + + assert envelope["runtime_status"] == "Failed" + # The controlled-failure payload is passed through untouched. + assert envelope["output"] == _failed_output() + + +def test_is_active_status_false_for_failed_output(): + status = _FakeStatus("wf-1", "Completed", output=_failed_output()) + + assert tools._is_active_status(status) is False + + +def test_status_envelope_completed_success_stays_completed(): + status = _FakeStatus( + "wf-1", "Completed", output={"results": {"a": {"ok": True}}} + ) + + assert tools.status_envelope(status)["runtime_status"] == "Completed" + + +def test_status_envelope_canceled_output_still_maps_to_canceled(): + status = _FakeStatus( + "wf-1", + "Completed", + output={"results": {}, "canceled": True, "reason": "stop"}, + ) + + assert tools.status_envelope(status)["runtime_status"] == "Canceled" + + +def test_status_envelope_native_failed_output_is_untouched(): + native = {"opaque": "provider stack trace"} + status = _FakeStatus("wf-1", "Failed", output=native) + + envelope = tools.status_envelope(status) + + # A native Durable Failed keeps its runtime_status and opaque output; + # the controlled-failure adapter only fires on Completed outputs. + assert envelope["runtime_status"] == "Failed" + assert envelope["output"] == native + + +# --- Owner policy persisted in the orchestration client input -------------- + + +class _CapturingDurableClient: + def __init__(self): + self.client_input = None + + async def get_status_all(self, *args, **kwargs): + return [] + + async def start_new(self, *args, **kwargs): + self.client_input = kwargs["client_input"] + return kwargs["instance_id"] + + +@pytest.mark.asyncio +async def test_start_workflow_persists_sorted_owner_policy_in_client_input(): + client = _CapturingDurableClient() + session = context.WorkflowSessionContext( + session_id="session-1", + agent_name="coordinator", + durable_client=client, + token="", + ) + params = tools.StartWorkflowParams( + tasks=[{"id": "target", "tool": "__echo", "args": {"value": "hi"}}] + ) + policy = schema.WorkflowPlanPolicy( + allowed_tools=frozenset({"__echo"}), + allowed_subagents=frozenset({"zeta", "alpha"}), + ) + + result = json.loads(await tools.start_workflow(params, session, policy=policy)) + + assert "workflow_id" in result + persisted = client.client_input["policy"] + assert persisted["allowed_tools"] == sorted(policy.allowed_tools) + assert persisted["allowed_subagents"] == ["alpha", "zeta"] diff --git a/tests/test_workflow_schema.py b/tests/test_workflow_schema.py index ec141ec..a74f29d 100644 --- a/tests/test_workflow_schema.py +++ b/tests/test_workflow_schema.py @@ -25,7 +25,9 @@ SUB_AGENT_TASK_TYPE, PlanValidationError, TemplateResolutionError, + WorkflowCondition, WorkflowPlanPolicy, + evaluate_condition, parse_iso8601_datetime, parse_iso8601_duration, plan_to_activity_inputs, @@ -973,3 +975,299 @@ def test_rejects_non_string_task_id(): raw = _plan({"id": 42, "type": "tool", "tool": ECHO_TOOL_NAME, "args": {}}) with pytest.raises(PlanValidationError): validate_plan(raw) + + +# --------------------------------------------------------------------------- +# conditional and iterative workflow tasks +# --------------------------------------------------------------------------- + + +def test_accepts_condition_and_iteration_on_tool_and_sub_agent_tasks() -> None: + analyze = _task( + "analyze", + depends_on=["discover"], + args={"url": "${item.url}", "index": "${index}"}, + ) + analyze["for_each"] = "${discover.result.pull_requests}" + analyze["when"] = { + "ref": "${item.open}", + "operator": "equals", + "value": True, + } + report = _subagent( + "report", + task="Review ${item.url} at ${index}: ${discover.result.title}", + depends_on=["discover"], + ) + report["for_each"] = "${discover.result.pull_requests}" + report["when"] = { + "ref": "${item.open}", + "operator": "not_equals", + "value": False, + } + + plan = validate_plan(_plan(_task("discover"), analyze, report)) + + assert plan.tasks[1].for_each == "${discover.result.pull_requests}" + assert plan.tasks[1].when == WorkflowCondition( + ref="${item.open}", + operator="equals", + value=True, + ) + assert plan.tasks[2].for_each == "${discover.result.pull_requests}" + + +def test_wait_accepts_condition_but_rejects_iteration() -> None: + conditional_wait = _wait("pause", duration="PT1S", depends_on=["source"]) + conditional_wait["when"] = { + "ref": "${source.result.should_wait}", + "operator": "equals", + "value": True, + } + validate_plan(_plan(_task("source"), conditional_wait)) + + iterated_wait = _wait("pause", duration="PT1S", depends_on=["source"]) + iterated_wait["for_each"] = "${source.result.items}" + with pytest.raises(PlanValidationError, match="'for_each' is not valid") as exc_info: + validate_plan(_plan(_task("source"), iterated_wait)) + assert exc_info.value.error_code == "workflow_reference_unresolved" + assert exc_info.value.node_id == "pause" + assert exc_info.value.path == "for_each" + + +@pytest.mark.parametrize("task_id", ["contains.dot", "node[0]", "space id", "café"]) +def test_rejects_authored_task_ids_outside_runtime_safe_alphabet(task_id: str) -> None: + with pytest.raises(PlanValidationError): + validate_plan(_plan(_task(task_id))) + + +@pytest.mark.parametrize( + "condition", + [ + {"ref": "${source.result.flag}", "operator": "contains", "value": True}, + {"ref": "${source.result.flag}", "operator": "equals", "value": {"bad": True}}, + { + "ref": "${source.result.flag}", + "operator": "equals", + "value": True, + "extra": "no", + }, + ], +) +def test_rejects_invalid_condition_schema_with_stable_metadata( + condition: dict[str, object], +) -> None: + node = _task("target", depends_on=["source"]) + node["when"] = condition + + with pytest.raises(PlanValidationError) as exc_info: + validate_plan(_plan(_task("source"), node)) + + assert exc_info.value.error_code == "workflow_condition_invalid" + assert exc_info.value.node_id == "target" + assert exc_info.value.path.startswith("when") + + +def test_rejects_malformed_condition_reference_with_stable_metadata() -> None: + node = _task("target", depends_on=["source"]) + node["when"] = {"ref": "source.result.flag", "operator": "equals", "value": True} + + with pytest.raises(PlanValidationError, match=r"when\.ref") as exc_info: + validate_plan(_plan(_task("source"), node)) + + assert exc_info.value.error_code == "workflow_condition_invalid" + assert exc_info.value.node_id == "target" + assert exc_info.value.path == "when.ref" + + +def test_validates_iteration_local_scope_and_upstream_references() -> None: + iterated = _task( + "target", + depends_on=["source"], + args={"item": "${item}", "source": "${source.result.name}"}, + ) + iterated["for_each"] = "${source.result.items}" + validate_plan(_plan(_task("source"), iterated)) + + non_iterated = _task( + "target", + depends_on=["source"], + args={"item": "${item.path}"}, + ) + with pytest.raises(PlanValidationError, match="only available on for_each") as exc_info: + validate_plan(_plan(_task("source"), non_iterated)) + assert exc_info.value.error_code == "workflow_reference_unresolved" + assert exc_info.value.node_id == "target" + + +@pytest.mark.parametrize( + ("node", "path"), + [ + ( + { + "id": "tool_target", + "type": "tool", + "tool": "${item.name}", + "args": {}, + "for_each": "${source.result.items}", + "depends_on": ["source"], + }, + "tool", + ), + ( + { + "id": "agent_target", + "type": "sub_agent", + "agent": "${item.agent}", + "task": "Review ${item.url}", + "for_each": "${source.result.items}", + "depends_on": ["source"], + }, + "agent", + ), + ], +) +def test_rejects_templated_iteration_targets( + node: dict[str, object], + path: str, +) -> None: + with pytest.raises(PlanValidationError, match="target must be static") as exc_info: + validate_plan(_plan(_task("source"), node)) + + assert exc_info.value.error_code == "workflow_reference_unresolved" + assert exc_info.value.path == path + + +def test_rejects_non_upstream_iteration_reference() -> None: + node = _task("target", depends_on=["other"]) + node["for_each"] = "${source.result.items}" + + with pytest.raises(PlanValidationError, match="not an upstream dependency") as exc_info: + validate_plan(_plan(_task("source"), _task("other"), node)) + + assert exc_info.value.error_code == "workflow_reference_unresolved" + assert exc_info.value.path == "for_each" + + +def test_plan_to_activity_inputs_serializes_dynamic_fields_only_when_present() -> None: + static = validate_plan(_plan(_task("static"))) + dynamic = _task("dynamic", depends_on=["source"], args={"value": "${item.value}"}) + dynamic["for_each"] = "${source.result.items}" + dynamic["when"] = { + "ref": "${item.enabled}", + "operator": "equals", + "value": True, + } + dynamic_plan = validate_plan(_plan(_task("source"), dynamic)) + + assert plan_to_activity_inputs(static) == [ + {"id": "static", "type": "tool", "tool": ECHO_TOOL_NAME, "args": {}, "depends_on": []} + ] + assert plan_to_activity_inputs(dynamic_plan)[1] == { + "id": "dynamic", + "type": "tool", + "tool": ECHO_TOOL_NAME, + "args": {"value": "${item.value}"}, + "depends_on": ["source"], + "when": {"ref": "${item.enabled}", "operator": "equals", "value": True}, + "for_each": "${source.result.items}", + } + + +def test_resolve_iteration_templates_preserves_native_values_and_mixed_templates() -> None: + results = {"source": {"prefix": "PR"}} + item = {"number": 42, "labels": ["bug", "urgent"]} + + assert resolve_template_value("${item}", results, item=item, index=3) == item + assert resolve_template_value("${item.labels.1}", results, item=item, index=3) == "urgent" + assert resolve_template_value("${index}", results, item=item, index=3) == 3 + assert ( + resolve_template_value( + "${source.result.prefix}-${item.number}-${index}", + results, + item=item, + index=3, + ) + == "PR-42-3" + ) + + +def test_resolve_iteration_paths_require_bound_items_and_existing_paths() -> None: + with pytest.raises(TemplateResolutionError, match="no iteration item"): + resolve_template_value("${item}", {}) + with pytest.raises(TemplateResolutionError, match="key not present"): + resolve_template_value("${item.missing}", {}, item={"present": True}, index=0) + + +def test_iteration_result_field_takes_precedence_over_task_named_item() -> None: + plan = validate_plan( + _plan( + _task("item"), + _task("discover"), + _task( + "analyze", + depends_on=["item", "discover"], + args={"summary": "${item.result.summary}"}, + ) + | {"for_each": "${discover.result.items}"}, + ) + ) + + resolved = resolve_template_value( + plan.tasks[2].args, + {"item": {"summary": "wrong"}}, + item={"result": {"summary": "right"}}, + index=0, + ) + + assert resolved == {"summary": "right"} + + +def test_task_named_item_remains_referenceable_outside_iteration() -> None: + plan = validate_plan( + _plan( + _task("item"), + _task( + "consume", + depends_on=["item"], + args={"summary": "${item.result.summary}"}, + ), + ) + ) + + resolved = resolve_template_value( + plan.tasks[1].args, + {"item": {"summary": "task result"}}, + ) + + assert resolved == {"summary": "task result"} + + +def test_evaluate_condition_is_scalar_and_type_sensitive() -> None: + condition = WorkflowCondition( + ref="${source.result.flag}", + operator="equals", + value=1, + ) + + assert evaluate_condition(condition, {"source": {"flag": 1}}) + assert not evaluate_condition(condition, {"source": {"flag": True}}) + assert not evaluate_condition(condition, {"source": {"flag": 1.0}}) + assert evaluate_condition( + WorkflowCondition(ref="${item.active}", operator="not_equals", value=False), + {}, + item={"active": True}, + index=0, + ) + with pytest.raises(TemplateResolutionError, match="non-scalar"): + evaluate_condition(condition, {"source": {"flag": {"nested": True}}}) + + +def test_authored_node_limit_has_stable_error_code() -> None: + tasks = [_task(f"task_{index}") for index in range(MAX_NODES + 1)] + + with pytest.raises(PlanValidationError) as exc_info: + validate_plan(_plan(*tasks)) + + assert exc_info.value.error_code == "workflow_node_limit_exceeded" + assert exc_info.value.path == "tasks"