FRD 0007 — Multi-agent delegation (agent-as-tool)¶
1. Summary¶
TL;DR: Today, an app can contain several agents, but users or app authors must route work between them. This feature lets one coordinator ask declared specialists for help, much like a lead asking subject-matter experts and then giving one final answer. It is the lighter coordination option: the coordinator stays in control, it uses an API already in the pinned dependency, and the runtime keeps one normal
agent.run()path. True handoff, where a specialist takes control, is a committed fast-follow rather than part of v1.
v1 adds delegation to the markdown-first runtime. A coordinator
*.agent.md declares existing agents as specialists in a new subagents:
front-matter field. At runtime, the coordinator calls those specialists as
hand-written delegate_<slug> function tools, each of whose handler runs its
specialist through Microsoft Agent Framework (MAF) Agent.run() — a plain,
non-streaming call, since a delegate only ever needs the specialist's final
answer back, never its streamed tokens. The user still interacts with "one
assistant that consults specialists, then answers."
Delegation needs no new dependency. agent_framework.Agent, its
non-streaming run(), and this repo's own existing @tool decorator all
already exist, unchanged, in the pinned agent-framework-core==1.3.*. It
fits the existing tool assembly, registration, and single-agent.run() path.
True handoff is different: control moves between agents through MAF's
HandoffBuilder, which returns a Workflow. Handoff is not part of v1. It is a
committed, chat-scoped fast-follow with its own FRD. This design prepares for
that work through stable participant identities, an immutable in-memory
AgentCatalog, and a reusable helper that builds a MAF Agent from a
ResolvedAgent for an explicit execution role. These choices avoid later
changes to the authoring surface or the per-session_id storage model.
Tracking issue: Azure/azure-functions-bucees-planning#1185 — "[Serverless
Agents] Multi-agent: Handoff via HandoffBuilder + workflows
(agents.config.yaml)".
Glossary
- MAF: Microsoft Agent Framework, the agent library used by this runtime.
- Coordinator: the agent that receives the request, consults specialists, and gives the final answer.
- Specialist / sub-agent: an existing
*.agent.mdagent that the coordinator can call for focused work. - Delegation: the coordinator calls a specialist as a tool and keeps control.
- Handoff: control transfers to another agent; this is the planned fast-follow.
as_tool(): the MAF API that exposes an agent as a callable tool.direct/delegatedrole: the same agent entered through its endpoint or called as a coordinator tool.- Dynamic Workflows: the repo's existing single-agent Durable Functions feature; it is not MAF multi-agent orchestration.
2. Motivation / problem¶
Today, each *.agent.md file registers independently with its own trigger(s)
and/or built-in endpoint(s). The runtime does not compose agents. The
samples/multi-agent-folder sample makes the user choose an endpoint, for
example by suggesting the research endpoint. An app author who wants automatic
routing must build it themselves.
Customers building AgentApps with several specialists want one agent to route work automatically instead of exposing N endpoints and asking the user to choose. The confirmed v1 interaction is "one assistant throughout — it consults specialists, then answers" (Decisions log #2). The coordinator stays in control. Delegation is therefore the smallest pattern that meets the need. Both delegation and handoff are new to this runtime; nothing coordinates agents today.
Two existing concepts use the word "workflow" but mean different things:
- This repo's Dynamic Workflows (FRD 0004,
docs/workflows.md) are LLM-authored DAGs of tool calls. Durable Functions executes them. An agent opts in throughworkflows.enabledfront matter, and the feature is explicitly single-agent. - MAF's orchestration
Workflowlayer supports multi-agent control transfer and shared-context collaboration.HandoffBuilder,GroupChatBuilder, andMagenticBuilderall return aWorkflow. True handoff usesHandoffBuilderand full-mesh shared broadcast context, so every participant sees the running conversation.
Delegation through as_tool() is the only MAF multi-agent pattern that runs
inside plain agent.run() with no Workflow. Because the two workflow concepts
are unrelated, this FRD must not reuse workflows: for its new field (Decisions
log #5). The future handoff FRD will add the MAF orchestration Workflow path.
The three routing patterns are:
- Manual routing (today): separate endpoints; the user chooses.
- Delegation / agent-as-tool (
as_tool()): the coordinator stays in control, calls specialists as tools, and combines their work into the final answer. It uses plainagent.run()and no new dependency. This is v1. - True handoff (
HandoffBuilder): control transfers to the specialist, which owns later turns. Participants share broadcast context. This needs aWorkflowexecution path and theagent-framework-orchestrationsdependency. This is the fast-follow.
3. Goals / Non-goals¶
Goals
- Add an optional, object-only
subagents:front-matter field. A coordinator can use it to declare one or more existing agents as specialists. - Let the coordinator call each specialist as a
delegate_<slug>function tool inside the existingagent.run()path, with no MAFWorkflow. - Run a specialist as itself in the delegated role: with its own instructions, model, static user tools, MCP servers, and skills. Request-scoped capabilities are naturally absent: a delegated call has no per-request sandbox of its own, and Dynamic-Workflow tools are already main-only. Delegation does not strip them.
- Add no dependency. Delegation needs only
agent_framework.Agent,Agent.run(), and this repo's own existing@tooldecorator — all present and unchanged in the pinnedagent-framework-core==1.3.*. - Make same-stem agent slugs fail fast app-wide (replacing today's silent auto-suffixing), so every agent has a unique slug — one collision contract, consistent with how the runtime already rejects duplicate skill and workflow-tool names. (Breaking change — see §4 Compatibility and Decisions log #17.)
- Use the existing
tool_startandtool_endSSE events for delegated calls. The tool name isdelegate_<slug>. Do not add an event type or require a client/UI change. - Emit correlated, nested telemetry for delegated calls automatically (the
runtime already enables MAF instrumentation), and add delegation-specific
af.*attributes, metrics, and error accounting for parity with the existing system tools. (See §4.12 and Decisions log #19.) - Establish reusable groundwork for the handoff fast-follow: stable participant identities, an immutable catalog, and role-based agent construction. Avoid coordinator-only APIs that handoff would have to replace.
Non-goals
- True handoff or control transfer through
HandoffBuilder, or any MAF orchestrationWorkflowexecution path. - Workflow checkpointing plus
request_infopause/resume across Function invocations. - Any human-in-the-loop (HITL) flow, including tool approvals or user-input
pause/resume. The runtime has none today. This was verified by finding no
user_input_requests,approval_mode,request_info, orUserInputRequiredhandling insrc/;run_agentawaitsagent.run(...)once and has no approval loop. v1 specialists must use autonomous tools. HITL is deferred to the handoff fast-follow. - Shared session/context, or shared-state tools, between coordinator and
specialist (what MAF's
as_tool()callspropagate_session=True). In v1 the specialist receives only the tool-call argument, not the coordinator's chat history. - Nested delegation. v1 is single-level: a delegated specialist cannot delegate onward. This is a deliberate v1 boundary, not a permanent limit. A later bounded-nesting change can stay localized to agent construction, so v1 adds no depth-counter machinery.
- String shorthand such as
subagents: [billing, tech]. v1 accepts only the object form. Shorthand can be added later as non-breaking sugar.
4. Proposed design¶
4.1 Runtime foundation¶
TL;DR: Delegation uses APIs already available in the pinned MAF package — no new dependency, in either the first or the final v1 shape.
A delegate needs only two MAF primitives, both already present, unchanged,
in pinned agent-framework-core==1.3.* (verified at tag python-1.3.0):
Agent(...), to build a specialist agent in the delegated role (§4.4), and
Agent.run(task), the plain non-streaming coroutine — it returns an
AgentResponse, and .text is the specialist's final answer.
BaseAgent.as_tool() also exists, unchanged, on root BaseAgent:
BaseAgent.as_tool(
*,
name=None,
description=None,
arg_name="task",
arg_description=None,
approval_mode="never_require",
stream_callback=None,
propagate_session=False,
) -> FunctionTool
The first v1 implementation called it directly. The final v1 shape (§5
Decision #20) does not: as_tool()'s own wrapper always runs the specialist
through Agent.run(stream=True, ...) internally and awaits
stream.get_final_response(), even though a delegate only ever needs that
final text back and never the specialist's streamed tokens (§4.12 — "SSE is
a black box at the boundary" was already a non-goal). Building the
delegate_<slug> tool by hand instead (§4.2, §4.8) and calling plain,
non-streaming Agent.run(task) directly avoids that unneeded streaming
machinery, and everything it otherwise forced on the implementation (see
Decision #20). Either way, no new dependency is needed.
4.2 Pipeline and two-pass composition¶
TL;DR: Resolve and validate all agents before registering any of them.
subagents: uses the existing four-stage pipeline from
docs/architecture.md §2: discover → translate → register → execute. The
runtime now needs a global view because one agent can refer to another.
Therefore, the app factory replaces its interleaved validate/register loop with
an explicit multi-pass order.
| Pipeline stage | Module(s) | Change |
|---|---|---|
| discover | — | No change. Coordinators and specialists are ordinary *.agent.md files found by the existing top-level and agents/ folder scan (FRD 0001). Discovery stays read-only. |
| translate | config/schema.py, config/merge.py, config/validation.py |
Add SubagentRef ({agent, when?}) and AgentSpec.subagents: list[SubagentRef] \| None. Carry subagents and the resolved identity (agent_id, the file-stem slug) onto ResolvedAgent. merge.py normalizes and validates each reference, and derives canonical identity from the file-stem slug. validation.py uses the complete global index. It rejects duplicate agent slugs (app-wide, fail-fast), unknown/duplicate/self references, and tool-name collisions. It relaxes the trigger-or-builtin_endpoints rule for a referenced internal specialist. |
| register | app.py, registration/_handlers.py, registration/endpoints.py, registration/_naming.py |
app.py becomes the composition root. It builds the identity index (slug → agent) and fails fast if two agents share a slug (_naming.py now rejects duplicates instead of auto-suffixing). After capabilities and capability-aware validation, it builds the read-only AgentCatalog (agent_id -> (ResolvedAgent, AgentCapabilities)) and the global referenced-subagent set. All of this happens before any FunctionApp mutation. _handlers.py and endpoints.py pass read-only catalog data, not live MAF agents, into run_agent and run_agent_stream for coordinators with subagents. Registration parses no YAML/front matter and resolves no references; it consumes validated data. |
| execute | runner.py, _observability.py |
Add build_subagent_tools(subagents, catalog). For each reference, build one hand-written delegate_<slug> FunctionTool (_build_delegate_tool — the same @tool(schema=...) pattern this repo already uses for the web_request/execute_python system tools, not MAF's BaseAgent.as_tool()) whose schema is {task: str} with a fixed arg_description, name delegate_<slug>, and description <when \| specialist description>. Its handler builds the specialist's MAF Agent in the delegated role — through the shared role-based client/tool assembly path — FRESH on every call (not once when the tool is built) and awaits its plain, non-streaming agent.run(task) directly. ClientManager creates the chat client for the specialist's own resolved model while reusing provider and credential state process-wide. Append the resulting tools to the coordinator's resolved_tools. Build the tool wrapper eagerly, but build/run the specialist Agent only if the coordinator selects it. The handler also emits af.delegate.* span attributes and delegate call/error metrics, and marks a recoverable delegated failure for correct tool-error accounting (see §4.12). Because each call gets its own, unshared specialist Agent, there is no per-specialist lock (see §5 Decision #20). |
The ordered composition pipeline is:
- Resolve every discovered agent into a typed
ResolvedAgent. - Build the identity index (slug → agent) and the global set of referenced ids. Two agents that resolve to the same slug are a fail-fast error here.
- Validate schema and references against that index: unknown, duplicate, and self-references; tool-name collisions; and the internal-specialist rule.
- Build
AgentCapabilitiesfor each agent. - Run capability-aware validation, including tool-name checks against each coordinator's known tool set.
- Build the read-only runtime
AgentCatalog:agent_id → (ResolvedAgent, AgentCapabilities). - Mutate the single
FunctionAppby adding triggers and built-in endpoints.
An early agent can depend on a later one. For example, a triggerless internal specialist is valid only if another agent references it. The ordered pipeline keeps validity independent of discovery order and prevents partial Azure registration before global validation finishes. Cross-agent awareness in steps 2–5 is the only genuinely new plumbing requirement; current validation rules are local to one agent.
Tool-name collision validation runs once, at composition (step 5); it is not re-checked at request time. The one category composition can't see — an MCP server's individually expanded remote functions, unknown until the server connects — is backstopped by MAF's own tool assembly, which independently rejects a duplicate name there.
4.3 Participant identity (Decisions log #9)¶
TL;DR: A specialist is referenced by its file-stem slug, and every agent's slug is unique — the app fails fast at startup if two collide.
Each agent's identity for subagents references is its file-stem slug:
billing.agent.md becomes billing. Translation derives it from the file stem
the same way _naming.py derives slugs. Because duplicate slugs now fail fast
app-wide (see below and Decisions log #17), a reference resolves to exactly one
agent — there is no suffixing to disambiguate against. (There is no separate
id field in v1 — see Decisions log #16.)
Slugs are globally unique. Two agents that resolve to the same slug — e.g.
one in the app root and one in agents/, or two names that sanitize to the same
slug — are a fail-fast startup error ("rename one of these files"). This
replaces today's silent auto-suffixing and makes agent-slug handling consistent
with how the runtime already rejects duplicate skill and workflow-tool names
(Decisions log #17; this is a breaking change — see Compatibility).
Because slugs are unique, a subagents reference resolves to exactly one agent
or fails as an "unknown reference" — no ambiguity handling is needed. Display
name is never an identity.
4.4 Direct and delegated execution roles (Decisions log #13)¶
TL;DR: A delegated specialist is the same agent with its own configuration, entered through a different path.
A referenced specialist remains an ordinary *.agent.md agent. It runs with
its own instructions, model, timeout, static user tools, MCP servers, and
skills. Delegation does not copy coordinator tools onto it or remove its static
tools. The delegate tool's handler runs the specialist Agent unchanged — a
plain agent.run(task) call, as verified against agent_framework source.
The same full-capability pattern appears in
the surveyed OpenAI Agents SDK, LangGraph, AutoGen, CrewAI, and Google ADK.
| Capability | direct role |
delegated role |
|---|---|---|
| Entry point | Own trigger or endpoint | Coordinator's delegate_<slug> tool call |
| Instructions, model, timeout, static tools, MCP, skills | Specialist's own | Specialist's own |
| Per-request sandbox | Attached by the top-level handler | No new sandbox session |
| Dynamic-Workflow tools | Main-agent-only rule applies | Absent because they are already main-only |
| Conversation context | Direct request/session behavior | Only the task string; no session= argument passed at all |
Own subagents |
Full direct-role behavior | Not wired in v1 |
The sandbox/code-interpreter is request-scoped. A trigger or endpoint handler
attaches it with build_sandbox_tools_for_session, using an ACA session id
created for that top-level invocation. A delegated call is not another
top-level request, so it opens no specialist sandbox session. A separate
delegated sandbox session could be a future enhancement.
Dynamic-Workflow tools are already restricted to main.agent.md by FRD 0004,
regardless of delegation. No delegation-specific removal is needed.
Conversation history is also isolated: the delegate tool's handler calls
agent.run(task) with no session= argument at all, so the specialist
receives only the task string and never the coordinator's history or any
other shared state. This mirrors the standard default in OpenAI, LangGraph,
and AutoGen (and, when the delegate tool was first implemented via MAF's
as_tool(), its own propagate_session=False default, which had the same
effect). The coordinator includes needed context in the task.
One reusable helper builds a MAF Agent from a ResolvedAgent, parameterized
by the direct or delegated execution role. It never mutates
ResolvedAgent. Therefore, the same specialist still gets its complete direct
capabilities through its own endpoint.
4.5 Delegation depth¶
TL;DR: v1 permits one coordinator-to-specialist level and removes recursion structurally.
When the runtime builds a specialist for the delegated role, it does not wire that
specialist's own subagents. The resulting agent has no delegate_* tools.
There is no runtime refusal or cycle check because the capability is absent.
Mutual references are therefore safe: A may reference B and B may reference A,
but neither expands the other's references during delegation.
Single-level delegation matches the confirmed interaction and common
coordinator-to-specialist designs. Google ADK's task/single_turn modes use
single-level "leaf agents"; CrewAI's default path is effectively single-level.
MAF itself has no recursion, depth, or cycle guard. Its only runaway bound is a
per-run limit of about 40 iterations, which does not compose across nesting
levels. A future nested design must therefore add its own bound.
Deeper composition remains a natural extension. Mature frameworks use small
budgets rather than free recursion: Anthropic caps sub-agent nesting at 5,
LangGraph uses recursion_limit=25, and ADK uses global max_llm_calls.
Because v1's block is one construction decision, future bounded nesting can be
a localized builder change: wire subagents below a limit and pass depth+1.
v1 adds no depth counter, and AgentCatalog and run_agent need no change now
to preserve that option.
The single-level guarantee covers native subagents expansion. An arbitrary
MCP or user tool could still call another endpoint over HTTP. That behavior is
outside delegation's control.
4.6 Failure, timeout, and cancellation (Decisions log #12)¶
TL;DR: Child failures return to the coordinator; parent cancellation stops the request.
The runtime treats two failure classes differently:
- Specialist failure or specialist-local timeout is recoverable. This
covers a failure building the specialist
Agent(e.g. a misconfigured specialist model) just as much as a failure or timeout from actually running it — both are inside the same recoverable-failure boundary, so neither can propagate unhandled out of the tool call. Return it as a recoverable tool error intool_end. The coordinator can retry, choose another route, or apologize. - Parent or request cancellation propagates. A coordinator-turn timeout, client disconnect, or host shutdown cancels the coordinator and all in-flight delegated calls. Do not turn it into a recoverable tool result.
The effective specialist timeout is
min(specialist timeout, coordinator's remaining budget). The recoverable path
returns a stable, sanitized string to the model/client. Internal telemetry keeps
full diagnostic detail: redacted exception type, correlation id, and outcome.
Existing SSE order remains tool_start then tool_end, with recoverable errors
inside the tool_end result.
A thin, hand-written handler guarantees this split (§5 Decision #20) — it
does not rely on or wrap pinned as_tool() behavior; it builds the
specialist Agent and awaits its plain, non-streaming Agent.run(task)
directly inside asyncio.wait_for, both inside the same try, catching
asyncio.CancelledError separately from TimeoutError/Exception. Because
each call builds its own specialist Agent instance (§4.7), no
per-specialist serialization is needed either — see §4.11.
4.7 Build live agents per delegate tool-call¶
TL;DR: Cache immutable definitions and shared clients, but build each live MAF agent fresh for every request — and, for a specialist, fresh for every individual delegate_<slug> tool CALL, not merely once per request.
Do not build specialists once at startup:
- Some state exists only for a request. A top-level agent's sandbox uses a one-time secure session, and conversation history belongs to one user's chat. A startup agent would need per-call patching, which defeats caching.
- A MAF
Agentis mutable, not a frozen share-safe object. On first use,agent.run()attaches an in-memory history provider to the instance. This was confirmed in pinnedagent-framework-core==1.3.*and remains true upstream. One warm Functions worker can serve concurrent requests, so sharing one live agent would create the race class already guarded by the per-session lock. - Fresh construction is cheap.
ClientManageralready reuses the expensive model client process-wide. A lightweightAgentwrapper adds little work, prevents cross-request state sharing, and uses less cold-start time and memory than pre-building every agent.
The runtime caches AgentCatalog (ResolvedAgent + AgentCapabilities) and
the shared client. It builds the coordinator Agent object once per request,
matching today's execution model. A delegated specialist's Agent object,
though, is built fresh on every individual delegate_<slug> tool CALL — by
the tool's own handler, not by build_subagent_tools/_build_delegate_tool
at tool-assembly time (§5 Decision #20). Only the cheap FunctionTool
wrapper (schema + closure) is built once, when the coordinator's tools are
assembled; the specialist Agent itself is not, so two calls to the same
declared specialist — even concurrent ones — never share a live agent
instance and need no lock (§4.11).
4.8 Authoring and routing¶
TL;DR: Coordinators declare object-form specialist references with an optional routing hint.
subagents is an optional per-agent front-matter field:
# agents/coordinator.agent.md
---
name: Support Coordinator # human-readable display name (not identity)
description: Routes customer questions to the right specialist
builtin_endpoints: true
subagents:
- agent: billing # references billing.agent.md by its slug
when: Invoices, charges, refunds, or subscription questions # -> becomes delegate_billing's tool description
- agent: tech # when omitted → uses tech's own `description`
---
You are a support coordinator. Use the billing and tech specialists when
relevant, then give the customer a single consolidated answer.
SubagentRef has two fields:
agentis required and names the specialist by slug.whenis an optional routing hint. It defaults to the specialist's own requireddescription.
The coordinator model reads when to decide whether to call a specialist.
Routing is model-selected, not deterministic. A weak hint can skip
delegation or route incorrectly; this is an accepted limitation.
The delegated tool is named delegate_<slug>. That name must be a valid
identifier and unique in the coordinator's final tool set. A collision with a
user, MCP, sandbox, workflow, or another specialist tool fails fast and is never
silently suffixed, because the name is a prompt-visible API. Validation runs
once, during capability-aware validation at composition; MAF's own tool
assembly backstops the one category composition can't see (an MCP server's
individually expanded remote functions).
Construct the specialist's FunctionTool wrapper while assembling one
invocation's coordinator tools; the specialist Agent itself is not built
there — its handler builds one fresh on every call (§4.7, §5 Decision #20).
ClientManager builds each specialist client for that specialist's own
resolved model and reuses provider/credential state process-wide. Do not
cache mutable MAF agents between Functions requests, or between calls within
one request. Declaring a specialist creates one cheap wrapper and adds its
schema to the coordinator prompt; the specialist's model does not run until
selected. Prompt-visible tool-schema size is the real cost of declaring many
specialists.
These fields are front-matter-only. They have no agents.config.yaml
equivalent, so there is no global/front-matter merge or precedence rule as there
is for model, timeout, and tools. This follows
docs/front-matter-spec.md; §7 covers documentation and reference regeneration.
4.9 Task and context contract¶
TL;DR: The coordinator must send a self-contained task because no conversation history is shared implicitly.
The delegate_<slug> tool's schema gives the specialist one string argument
named task. Its description tells the coordinator to send a self-contained
request. Isolation does not mean that no data can move: the coordinator must
include all needed context in this string.
No session id or mutable state dictionary is shared either — the handler's
agent.run(task) call passes no session= argument at all. The specialist's
output returns only as the coordinator's tool_end result.
4.10 HITL and trust boundary¶
TL;DR: Specialists run autonomously inside the coordinator's app trust boundary.
The runtime has no approval or user-input flow today, so v1 uses
approval_mode="never_require". A specialist tool that requires approval, or a
specialist that raises UserInputRequiredException, cannot pause or surface a
question to the user. Such tools are unsupported in v1 specialists. Real HITL
(pause → ask → resume) belongs to the handoff fast-follow.
subagents is an explicit capability grant from the app author. A delegated
call runs in-process and does not pass through the specialist endpoint's
authorization. Anyone who can invoke the coordinator effectively gains access
to the declared specialist's tools, MCP servers, and skills; prompt injection
can widen that exposure. v1 treats one deployed app as one trust domain and
relies on the declaration. Specialist opt-in, such as a delegatable flag or
allow-list, is possible future hardening, not v1.
4.11 Breadth and concurrency (Decisions log #14)¶
TL;DR: Declare any number of specialists; every call — different specialists or repeated calls to the same one — runs independently and in parallel.
v1 sets no hard cap on declared specialists. MAF's tool-calling loop bounds the
number of delegations in one turn. Different specialists always run in
parallel. So do concurrent calls to the same specialist: each
delegate_<slug> call builds its own specialist Agent instance fresh
(§4.7, §5 Decision #20) rather than sharing or reusing one built at
tool-assembly time, so there is no shared, mutable Agent for concurrent
calls to race on in the first place — v1 needs no per-specialist lock and no
assumption about whether a delegated Agent is reentrant, because no two
calls ever share one.
Delegated calls are ephemeral and have no persistent session: the handler's
agent.run(task) call passes no session= argument at all, so a specialist
never sees the coordinator's conversation history or any other shared state.
They do not contend for the coordinator session lock, which serializes turns
rather than tool calls inside one turn. The only shared components are
process-wide ClientManager and per-app cached MCP tool objects. Both are
already shared across concurrent top-level requests, so delegation adds no
new cross-request sharing model. Tests cover parallel specialists and
repeated, concurrent calls to one specialist, each running independently on
its own instance.
4.12 Observability¶
TL;DR: Tracing is automatic — the runtime already enables MAF instrumentation, so a delegated call emits a nested span tree under one App Insights OperationId with no new tracing code. v1 adds delegation-specific af.* attributes, metrics, and correct error accounting; token totals still do not roll up across the boundary, and the SSE stream shows the delegate call as a black box.
Automatic today. The runtime bootstraps OpenTelemetry once
(_observability.py, from create_function_app()): it calls MAF's
enable_instrumentation() and, when APPLICATIONINSIGHTS_CONNECTION_STRING and
the [monitor] extra are present, wires the Azure Monitor exporter (a no-op
otherwise). Every run is already wrapped in a runtime agent.run {name} span
with af.* attributes. Because the hand-written delegate_<slug> tool's
handler calls the specialist's plain Agent.run(task) directly, and MAF
traces every Agent.run() and every FunctionTool.invoke(), a delegated
call produces this tree with no new tracing code:
agent.run {coordinator} runtime span (af.*)
└─ invoke_agent {coordinator} MAF
├─ chat {model} the routing decision
└─ execute_tool delegate_<slug> the delegation (an ordinary tool span)
└─ invoke_agent {specialist} auto-nested
└─ chat {model} the specialist's own model call
All spans share one trace, so Application Insights ties the chain under a single
OperationId. Nesting is by in-process OpenTelemetry context (contextvars):
the Functions Python worker attaches the invocation's traceparent before the
handler runs, and asyncio.gather copies the current context into each task, so
concurrent specialists nest correctly as long as gather runs while the
coordinator span is current (it does — delegation happens inside agent.run).
The only requirement is that a specialist is a real agent_framework.Agent; the
delegated-role builder path already constructs one (a RawAgent would carry no
telemetry layer). Correlating attributes come for free: gen_ai.agent.name (the
specialist slug), gen_ai.tool.name (delegate_<slug>), model, per-span token
usage, and duration. (Runtime logs go to the azure.functions.AgentRuntime
system logger, which the Functions worker does not surface in App Insights
traces; spans, not logs, are the debugging surface.)
Added in v1. For parity with the existing system tools (sandbox,
web_request), the delegate_<slug> tool's handler enriches the delegate call:
- af.delegate.* span attributes (at least the specialist slug and outcome) on
the execute_tool delegate_<slug> span, a dedicated FaultDomain value for a
failed delegated call, and delegate call/error metrics parallel to
record_sandbox_execution / record_web_request. The call metric is
recorded on every dispatched attempt, including one stopped by a parent/
request cancellation (outcome=cancelled) — cancellation is exempt only
from the error counter (Decision #12), never from the call counter, so a
cancelled delegate call is never invisible to call-volume metrics.
- Correct error accounting: today _looks_like_tool_error expects a JSON
{"error": …} / stderr envelope (the sandbox/web_request shape) and would
mis-count a specialist's sanitized free-text failure (§4.6, Decision #12). The
handler marks a recoverable delegated failure explicitly so it lands
in af.agent.tool_error_count rather than relying on that heuristic.
Accepted limitations.
- SSE is a black box at the boundary. The coordinator stream emits
tool_start/tool_end for the delegate_<slug> call (task in, final text
out), exactly like the sandbox/web_request tools. The specialist's internal
deltas and nested tool calls do not surface unless a MAF stream_callback is
wired into run_agent_stream — out of scope for v1.
- Token usage does not roll up across the boundary. MAF records usage
per-run on each invoke_agent/chat span; the execute_tool span carries no
usage, and a specialist's tokens are not merged into the coordinator's span. A
combined per-request total must be summed from the child spans in the backend
(by trace). Documented, not changed in v1.
Sampling guidance (docs). For delegation-heavy apps, prefer
OTEL_TRACES_SAMPLER=parentbased_traceidratio with an explicit
OTEL_TRACES_SAMPLER_ARG. Azure Monitor's default rate-limited sampler counts
spans, and one fan-out turn (coordinator + N specialists + MAF's own child spans)
can exhaust the budget and drop whole traces under load. Its sampler is
trace-id-deterministic, so a decision applies consistently across the whole
nested trace (no half-traces); logs on a dropped trace are dropped with it.
4.13 Compatibility and handoff groundwork¶
TL;DR: Adding subagents is additive, but one change is breaking — same-stem agent slugs now fail fast instead of auto-suffixing.
Adding subagents is additive: apps that omit it behave as before, and
GlobalConfig/AgentSpec keep extra="forbid", so unrelated unknown keys still
fail fast.
One deliberate breaking change (Decisions log #17): two agent files that
resolve to the same slug — e.g. billing.agent.md in both the app root and
agents/, or names that sanitize identically — now cause a fail-fast startup
error instead of today's silent auto-suffixing (billing, billing_2).
Migration is a one-line fix: rename one file. This unifies the collision
contract app-wide (matching duplicate skill/workflow-tool handling) and is what
lets subagents references resolve unambiguously — but because it can reject a
previously-booting app, it must be called out in release notes.
The implementation is not zero-touch: the app factory changes to two-pass
composition, validation gains cross-agent rules, and _naming.py changes from
auto-suffix to fail-fast. Regression tests must cover existing no-subagent
registration names, triggers, endpoints, and tool assembly, plus the new
fail-fast path.
The field is not named workflows: for the reasons in §2 and Decisions log #5.
Stable identities, immutable AgentCatalog, and the role-based agent builder
are reusable for handoff. They are not all that handoff needs:
HandoffBuilder also requires a Workflow execution path, checkpointing,
session ownership, request-info/HITL, and event semantics. The handoff FRD owns
that work.
Delegation and handoff will coexist and compose (Decisions log #7). An app may
mix delegating coordinators with handoff coordinators. After the fast-follow, a
handoff participant may itself declare subagents and delegate.
5. Decisions log¶
| # | Decision | Options considered | Choice | Decided by | Date |
|---|---|---|---|---|---|
| 1 | v1 pattern | manual routing / delegation (as_tool) / true handoff (HandoffBuilder) |
Delegation | Human (user) | 2026-07-14 |
| 2 | Target interaction | "one assistant throughout" vs "specialist takes over" | One assistant throughout (→ delegation) | Human (user) | 2026-07-14 |
| 3 | Dependency for v1 | none (as_tool in core 1.3.x) / orchestrations beta / orchestrations stable + core bump |
None (use as_tool, no new dep — mechanism later revised by #20, conclusion unchanged: still no new dependency) |
Agent (proposed) | 2026-07-14 |
| 4 | Handoff disposition | drop / "maybe later" / committed fast-follow / v1 preview | Committed fast-follow, chat-scoped, designed-for in v1 | Human (user) | 2026-07-14 |
| 5 | Authoring field name | subagents / delegates_to / agents / reuse workflows |
subagents (avoids workflows collision) |
Human (user) | 2026-07-15 |
| 6 | Mutual / peer references (A↔B) | reject cycles / single-level (structural) / nested + depth cap | Single-level in v1, enforced structurally (a delegated specialist has no delegate_* tools wired), so mutual refs are benign and no cycle detection is needed; nesting is a later extension |
Human (user) | 2026-07-14 |
| 7 | Delegation vs handoff coexistence | either-or / both coexist + compose | Coexist + compose (a handoff participant may itself declare subagents and delegate) |
Human (user) | 2026-07-14 |
| 8 | HITL in v1 | support / none | None — no HITL/approval exists in the runtime today (verified); v1 sub-agents run autonomously; HITL is net-new and lands with the handoff fast-follow | Agent (proposed — confirm) | 2026-07-14 |
| 9 | Participant identity | file-stem slug only / display name / stable id field / relative path |
File-stem slug only (no id field — see #16); computed pre-registration; slugs are globally unique — any collision fails fast at startup (see #17) — so a reference resolves to exactly one agent |
Human (user) | 2026-07-15 |
| 10 | Delegation trust model | coordinator-authority / specialist opt-in / auth-level guardrail | Coordinator-authority — subagents is an author capability grant; one app = one trust domain; specialist endpoint auth not consulted |
Human (user) | 2026-07-14 |
| 11 | subagents schema shape |
object-only / string-list / mixed str \| SubagentRef union |
Object-only SubagentRef with fields {agent, when?} (see #16); string shorthand deferrable later as non-breaking sugar |
Human (user) | 2026-07-14 |
| 12 | Delegated failure / timeout / cancellation | abort coordinator / recoverable tool error / split the two classes | Split: child failure or specialist-local timeout → recoverable tool_end error; parent/request cancellation → propagate + abort; effective timeout = min(specialist, coordinator remaining); stable sanitized error to the model, full detail to telemetry |
Agent (proposed — confirm) | 2026-07-14 |
| 13 | How a specialist runs when delegated (the delegated role) | strip a delegated agent's tools / inherit-then-restrict / runs as itself with request-scoped caps naturally absent | Runs as itself — own instructions/model/static tools/MCP/skills, via a builder direct/delegated execution-role param (no mutation); sandbox is per-request and Dynamic-Workflow tools are already main-only, so both are naturally absent from a delegated call rather than stripped; context isolated (propagate_session=False) per industry-standard default |
Agent (proposed — confirm) | 2026-07-15 |
| 14 | Delegation breadth / concurrency | no cap / explicit caps / serialize calls | No hard cap; different specialists run in parallel. Revised by #20: each delegate call builds its own specialist instance, so calls — including repeated calls to the same specialist — run in parallel with no shared-instance lock; per-turn count bounded by MAF's tool loop; prompt-schema size is the documented cost | Human (user) | 2026-07-14 |
| 15 | Delegated-role capability scope, re-examined via cross-framework research | keep original restrictive framing / drop restrictions / reframe | Reframe — a survey of MAF + OpenAI, Anthropic, LangGraph, AutoGen, CrewAI, and Google ADK confirmed MAF imposes none of the originally-drafted restrictions and that a delegated sub-agent running with its own tools is the universal norm. Dropped the "workflow-tool strip" (redundant with FRD 0004's main-only gating); reframed sandbox/context as request-scoped defaults, not restrictions; kept single-level (#6) as the one real delegation rule — enforced structurally (a delegated specialist has no delegate_* tools wired), precedented (ADK task-mode, CrewAI), with bounded nesting left as a localized future change |
Human (user) | 2026-07-15 |
| 16 | Drop id and tool_name for v1 (simplicity) |
keep both / drop both / drop one | Drop both — identity is the file-stem slug (collisions fail fast app-wide — see #17); the delegated tool is always delegate_<slug>. Removes a field and the id/name/slug confusion; both are re-addable later as non-breaking additions if a real need appears |
Human (user) | 2026-07-15 |
| 17 | Same-stem slug collision handling (base runtime) | keep auto-suffix+warn / fail-fast app-wide / open separate issue | Fail-fast app-wide — replace _naming.py's silent auto-suffix with a startup error, unifying the contract with duplicate skill/workflow-tool handling and guaranteeing unique slugs for subagents references. Breaking: existing apps with same-stem files must rename one (release-note item) |
Human (user) | 2026-07-15 |
| 18 | Who may declare subagents |
any independently runnable agent / main-agent-only (mirror FRD 0004 workflows.enabled) |
Any independently runnable agent may declare subagents. Single-level (#6) still applies, so when that agent is itself invoked as a sub-agent its subagents are not wired and it cannot delegate onward. Simpler than a main/non-main split and matches the cross-framework norm (#15) |
Human (user) | 2026-07-15 |
| 19 | Observability approach | new bespoke tracing / rely on existing auto-instrumentation + add delegation enrichment / defer all enrichment | Rely on auto-instrumentation, add delegation enrichment — the runtime already enables MAF gen_ai spans and Azure Monitor export (_observability.py), and the delegate tool calling Agent.run() (originally via as_tool()→run(); the hand-written tool added by #20 calls run() directly, same effect) + FunctionTool.invoke() auto-nest a delegated call under one trace/OperationId with no new tracing code (verified against MAF tag python-1.3.0 and the Functions Python worker's context attach). v1 additionally adds af.delegate.* attributes, delegate metrics, and explicit delegated-error accounting for parity with sandbox/web_request. Token roll-up across the boundary and SSE stream-through of specialist internals are documented limitations |
Human (user) | 2026-07-15 |
| 20 | Delegate execution mechanism, revisited post-implementation | keep as_tool() + per-specialist asyncio.Lock + specialist_agent.run monkeypatch/stream-capture (as first implemented, #1/#3) / rewrite as a hand-written non-streaming @tool(schema=...) function tool, building a fresh specialist Agent per call |
Hand-written non-streaming tool, built fresh per call — a delegate only ever needs the specialist's final text (§4.12's "SSE is a black box at the boundary" was already a non-goal), so there is no reason to run the specialist through Agent.run(stream=True, ...) at all, which is all as_tool()'s own _agent_wrapper ever did internally before await-ing stream.get_final_response() back into one string anyway. That streaming requirement was the only reason the first implementation needed to monkeypatch specialist_agent.run (to capture the ResponseStream as_tool() builds internally, so it could be force-finalized on timeout/cancellation — ResponseStream.__anext__'s cleanup hooks only fire from its own except StopAsyncIteration/except Exception branches, never BaseException) and, because the specialist Agent object was shared across calls in a turn, to serialize concurrent same-specialist calls behind a per-specialist asyncio.Lock so that monkeypatch rebind was race-free. Switching to plain, non-streaming agent.run(task) — verified against installed agent-framework-core==1.3.0 (AgentTelemetryLayer._run, ChatTelemetryLayer._get_response in agent_framework.observability) to close its OTel spans deterministically on any exception, asyncio.CancelledError included, via the ordinary with/context-manager __exit__ guarantee (no BaseException gap like the streaming path has) — removes the need to capture or finalize a stream at all. Building the specialist Agent fresh on every call, instead of once per tool-build and reused, removes the shared mutable state the lock existed to protect, so the lock is removed too: same-specialist calls now simply run in parallel, each on its own instance (revises #14). Net result: less code, a cleaner and more debuggable per-call span timeline (each specialist run's span opens/closes at one well-defined point per call, on every path — success, recoverable failure, timeout, cancel), and no behavior change visible to the coordinator or its model |
Human (user) | 2026-07-16 |
6. Test plan¶
- [ ] Unit:
schema—SubagentRefobject form parses (fieldsagent, optionalwhen);extra="forbid"remains; v1 rejects string shorthand. - [ ] Unit:
merge— normalizesubagents; derive identity from the file-stem slug. - [ ] Unit:
validation/_naming.py— fail fast on any duplicate agent slug app-wide (same stem in root vsagents/, or names that sanitize alike), with an actionable rename error naming both files (replaces the old auto-suffix behavior); also reject unknown, duplicate, and self-references; accept A↔B with no cycle rejection; accept a referenced endpoint-less internal specialist regardless of file order. - [ ] Unit: tool-name collision — fail fast when
delegate_<slug>collides with coordinator user/MCP/sandbox/workflow tools or another specialist. Checked once, during capability-aware validation. - [ ] Unit:
runner/ delegated role — assemble the hand-writtendelegate_<slug>specialist tool and return a result. Verify that a delegated specialist uses its own instructions, model, static user/MCP/skills tools; lacks a per-request sandbox and main-only workflow tools; and does not expand its ownsubagents. Verify the same agent in the direct role keeps its full capabilities. - [ ] Unit: single-level structure — inspect the delegated specialist's tool
list and confirm it has no
delegate_*tools. Confirm A↔B cannot recurse without relying on a runtime guard. - [ ] Unit: failure and cancellation — return specialist exceptions and local
timeouts as sanitized recoverable
tool_enderrors without aborting the coordinator. Propagate parent/request cancellation and abort. Enforcemin(specialist, coordinator remaining)timeout. - [ ] Unit: concurrency — two concurrent calls to the same specialist each build and run on their own independent instance, in parallel, both producing correct results (no shared-instance lock — #20); calls to different specialists also run in parallel; verify every result.
- [ ] Observability — with instrumentation enabled, one delegated call produces
nested
execute_tool delegate_<slug>andinvoke_agent {specialist}spans under the coordinator'sagent.runspan, all sharing one trace id; specialists dispatched viaasyncio.gathernest under the coordinator span (context captured at gather time). - [ ] Observability — a recoverable delegated failure increments
af.agent.tool_error_count, sets the delegateaf.*/FaultDomainattributes, and is not mis-classified by_looks_like_tool_error; delegate call/error metrics are recorded. - [ ] Fixture: add
tests/fixtures/config_scenarios/<nn_delegation>/with one coordinator and two specialists, including one endpoint-less internal specialist. Cover object form and app-wide duplicate-slug rejection at startup. - [ ] Regression: existing collision-free no-subagent fixtures resolve and register identically under two-pass composition (triggers, endpoints, and tool assembly unchanged). A fixture with duplicate stems now fails fast at startup (previously auto-suffixed).
- [ ] Sample: add
samples/multi-agent-delegation/with a coordinator and two specialists, one endpoint-less/internal. Show a self-containedtask.
7. Docs impact¶
- [ ]
docs/front-matter-spec.md— document object-formsubagents:and the delegated role. Regeneratedocs/front-matter-reference.mdwithpython eng/scripts/generate_config_reference.py, then run theupdate-schema-docsskill to sync examples. - [ ]
docs/architecture.md— document two-pass composition, immutableAgentCatalog, anddirect/delegatedroles in the module map and pipeline. Add the coordination concept and distinguish it from Dynamic Workflows (FRD 0004). - [ ]
docs/observability.md— document the delegated-call span tree andaf.delegate.*conventions, the token-rollup limitation, and sampling guidance (parentbased_traceidratio) for delegation-heavy apps. - [ ]
docs/triggers.md— explain that an otherwise triggerless/endpoint-less agent is valid only when another agent globally references it as an internal specialist. - [ ]
README.md— add asubagents:quickstart with the trust boundary and self-contained-taskguidance. - [ ]
docs/frds/README.md— add FRD 0007 to the index.
8. Status & sign-off¶
- Architecture review (phase 2): Complete (agent review). Three
rubber-duckpasses are complete. R1 (v1) drove two-pass composition, stable identity, tool-name collisions, failure semantics, delegated-role capability scope, and the trust boundary. R2 (v2) drove catalog/capability ordering, the identity-collision model, correctedAgentconstruction timing, the failure/cancellation split, phased validation, and the breadth/concurrency decision (#14). R3 (v3.1) confirmed the index construction, per-model client construction, and per-specialist concurrency serialization. A later cross-framework research pass covered MAFas_tool()internals plus OpenAI, Anthropic, LangGraph, AutoGen, CrewAI, and Google ADK. It confirmed that MAF imposes none of the original delegated-role restrictions and that a full-capability delegated specialist is the universal norm. §4 and Decisions log #13/#15 were reframed accordingly. A follow-up simplification then dropped theidandtool_namefields (#16) and unified slug-collision handling to fail-fast app-wide (#17), so identity is simply a globally-unique file-stem slug. Single-level delegation remains the one real rule and is enforced structurally. A post-implementation simplification (#20) then replaced theas_tool()-based delegate tool — and the per-specialistasyncio.Lock specialist_agent.runmonkeypatch/stream-capture it needed to finalize MAF's internalResponseStreamon timeout/cancellation — with a hand-written, non-streaming@tool(schema=...)function tool that builds a fresh specialistAgentper call: a delegate never needed the specialist's streamed tokens, so removing the streaming removed the lock and the monkeypatch with it, at no behavior change to the coordinator. No mechanical blockers remain; the final verdict is Go. Both product choices previously open here are now decided by the reviewer (#5, #18).- Human sign-off: Pending. No open questions remain.
statusisIn reviewwhile this PR is under review; it moves toFinalizedonce the reviewer records final sign-off here.
Resolved product questions¶
Both questions previously open here were decided on 2026-07-15:
- Field name →
subagents(Decision #5) — chosen overdelegates_to/agents/ reusingworkflows, to avoid colliding with FRD 0004'sworkflowsfield. - Who may declare
subagents→ any independently runnable agent (Decision #18). Single-level delegation (#6) still holds: when such an agent is itself invoked as a sub-agent, itssubagentsare not wired and it cannot delegate onward. Simpler than a main-only rule and consistent with the cross-framework norm (#15).
Deferred wording and fast-follow notes¶
- Finalize the exact
arg_descriptionwording during implementation so coordinators reliably produce a self-containedtask. - The handoff FRD, not v1, will choose between beta
agent-framework-orchestrations(1.0.0b260507,core>=1.3.0,<2) and stable1.0.0, which requirescore>=1.9.0.