FRD 0004 — Dynamic workflows¶
1. Summary¶
Add experimental Dynamic Workflows support to the markdown-first Azure Functions
Agents Runtime. A workflow-enabled main agent can ask the runtime to launch a
Durable Functions-backed DAG of tool and wait tasks, observe progress through
built-in endpoints/UI, and receive final workflow notifications in the chat
session. Workflow task tools are authored under the existing tools/ directory
but opt into Durable Activity execution explicitly with a new @workflow_tool
decorator; normal plain-function tool discovery remains backward compatible.
Workflow-enabled main agents can also start the same Durable workflows from any
supported Markdown-declared trigger; the trigger starts the workflow
asynchronously and does not wait for it to finish.
2. Motivation / problem¶
Today agents can call tools directly through the Microsoft Agent Framework (MAF) during a chat turn. That works well for short, latency-sensitive work, but it is awkward for work that:
- needs multiple dependent tool calls that would otherwise require repeated model round-trips;
- can fan out independent evidence gathering in parallel;
- needs a durable wait without holding a worker or client connection open;
- produces large intermediate results that should stay out of the model context;
- should survive host restarts or a user reconnecting later.
Dynamic Workflows introduces a new authoring surface, so the first release needs
to make workflow tools easy to place, hard to register accidentally, and
consistent with the runtime's existing capability-filtering model. The agreed
model uses the existing tools/ directory as the single placement surface,
preserves normal plain-function tool discovery, and requires @workflow_tool to
explicitly opt a function into the Durable Activity execution path.
3. Goals / Non-goals¶
Goals
- Enable
workflows.enabled: trueformain.agent.mdto register Durable workflow management tools and a Durable orchestrator/activity engine. - Add
workflows.excludeso workflow filtering matches existing exclude-style capability UX (tools.exclude,mcp.exclude,skills.exclude). - Keep sample
function_app.pyminimal so workflow authoring is expressed throughmain.agent.mdplustools/. - Add
@workflow_toolas an explicit workflow authoring decorator for functions placed intools/. - Preserve existing normal
tools/behavior: public plain functions and@toolvalues continue to become normal MAF tools. - Support four clear authoring cases:
- workflow-only:
@workflow_tool; - normal-only: public plain function or
@tool; - both:
@toolplus@workflow_tool, or separate adapters sharing internal business logic; - neither:
_-prefixed helper. - Skip workflow-incompatible functions during workflow registration with a clear warning rather than failing startup when safe to do so.
- Keep discovery read-only and keep Azure Functions/Durable registration in the registration/integration stage.
- Enable every supported Markdown-declared trigger on a workflow-enabled
main.agent.mdto start Dynamic Workflows through the existing runner. - Document the workflow authoring surface in
docs/workflows.md,docs/front-matter-spec.md, anddocs/architecture.md.
Non-goals
- Enabling workflows for non-main agents in v1.
- Hand-authored workflow YAML/markdown templates; workflow plans remain
LLM-authored through
start_workflow. - Per-task retry/timeout/concurrency settings in v1, beyond reserving
@workflow_tool(...)as the future metadata surface. - Sub-orchestrations, nested/stateful Sub Agent tasks, MCP Tasks integration, 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.
4. Proposed design¶
| Pipeline stage | Module(s) | Change |
|---|---|---|
| discover | discovery/tools.py, _function_tool.py |
Load tools/*.py once, preserving normal FunctionTool discovery while also discovering explicit workflow tool declarations. Add a public workflow_tool decorator that records workflow metadata without making the function a normal MAF tool by itself. |
| translate | config/schema.py, config/merge.py, registration/capabilities.py |
Parse and validate the public workflow config shape (enabled, optional exclude, and independent subagents) and compute concrete capabilities without hard-coding the v1 owner. Unknown workflow excludes warn, mirroring tools.exclude. |
| register | app.py, workflows/integration.py, workflows/registry.py, workflows/engine.py, registration/endpoints.py, registration/triggers.py |
The app composition root selects main.agent.md as the v1 owner. Integration consumes its filtered workflow tools and Sub Agent grants, builds one immutable owner policy, registers the Durable blueprint and catalog-backed Sub Agent Activity, and threads the policy plus Durable client through endpoints and declared triggers. |
| execute | workflows/tools.py, workflows/engine.py, runner.py, registration/_handlers.py, public/index.html |
MAF invokes workflow management tools (start_workflow, status/list/cancel/terminate). Runtime validation uses the same policy that generated prompt guidance. Durable Activities invoke registered workflow tools or fresh stateless leaf specialists. Trigger handlers pass the bound Durable client and trigger-specific workflow guidance to the runner. UI polls workflow status and injects terminal notifications. |
Authoring / API surface¶
Frontmatter¶
Workflow enablement remains explicit on the main agent:
---
name: Incident Triage Assistant
description: Investigates incidents by gathering evidence in parallel.
builtin_endpoints: true
workflows:
enabled: true
exclude:
- expensive_diagnostic_tool
---
workflows.enabled:bool;trueenables Dynamic Workflows formain.agent.md.workflows.exclude: optionallist[str]; filters discovered workflow tool names out of the effective workflow tool set.- Durable backend and task hub configuration stay in
host.jsonand app settings, not frontmatter. - If
workflows.enabled: trueis set on a non-main agent in v1, the runtime logs a startup warning and ignores the workflows block for that agent. This matches the current v1 constraint without failing unrelated agents.
Markdown-declared trigger starters¶
When a supported Markdown-declared trigger belongs to a workflow-enabled
main.agent.md, registration adds a Durable client input to that generated
Function. The handler passes the bound client, workflow enablement, the agent
identity slug, and trigger-specific system guidance to the existing runner.
Workflow-disabled and non-main handlers retain their original signatures.
start_workflow schedules the orchestration and returns a workflow_id to the
agent. The initial trigger Function ends after that agent turn instead of
polling for terminal workflow status. An HTTP caller receives the immediate
agent response; non-HTTP triggers have no response channel, so applications can
provide a workflow tool that delivers the eventual result to an appropriate
destination. This evolution adds no new frontmatter fields.
Tool decorators¶
Normal tool behavior stays unchanged:
The public plain function above remains a normal MAF tool only. It does not become a workflow Activity target.
Workflow-only tools opt in with @workflow_tool. The decorator attaches
workflow metadata and returns the original callable/object so it does not make a
function a normal MAF tool by itself:
from azure_functions_agents import workflow_tool
@workflow_tool(description="Fetch recent log lines for a service.")
def fetch_logs(args: dict[str, object]) -> dict[str, object]:
service = str(args["service"])
return {"service": service, "errors": 12}
Both direct MAF tools and workflow tools can be expressed by applying both
decorators when the callable contract is intentionally shared. Decorator order
should not affect discovery: @workflow_tool attaches metadata to a plain
callable or to a FunctionTool, and discovery also checks the wrapped
FunctionTool.func for workflow metadata.
from azure_functions_agents import tool, workflow_tool
@tool
@workflow_tool(description="Get current service health.")
def get_service_health(args: dict[str, object]) -> dict[str, object]:
return {"service": args["service"], "status": "healthy"}
The reverse order is also valid:
@workflow_tool(description="Get current service health.")
@tool
def get_service_health(args: dict[str, object]) -> dict[str, object]:
return {"service": args["service"], "status": "healthy"}
The single-callable "both" pattern is only viable for synchronous callables that can satisfy both the MAF and workflow Activity contracts. Async normal tools must use the separate-adapter pattern below for workflow support.
When normal tools use a Pydantic model but workflow Activities use dict
arguments, authors should share internal business logic and expose separate
adapters:
from pydantic import BaseModel
from azure_functions_agents import tool, workflow_tool
class HealthParams(BaseModel):
service: str
def _get_health(service: str) -> dict[str, object]:
return {"service": service, "status": "healthy"}
@tool
def get_service_health(params: HealthParams) -> str:
return str(_get_health(params.service))
@workflow_tool(name="get_service_health")
def get_service_health_workflow(args: dict[str, object]) -> dict[str, object]:
return _get_health(str(args["service"]))
Helpers remain _-prefixed:
def _require_service(args: dict[str, object]) -> str:
service = args.get("service")
if not isinstance(service, str) or not service:
raise ValueError("service is required")
return service
Workflow tool execution contract¶
For v1, a workflow tool handler must:
- be synchronous;
- accept one
dict[str, Any]argument; - return a JSON-serializable value;
- avoid relying on chat-turn-local runtime state;
- be appropriate for Durable Activity execution, including background and parallel execution.
The runtime should warn and skip functions that are clearly incompatible, such
as async handlers, declaration-only tools, reserved names, duplicate names, or
handlers whose signature cannot accept the workflow dict argument.
Reserved workflow tool names are the workflow management tools injected by the
runtime: start_workflow, get_workflow_status, list_workflows,
cancel_workflow, and terminate_workflow.
Duplicate detection is scoped to the workflow registry only. It is valid for a normal MAF tool and a workflow tool to share the same name intentionally; that is the expected shape for tools that support both direct chat use and workflow DAG execution.
Compatibility¶
- Existing normal tools remain backward compatible:
- public plain functions continue to be auto-wrapped as normal
FunctionToolinstances; - existing
@toolusage remains a normal MAF tool. @workflow_toolalone must not accidentally enter the normal plain-function fallback path.- Sample
function_app.pystays minimal; samples use the sametools/plus@workflow_toolauthoring model expected of users. @workflow_toolaccepts only supported v1 metadata (name,description,public) until retry/timeout metadata is implemented. Unknown keyword arguments fail fast at startup so authors do not think unsupported policy knobs are active.
Workflow Sub Agents¶
[!IMPORTANT] This extension is approved for the Dynamic Workflows v1 surface. Its first implementation is limited to the workflow-enabled
main.agent.md; issue #109 will apply the same contract to non-main workflow owners. Thesamples/workflow-subagents-preview/directory becomes a runnable sample as part of this implementation.
The extension lets the workflow-enabled main agent authorize existing Markdown agents as DAG nodes:
---
name: Support Coordinator
workflows:
enabled: true
subagents:
- agent: pr_status_analyst
when: Review one pull request and summarize its current status
- agent: actionable_report_writer
when: Combine pull-request summaries into an actionable portfolio report
---
workflows.subagents and the top-level chat-time subagents: list are
independent capability grants. Both are deny-by-default when omitted.
workflows.subagents may reference a specialist used only by Workflows.
Unknown, duplicate, and self references fail during app composition. As with a
top-level subagents: reference, an authorized Workflow-only specialist does
not need its own trigger or built-in endpoint. when is the routing hint shown
to the coordinator's plan-authoring model; when omitted, the specialist's
description is used. The subagents items are translated into typed
configuration during app composition rather than re-parsed by registration or
execution code.
The static grant and every runtime plan are enforced independently. Before a
plan starts, each sub_agent.agent must be present in the owning agent's
workflows.subagents grant. An unauthorized or unknown slug rejects the plan;
the Activity also fails closed if its catalog lookup cannot resolve the
already-authorized slug. The immutable owner-specific policy used for prompt
guidance is the same policy used for plan validation. v1 constructs that policy
only for main.agent.md; issue #109 can construct the same value per owner
without changing the node or Activity contract.
The Workflow plan uses a sub_agent task:
{
"id": "analyze_pr_42",
"type": "sub_agent",
"agent": "pr_status_analyst",
"task": "Review pull request https://github.com/owner/repo/pull/42 and summarize its current status."
}
The reduce node uses the same task type and depends on every map result:
{
"id": "write_report",
"type": "sub_agent",
"agent": "actionable_report_writer",
"task": "Create an actionable report from PR 42: ${analyze_pr_42.result.text}; PR 43: ${analyze_pr_43.result.text}.",
"depends_on": ["analyze_pr_42", "analyze_pr_43"]
}
task must be a self-contained string and may template upstream results. A
successful v1 node returns
{"agent": "pr_status_analyst", "text": "..."};
downstream tasks can reference ${analyze_pr_42.result.text}. Independent Sub
Agent tasks can fan out without dependencies, and another authorized Sub Agent
can depend on all of them to reduce their summaries. Status and lineage remain
owned by the parent Workflow and identify the execution by parent Workflow id,
node id, and specialist slug. Leaf-only means that the specialist cannot start
another Workflow or delegate again. A Sub Agent Activity is not an independently
queryable workflow instance: built-in status surfaces report it only as a parent
node, including the currently scheduled node ids while a wave is running.
The specialist runs as itself with a fresh context and its own instructions,
model, timeout, normal tools, MCP servers, skills, and web_request setting. It
does not inherit the parent's tools or conversation history. In v1 it also
receives no request-scoped sandbox, Workflow management tools, or delegate_*
tools.
The specialist's configured timeout is enforced inside the async Agent Activity
around Agent.run(task). The Functions host's activity/function timeout remains
an outer limit, so the observable upper bound is the shorter of the specialist
timeout and the host limit. A timeout raises from the Activity and fails the
parent Workflow; it is never returned as a success-shaped result.
| Concern | Proposed v1 | Deferred to v2 |
|---|---|---|
| Execution | One stateless Agent Activity per leaf node; no child orchestration | Stateful or bounded multi-level execution |
| Result | Fixed {agent, text} envelope |
response_schema-validated output |
| Failure | Activity failure or timeout fails the parent Workflow | Retry and continue-on-error policy |
| Retry | No automatic retry; use the specialist's timeout | Idempotent retry with attempts/backoff |
| Cancellation | Parent stops scheduling; an already-dispatched model call is best-effort | Stronger activity interruption where supported |
| Context | Self-contained task only |
Explicit context-sharing policy, if justified |
The v1 runtime does not configure automatic Durable retries. The task and result authoring contract should remain unchanged if a runtime-managed Durable retry policy is added later. Before enabling it, the implementation must define idempotency, retryable failure kinds, maximum attempts/backoff, and how repeated model or tool side effects are surfaced.
Even without configured retry options, Durable Activity delivery is at-least-once. A worker failure can therefore repeat a model call or specialist tool side effect. v1 does not claim exactly-once Agent execution: specialist tools used from a Workflow should tolerate re-execution, and terminal publishers should use stable destination identities or equivalent idempotent writes. The PR-status sample overwrites the request's specified Blob path so repeated publication converges on the same report instead of creating duplicate outputs.
Reviewer note: positive capability allowlists¶
Today specialist tools, skills, and mcp capabilities inherit the
project-wide inventory and can only be narrowed with exclude (or disabled
entirely). The proposal preserves that existing behavior, but durable background
execution makes the lack of a positive allowlist a least-privilege concern:
adding a new project capability can make it available to existing specialists
without editing their definitions.
A future capability proposal could add an explicit form such as:
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.
5. Decisions log¶
| # | Decision | Options considered | Choice | Decided by | Date |
|---|---|---|---|---|---|
| 1 | Workflow execution backend | Direct chat tool loop / in-process scheduler / Durable Functions | Durable Functions orchestrator + Activity engine | Human + Agent | 2026-07-01 |
| 2 | Workflow enablement surface | Always on / agent frontmatter flag / global config only | workflows.enabled: true on main.agent.md |
Human + Agent | 2026-07-01 |
| 3 | Workflow tool placement | Dedicated workflow_tools/ / existing tools/ |
Existing tools/ directory |
Human | 2026-07-06 |
| 4 | Workflow tool opt-in | Auto-promote compatible plain functions / @tool(workflow=True) / explicit @workflow_tool |
Explicit @workflow_tool decorator |
Human | 2026-07-06 |
| 5 | Normal plain function behavior | Stop auto-wrapping / keep existing normal tool discovery | Keep existing plain-function discovery for normal MAF tools | Human | 2026-07-06 |
| 6 | Workflow filter style | exclude list / no filtering |
Use workflows.exclude to match existing capability filtering |
Human | 2026-07-06 |
| 7 | Workflow-only functions | Require duplicate wrappers / @workflow_tool only / config-only exclusion |
@workflow_tool only means workflow-only and must not become normal MAF tool |
Human + Agent | 2026-07-06 |
| 8 | Future workflow metadata | Separate config maps / decorator kwargs / postpone with no surface | Reserve @workflow_tool(...) for future retry/timeout/etc. metadata |
Human + Agent | 2026-07-06 |
| 9 | Incompatible workflow candidates | Fail all startup / silently skip / warn and skip where safe | Warn and skip incompatible workflow tool declarations where safe | Human | 2026-07-06 |
| 10 | Workflow filtering stage | Apply workflows.exclude in integration/register / compute concrete workflow tools in capabilities |
Compute the concrete workflow tool set before registration so registration consumes objects, not exclude policy | Agent | 2026-07-06 |
| 11 | Dual decorator order | Require one order / support both orders | Support both orders by attaching workflow metadata to both callables and FunctionTool objects |
Agent | 2026-07-06 |
| 12 | Record trigger support | Create a second Dynamic Workflows FRD / evolve this FRD | Update FRD 0004 because Markdown-declared trigger support extends the existing feature without redesigning it | Human | 2026-07-23 |
| 13 | Declared-trigger scope | Add named trigger types individually / use generic trigger registration | Add the Durable client binding generically to every supported Markdown-declared trigger for the workflow-enabled main agent | Human + Agent | 2026-07-17 |
| 14 | Trigger lifetime | Wait for terminal status / start asynchronously | End the initial trigger Function after the agent starts the workflow; Durable execution continues independently | Human + Agent | 2026-07-17 |
| 15 | Workflow Sub Agent authorization | Reuse the top-level list / add a mode flag / use a Workflow-owned grant | Add independent, deny-by-default workflows.subagents |
Human | 2026-07-23 |
| 16 | First execution boundary | Recursive delegation / bounded nesting / leaf-only | v1 is leaf-only; bounded multi-level execution is v2 | Human | 2026-07-23 |
| 17 | Specialist context | Copy parent state / share history / self-contained task | Run with the specialist's own static capabilities and a self-contained task only | Human | 2026-07-23 |
| 18 | Failure and retry | Recoverable result / automatic retry / fail parent without retry | Sub Agent failure fails the parent Workflow; v1 has no automatic retry | Human | 2026-07-23 |
| 19 | Successful result | Plain text / schema-dependent result / fixed envelope | Return {agent, text}; defer response_schema to v2 |
Human | 2026-07-24 |
| 20 | Sub Agent runtime boundary | Direct Activity / one child orchestrator per node / shared child orchestrator | Invoke each stateless Sub Agent directly as an Activity; retain status and lineage on the parent node | Human + Chris Gillum | 2026-07-24 |
| 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 |
6. Test plan¶
- [ ] Unit:
tests/test_discovery_tools.py - plain public functions still become normal tools;
@toolvalues still become normal tools;@workflow_tool-only functions do not become normal tools;- modules can expose multiple workflow tools;
_-prefixed helpers are ignored.- [ ] Unit: dual-decorator behavior
@toolover@workflow_toolis both a normal tool and a workflow tool;@workflow_toolover@toolis both a normal tool and a workflow tool;- duplicate names are rejected only within the workflow registry, not across normal and workflow tool inventories.
- [ ] Unit: workflow discovery/registry tests
- compatible
@workflow_toolhandlers register automatically; - async/incompatible handlers are skipped with warning logs;
- duplicate/reserved names are handled with clear warnings/errors;
@workflow_toolusing a reserved runtime management name such asstart_workflowis rejected;- effective workflow tool set respects
workflows.exclude. - [ ] Unit: non-main workflow config
- non-main
workflows.enabled: truelogs a warning and does not inject workflow tools. - [ ] Unit:
tests/test_workflow_integration_validation.py workflows.excludeshape validation;- unknown workflow keys fail with actionable messages.
- [ ] Unit:
tests/test_app_routes.py - workflow-enabled app startup discovers sample workflow tools from
tools/; - workflow addendum lists discovered non-excluded workflow tools.
- [ ] Fixture scenario:
tests/fixtures/config_scenarios/<next>_dynamic_workflow_tools/ tools/contains normal-only, workflow-only, both, and helper functions.- [ ] Sample tests: update
tests/test_incident_tools.pyfor the decorator-based sample layout. - [ ] E2E: run the
workflow-incident-triagesample locally with Azurite/Durable storage and confirm a workflow can start, execute sample tools, and complete. - [x] Evolution #112: workflow-enabled HTTP and non-HTTP handlers receive the Durable client and trigger addendum while disabled/non-main handlers keep their existing signatures.
- [x] Evolution #112: timer and queue samples index their trigger, Durable client, orchestrator, and Activity bindings and complete model-backed local runs.
- [x] Evolution #117: Workflow Sub Agents
- validate the independent, deny-by-default
workflows.subagentsgrant; - reject a runtime
sub_agentnode whose slug is not authorized by that grant, and fail closed on an impossible catalog miss; - validate
sub_agentnode shape, authorization, DAG templates, and results; - execute map nodes as parallel Agent Activities and reduce their
{agent, text}results; - verify specialist capability isolation, timeout, failure, and cancellation;
- make
samples/workflow-subagents-preview/runnable and execute it end to end through Queue, Durable execution, fake PR tools, HTML reduction, and Blob publication, including convergence on the same Blob after repeated publication.
7. Docs impact¶
- [ ]
docs/architecture.md— add workflows to the data flow, module map, and pipeline-stage descriptions. - [ ]
docs/front-matter-spec.md— documentworkflows.enabledandworkflows.exclude. - [ ]
docs/workflows.md— document@workflow_toolauthoring and auto-registration fromtools/. - [ ]
README.md— ensure experimental workflows mention points to the sample and docs. - [ ]
samples/workflow-incident-triage/README.md— update authoring and local run instructions for auto-registration. - [ ]
docs/frds/README.md— add FRD 0004 to the index. - [x] Evolution #112: update
docs/triggers.md,docs/workflows.md, anddocs/architecture.mdfor trigger-started workflows. - [x] Evolution #117: document
workflows.subagentsand thesub_agenttask indocs/front-matter-spec.md,docs/workflows.md, anddocs/architecture.md; keep the sample customer-facing and free of FRD/Durable implementation details.
8. Status & sign-off¶
- Architecture review (phase 2): Completed by
frd-reviewer(rubber-duck), 2026-07-06. Initial findings around pipeline boundaries, dual-decorator semantics, duplicate-name scope, non-main behavior, reserved names, and unknown decorator kwargs were addressed. Re-review found no remaining blocking issues and deemed the FRD ready for human sign-off. - Human sign-off: TsuyoshiUshio, 2026-07-06 →
status: Finalized. - Evolution review: Markdown-declared trigger support reviewed by TsuyoshiUshio and Chris Gillum in PR #112, 2026-07-23.
- Workflow Sub Agent architecture review: External contract reviewed in PR #117. Chris Gillum recommended direct Activity execution because current Serverless Agent invocations are stateless; the plan was revised to remove child orchestration and child ids. A dedicated pre-implementation review on 2026-07-24 additionally required an executable E2E sample, runtime authorization enforcement, explicit at-least-once semantics, and an Activity-owned timeout boundary; those findings are incorporated above.
- Workflow Sub Agent human sign-off: TsuyoshiUshio, 2026-07-24. Approved
Activity-only execution,
{agent, text}results, main-only v1 ownership, and implementation using TDD followed by sample E2E validation.