Chapter 14 — Handoff Orchestration
Why this chapter
Sequential and Concurrent orchestration (Chapters 12 and 13) both predetermine the flow — the graph shape is fixed before any agent runs. Handoff lets the agents decide where the conversation goes next. A Triage agent reads the question and hands off to the right specialist by emitting a synthesized handoff_to_<name> tool call; specialists can hand back to Triage for follow-ups. It’s the mesh topology that powers customer-support bots and research assistants that pull in domain experts on demand — and it’s the same shape this repo’s capstone uses to let the orchestrator route live traffic to specialist agents mechanically instead of through hand-rolled tool logic.
Canonical example: Triage agent routes to a Math or History specialist, which can hand back for follow-ups.
Prerequisites
- Completed Chapter 13 — Concurrent Orchestration
- Repo-root
.envwith one LLM provider:OPENAI_API_KEY(+ optionalLLM_MODEL, defaultgpt-4.1) orAZURE_OPENAI_ENDPOINT/AZURE_OPENAI_KEY/AZURE_OPENAI_DEPLOYMENT(+ optionalAZURE_OPENAI_API_VERSION, default2024-10-21)
The concept
HandoffBuilder wires a set of agents into a mesh and, for every declared edge, synthesizes a handoff_to_<name> tool on the source agent. The current speaker decides — using its own reasoning over the conversation so far — whether to answer directly or call that tool and pass control to a target. Nothing external routes the conversation; the routing decision lives entirely inside each agent’s own LLM call.
That autonomy is also the risk. A mesh with no exit condition can bounce a conversation between two agents indefinitely, each one legitimately deciding to hand back. with_autonomous_mode(agents=..., turn_limits={...}) bounds that: it keeps the loop running without waiting for a human turn between hops, but caps how many turns each named agent gets before the workflow is forced to stop.
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af',
'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981',
'background': 'transparent'}}}%%
flowchart LR
accTitle: The concept
classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef external fill:#f59e0b,stroke:#b45309,color:#000000
classDef success fill:#10b981,stroke:#047857,color:#ffffff
user([User question])
triage[Triage agent]
math[Math specialist]
history[History specialist]
answer([Final answer])
user --> triage
triage -- "handoff_to_math" --> math
triage -- "handoff_to_history" --> history
math -- "handoff_to_triage (follow-up)" --> triage
history -- "handoff_to_triage (follow-up)" --> triage
math --> answer
history --> answer
class triage core
class math core
class history core
class answer success
Each specialist decides for itself whether to hand back — the mesh has no central router; every edge is a tool call one agent chooses to make.
Python
Run from the repo root using the shared tutorials/ uv project (one uv sync covers every chapter):
uv sync --project tutorials
uv run --project tutorials python tutorials/14-handoff-orchestration/python/main.py
uv run --project tutorials pytest tutorials/14-handoff-orchestration/python/tests -v
Source: python/main.py. The mesh is built in build_workflow():
def build_workflow():
t = triage()
m = math_expert()
h = history_expert()
return (
HandoffBuilder(participants=[t, m, h])
.with_start_agent(t)
.add_handoff(t, [m, h])
.add_handoff(m, [t]) # specialists can hand back to triage for follow-ups
.add_handoff(h, [t])
.with_autonomous_mode(agents=[t, m, h], turn_limits={"triage": 3, "math": 2, "history": 2})
.build()
)
Every participant Agent is constructed with require_per_service_call_history_persistence=True — as of agent-framework-orchestrations>=1.0.1, HandoffBuilder.build() requires this on every participant because its middleware short-circuits tool calls during a handoff, so local history has to stay in sync with what the service actually saw.
ask() drives the workflow with stream=True and reconstructs each agent’s turn from the event stream:
async for event in _workflow_events(workflow, question):
etype = getattr(event, "type", None)
eid = getattr(event, "executor_id", "") if etype == "output" else None
if etype == "output" and eid in {"triage", "math", "history"}:
if current_agent != eid:
current_agent = eid
buffers.append((eid, []))
update = getattr(event, "data", None)
text = getattr(update, "text", None) if update is not None else None
if text:
buffers[-1][1].append(text)
elif etype == "handoff_sent":
data = getattr(event, "data", None)
target = getattr(data, "target", None)
if target:
handoffs.append(target)
Running "What's 37 * 42?" routes triage → math and prints the numeric answer; running "When did WWII end?" routes triage → history and prints 1945.
.NET
cd tutorials/14-handoff-orchestration/dotnet
dotnet run -- "What is 37 * 42?"
dotnet/Program.cs uses the convenience builder to wire the same mesh shape:
Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triage)
.WithHandoffs(triage, new[] { mathTutor, historyTutor })
.WithHandoffs(new[] { mathTutor, historyTutor }, triage)
.Build();
The run loop watches AgentResponseUpdateEvent for streamed text (printing the executor id the first time each agent speaks) and pulls the final transcript off the WorkflowOutputEvent whose Data is a List<ChatMessage>. description on each AsAIAgent(...) call shows up as the default handoff reason the builder stamps into that agent’s synthesized handoff_to_<name> tool schema.
Side-by-side differences
| Aspect | Python | .NET |
|---|---|---|
| Declare mesh | HandoffBuilder(participants=[...]).add_handoff(source, [targets]) | AgentWorkflowBuilder.CreateHandoffBuilderWith(start).WithHandoffs(source, targets) |
| Autonomy | .with_autonomous_mode(agents=[...], turn_limits={...}) | Interactive loop via RunStreamingAsync + TrySendMessageAsync(new TurnToken(...)) |
| Observe handoffs | handoff_sent event, event.data.target | Inferred from AgentResponseUpdateEvent.ExecutorId changing |
| Streamed text | output event, event.data.text (an AgentResponseUpdate) | AgentResponseUpdateEvent.Update.Text |
| Per-participant history requirement | require_per_service_call_history_persistence=True on every Agent | Not required — handled by the .NET builder internally |
Gotchas
- Every participant needs an explicit handoff edge. If you declare an agent as a participant but never call
add_handoff(agent, [...])/WithHandoffs(agent, ...)for it, that agent can’t invoke any handoff tool — it can receive control but never hand off or hand back. - Turn limits prevent infinite loops. Without
turn_limits={}per agent in Python’swith_autonomous_mode(...), a legitimate back-and-forth between triage and a specialist can cycle indefinitely, since each hop is a locally reasonable decision with no global view of the conversation. require_per_service_call_history_persistence=Trueis mandatory in current Python.HandoffBuilder.build()inagent-framework-orchestrations>=1.0.1raises if any participant Agent omits it — its middleware short-circuits tool calls during a handoff, so each agent’s local history has to track what the service actually saw.- The MAF v1.0 empty-
__init__.pypackaging bug is fixed, but the fix wasn’t a new tutorial file.agents/python/patch_maf.pystill exists as a documented no-op — the bug it patched (agent-framework-core==1.0.0shipping an empty__init__.pywith no public re-exports) was fixed upstream byagent-framework1.14.0, which this repo now pins, sopatch()only writes when the target file is empty (never, on a current install). The bootstrap tutorials actually rely on today istutorials/_shared/maf_bootstrap.py, called at the top ofmain.pybefore anyagent_frameworkimport. HandoffBuilder’s mesh construction is sensitive toPYTHONHASHSEED. This chapter discovered that MAF builds its participant mesh from a set-like collection internally, so the exact text baked into a specialist’s follow-up turns — and therefore the replay-fixture hash for those turns — varies with Python’s per-process hash randomization. None of the other orchestration chapters (12/13/15/16) hit this; their participant lists are consumed in list order. The replay fixtures underpython/tests/fixtures/replay/were recorded withPYTHONHASHSEED=0, the replay test skips itself unless that same pin is set, and CI (.github/workflows/tutorials.yml) setsPYTHONHASHSEED: "0"at the job level specifically because of this chapter.
Tests
python/tests/test_handoff.py is integration-focused — the mesh needs a real LLM to make routing decisions, so most of it is skipped without credentials:
- A wiring test (
test_workflow_builds) that always runs. - A keyless replay test (
test_replay_routes_math_to_math_agent) that plays back a committed fixture viaLLM_PROVIDER=replay— this is what CI exercises on every PR, gated onPYTHONHASHSEED=0per the gotcha above. - Three
@pytest.mark.integrationtests against a real LLM: routing math to the math agent, routing history to the history agent, and asserting math/history questions diverge in which specialists they reach.
uv run --project tutorials pytest tutorials/14-handoff-orchestration/python/tests -v
# Deterministic replay run (matches how CI runs it):
PYTHONHASHSEED=0 uv run --project tutorials pytest tutorials/14-handoff-orchestration/python/tests -v
How this shows up in the capstone
This chapter’s mesh is not just a teaching example — it’s the reference implementation for a live orchestration mode in the capstone app:
agents/python/orchestrator/handoff.py:49—build_orchestrator_handoff_workflow()builds the productionHandoffBuildermesh: the orchestrator as start agent, one edge to each remote specialist (agents/python/orchestrator/handoff.py:80), and a handoff back to the orchestrator from each specialist (agents/python/orchestrator/handoff.py:81-82). Every specialist is aRemoteSpecialistChatClient(shared/remote_agent.py) wrapped in anAgent, so handoffs still traverse A2A HTTP on the wire — the mechanism is Handoff, the transport stays A2A.agents/python/orchestrator/modes/handoff_mode.py:1—HandoffModeis what makes the mesh reachable from a live request (mode="handoff"on/api/chat, orORCHESTRATION_MODE=handoffas the deployment default). Its module docstring says outright that this tutorial’spython/main.py::ask()is “the verified reference for how to read a handoff workflow’s event stream” — the sameoutput-event, per-executor-id text assembly shown above is whatHandoffMode.run()(agents/python/orchestrator/modes/handoff_mode.py:61) does against real specialist agents.- Default orchestration stays
toolmode (thecall_specialist_agentrouter);handoffis additive and opt-in, so nothing about the default runtime changes unless a request or deployment config selects it.
What’s next
- Next chapter: Chapter 15 — Group Chat Orchestration
- Full source:
python/·dotnet/ - MAF docs — Handoff Orchestration
Source: tutorials/14-handoff-orchestration/README.md — this page is generated from the repository.