Add handoff agent-workflow builder (implements #520) - #545
Add handoff agent-workflow builder (implements #520)#545PratikDhanave (PratikDhanave) wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a first-class “handoff” workflow builder to the workflow/agentworkflow package by composing on top of the existing group-chat hosting/manager plumbing, and updates hosting configuration to support builder-injected per-run agent options.
Changes:
- Introduces
NewHandoffWorkflowBuilderplus a handoff-specificGroupChatManagerimplementation that routes based on handoff tool calls. - Extends
agentworkflow.ConfigwithRunOptions []agent.Optionand applies those options during hosted agent runs. - Adds
handoff_test.gocoverage for routing/termination/validation/checkpoint restore, and updates the .NET vs Go feature comparison docs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| workflow/agentworkflow/hosting.go | Adds Config.RunOptions and applies them when invoking hosted agents. |
| workflow/agentworkflow/handoff.go | New handoff builder + manager implementation that injects per-agent handoff tools and routes the next speaker. |
| workflow/agentworkflow/handoff_test.go | New tests covering handoff routing, validation, termination behavior, and checkpoint restore. |
| docs/dotnet-go-sdk-feature-comparison.md | Updates feature comparison to reflect the new handoff builder. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -404,6 +411,7 @@ | |||
| // Run the agent in streaming mode only when update events are to be emitted. | |||
| agent.Stream(emitUpdates), | |||
| } | |||
| runOpts = append(runOpts, h.cfg.RunOptions...) | |||
There was a problem hiding this comment.
Fixed — RunOptions are now applied before the host's WithSession/Stream, which are resolved last and take precedence, so a RunOptions entry can no longer override the managed session or streaming mode.
| // RunOptions are additional [agent.Option] values passed to the hosted | ||
| // agent on every run, appended after the host's own options (session and | ||
| // streaming mode). They let a workflow builder inject per-agent behavior — | ||
| // for example the handoff tools added by [NewHandoffWorkflowBuilder] — that | ||
| // is not part of the agent's own configuration. | ||
| RunOptions []agent.Option |
There was a problem hiding this comment.
Fixed — the doc now states RunOptions are applied before the host's session/stream options, which are resolved last and take precedence, so RunOptions cannot override the managed session or stream mode.
| call, ok := content.(*message.FunctionCallContent) | ||
| if !ok { | ||
| continue | ||
| } | ||
| if next, ok := m.toolTargets[call.Name]; ok { |
There was a problem hiding this comment.
Enforcement is at the tool level: each agent is only injected the handoff_to_<target> tools for the targets granted via WithHandoff, so an agent cannot emit a handoff call for a target it was not granted. toolTargets is the shared resolver the routing cursor reads. Happy to add an explicit per-agent guard for defense-in-depth if you'd prefer.
3f0e4f6 to
967fb9b
Compare
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
awmgmcpg
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
Generated by Go API Consistency Review Agent · 87.4 AIC · ⌖ 4.57 AIC · ⊞ 5.7K
| // WithHandoff allows from to hand off the conversation to each of targets. It | ||
| // may be called multiple times to extend an agent's set of targets. Both from | ||
| // and every target must be participants passed to [NewHandoffWorkflowBuilder]. | ||
| func (b *HandoffWorkflowBuilder) WithHandoff(from *agent.Agent, targets ...*agent.Agent) *HandoffWorkflowBuilder { |
There was a problem hiding this comment.
Parity gap — WithHandoff missing per-edge handoffReason
The .NET HandoffWorkflowBuilder.WithHandoff(from, to, handoffReason?) accepts an optional reason string per edge that becomes the tool description surfaced to the LLM. Go currently accepts only source and variadic targets; every resulting tool description is a hardcoded generic sentence (see newHandoffTool).
In .NET the reason falls back to the target agent's Description when not supplied. Models therefore receive richer context about when to hand off versus the current Go behavior.
Upstream reference: dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs — WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null).
Suggestion: keep the variadic convenience form but add a per-edge option (e.g. a scalar overload WithHandoff(from, to, reason) or an options struct) and fall back to to.Description() when not provided — matching the .NET resolution logic.
| Description: fmt.Sprintf("Hand off the conversation to the %q agent. Call this when the request should be handled by that agent instead of you.", targetName), | ||
| }, | ||
| func(context.Context, handoffToolArgs) (string, error) { | ||
| return fmt.Sprintf("Handing off to %s.", targetName), nil |
There was a problem hiding this comment.
Parity gap — handoff system-prompt instructions not injected
The .NET builder auto-injects a HandoffInstructions string into each participating agent's system prompt:
You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved by calling a handoff function, named in the form `handoff_to_<agent_id>`...
This is configurable via WithHandoffInstructions(string?) and defaults to a well-crafted paragraph that also asks agents not to mention handoffs to the user. The Go implementation currently omits this entirely — models receive only the tool descriptions and no system-level context about being part of a handoff workflow.
Upstream reference: dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs — HandoffInstructions property, WithHandoffInstructions() method, DefaultHandoffInstructions constant.
The PR description acknowledges this as an open question, which is good. It should be resolved before the builder is considered aligned with .NET. At minimum, add a WithHandoffInstructions(string) builder method and an opt-out path (empty/nil) — injecting the instructions via the existing RunOptions/agent.WithSystemPromptSuffix mechanism should work without changing the agent itself.
|
|
||
| // handoffToolName derives the handoff tool name presented to the model for a | ||
| // target agent, from its name (or ID when unnamed). | ||
| func handoffToolName(target *agent.Agent) string { |
There was a problem hiding this comment.
Subtle parity divergence — tool name derived from agent name, not agent ID
The .NET FunctionPrefix constant documents: "the full name is then handoff_to_<agent_id>, where <agent_id> is the ID of the target agent to hand off to." The Go implementation instead derives the tool name from the agent's display name (falling back to ID only when the name is blank):
base := target.Name()
if strings.TrimSpace(base) == "" {
base = target.ID()
}This means:
- Two agents with the same display name (but different IDs) will produce a tool-name collision detected at build time — which is correct and caught.
- An agent whose display name changes between runs could produce a different tool name, breaking checkpoint-restored routing (though in practice the agent name is set at construction).
- The tool names presented in the LLM context will differ from the .NET convention when agent IDs and names diverge.
This is a minor but real divergence. If the intent is to keep names human-readable for the model, it is a reasonable Go-specific choice; if so, a brief doc comment explaining the deliberate difference from .NET would help maintainers.
| | Workflow as agent | Workflow host agent / `AsAIAgent`, sample. | `agentworkflow.NewAgent`. | Aligned | Go chooses in-process environment based on concurrency; .NET is integrated with `AIAgent` extensions. | | ||
| | Subworkflows | `ConfigureSubWorkflow`, `BindAsExecutor`, subworkflow sample. | `inproc.BindSubworkflowAsExecutor` plus in-process subworkflow execution. | Aligned | Go exposes subworkflow binding from the in-process execution package. | | ||
| | Handoff orchestration | Handoff workflow builder with handoff instructions, tool-call filtering, return-to-previous, response/update events. | No first-class handoff builder. | .NET only | Could be modeled manually with tools/workflows, but no SDK feature. | | ||
| | Handoff orchestration | Handoff workflow builder with handoff instructions, tool-call filtering, return-to-previous, response/update events. | `agentworkflow.NewHandoffWorkflowBuilder` with `WithHandoff` handoff edges; per-agent handoff tools route the shared conversation to the chosen target, with return-to-previous supported by declaring reverse handoffs. | Aligned | Go now has a first-class handoff builder; the current turn's agent hands off by calling an injected `handoff_to_<target>` tool, reusing the group chat host and manager plumbing. | |
There was a problem hiding this comment.
Docs parity assessment is premature — should remain "Partial"
This row changes the parity verdict from .NET only to Aligned, but several meaningful .NET features are still absent from the Go builder:
| .NET feature | Go status |
|---|---|
WithHandoffInstructions / auto-injected system prompt |
Missing (open question in PR) |
Per-edge handoffReason (used as tool description) |
Missing |
WithTerminationCondition (custom callback) |
Missing (Go only has max-iteration count) |
WithToolCallFilteringBehavior |
Missing |
WithAutonomousMode |
Missing (lower priority, but a .NET feature) |
EnableReturnToPrevious |
Present in Go (via reverse WithHandoff edges) |
Suggesting changing the verdict to Partial and updating the description to note the gaps remaining, so the comparison table stays accurate.
967fb9b to
bd49051
Compare
This comment has been minimized.
This comment has been minimized.
…uilder) Implements the handoff orchestration gap tracked in microsoft#520. A handoff workflow is a group chat whose next speaker is chosen by the current agent itself: each agent is given a handoff tool for every target it may hand off to, and calling that tool routes the shared conversation to the target. The first agent is the entry agent; a turn with no handoff call ends the workflow. Reuses the existing group chat host executor and GroupChatManager plumbing rather than introducing a new engine: NewHandoffWorkflowBuilder injects per-agent handoff tools and supplies a handoff-flavored manager whose SelectNextAgent routes on the most recent handoff tool call in the previous speaker's turn. Adds a RunOptions field to the hosting Config so the builder can inject those tools into each hosted agent's run. Mirrors the existing builders' API (WithName/WithDescription/WithHandoff/ WithOutputFrom/Build) and the round-robin manager's checkpoint pattern. Covers routing, hand-back, no-handoff termination, builder validation, and manager checkpoint restore. Updates the .NET/Go feature comparison. Implements microsoft#520
- Add WithMaximumIterationCount to cap handoff loops (e.g. two agents that keep handing off), wired through the manager's ShouldTerminate; defaults to the group chat's 40-turn cap. - Reject self-handoff (WithHandoff(a, a)) in Build with a clear error. - Document that participant agents must have automatic function-tool calling enabled so injected handoff tools execute (otherwise the handoff call is unresolved and stalls the run). - On an inconsistent restored cursor beyond the history, end cleanly instead of rescanning from the start and routing on a stale handoff. - Comment the manager factory's ignored agents argument. Adds tests: last-of-multiple handoff calls wins, unknown handoff tool is ignored, the iteration cap stops a ping-pong loop, and colliding sanitized tool names are rejected at Build.
Apply RunOptions before the host's own WithSession/Stream so those are resolved last and take precedence; a RunOptions entry can no longer override the managed session or streaming mode. Update the doc to match.
bd49051 to
93bd648
Compare
This comment has been minimized.
This comment has been minimized.
Parity Review: PR #545 — Handoff Workflow BuilderThe PR adds public Go APIs ( The implementation is well-grounded in the upstream .NET pattern (tools named 1. System-prompt handoff instructions are not auto-injected (🔴 substantive gap)Upstream behaviour (.NET): Go behaviour: The Go builder only injects the handoff tools; it does not add any system-level instruction to tell the model how or when to use them. Without this context, models that are not already aware of the handoff convention will tend to ignore or mis-use the tools. Suggested alignment: Add a default instructions string (mirroring the .NET default) and inject it via each agent's Reference: 2. Per-edge handoff reason / tool description is not customisable (🟡 minor gap)Upstream behaviour (.NET & Python): Both SDKs let callers provide a custom reason (description) for each handoff edge — Go behaviour: Suggested alignment: Add an optional Reference: 3. No example added — upstream has a dedicated sample (🟡 tracking item)The PR description explicitly defers the example ( This is a known gap, not a blocker, but it should be tracked as a follow-up to maintain sample parity once the API shape is agreed. What is well-aligned
The most important item to resolve before the API is marked stable is gap 1 (system-prompt injection); gap 2 is strongly recommended for routing quality parity.
|
|
PratikDhanave (@PratikDhanave) can you resolve the parity gaps? |
There was a problem hiding this comment.
Generated by Go API Consistency Review Agent · sonnet46 · 37.8 AIC · ⌖ 4.91 AIC · ⊞ 6K
| Description: fmt.Sprintf("Hand off the conversation to the %q agent. Call this when the request should be handled by that agent instead of you.", targetName), | ||
| }, | ||
| func(context.Context, handoffToolArgs) (string, error) { | ||
| return fmt.Sprintf("Handing off to %s.", targetName), nil |
There was a problem hiding this comment.
Parity gap: missing handoff system-prompt injection (.NET / Python)
Both the .NET and Python upstream implementations automatically append handoff instructions to each participating agent's system prompt when handoff tools are injected. This orients the agent so it knows when and how to call the tool.
.NET default (HandoffWorkflowBuilder.DefaultHandoffInstructions): appended to each agent that receives handoff tools, explaining the transfer_to_(id) function mechanics and instructing the agent never to narrate handoffs.
Python (_prepare_agent_with_handoffs): similarly augments each cloned agent's options with per-target context.
The Go implementation injects the handoff tools but does not append any system-level instructions. Without this, agents may call the wrong tool, ignore it, or narrate the handoff to the user — all behaviors the upstream defaults suppress.
The PR author acknowledges this is open. To reach parity before this API is stable:
- Add default handoff instruction injection (e.g., via a new agent option) applied in Config.RunOptions alongside the tool injection.
- Expose WithHandoffInstructions(string) on the builder so callers can customize or suppress the default, mirroring .NET's WithHandoffInstructions(string?).
Upstream references:
- dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs — DefaultHandoffInstructions, WithHandoffInstructions()
- python/packages/orchestrations/agent_framework_orchestrations/_handoff.py — _prepare_agent_with_handoffs()
|
|
||
| // handoffToolArgs is the (empty) argument object for a handoff tool. The model | ||
| // calls the tool with {} to hand off; no parameters are required. | ||
| type handoffToolArgs struct{} |
There was a problem hiding this comment.
Parity gap: handoff tool description ignores target agent description (.NET / Python)
In .NET, WithHandoff(from, to, handoffReason?) derives the tool description from the target agent's Description, then Name, then Instructions. If none is available it throws at build time, forcing callers to either supply agent metadata or pass an explicit reason. This gives the LLM meaningful guidance on why to route to a specific agent.
In Python, _create_handoff_tool similarly accepts a description (defaulting to 'Handoff to the (id) agent.' only when none is provided).
The Go implementation produces a fixed generic description ('Hand off the conversation to the "X" agent. Call this when the request should be handled by that agent instead of you.') regardless of what description the target agent carries.
Suggested fix: fall back to the target agent's Description (if exposed) before using the generic template, and optionally expose a per-handoff reason parameter on WithHandoff, mirroring .NET's handoffReason.
Upstream reference:
- dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs — WithHandoff(from, to, handoffReason?) description resolution
There was a problem hiding this comment.
Generated by Go API Consistency Review Agent · sonnet46 · 36.4 AIC · ⌖ 5.29 AIC · ⊞ 6K
| func (b *HandoffWorkflowBuilder) WithName(name string) *HandoffWorkflowBuilder { | ||
| if b == nil || b.err != nil { | ||
| return b | ||
| } |
There was a problem hiding this comment.
Parity gap — handoff instructions not injected into agent system prompts
Both the .NET and Python implementations automatically inject a default system-level instruction into each participating agent explaining when and how to call the handoff_to_<target> tool. Without these instructions, agents may not reliably call the tool.
.NET (HandoffWorkflowBuilderCore):
public string? HandoffInstructions { get; private set; } = DefaultHandoffInstructions;
// Default: "You are one agent in a multi-agent system. You can hand off the conversation to another agent..."
public TBuilder WithHandoffInstructions(string? instructions) { ... }Python (HandoffWorkflowBuilder): a default handoff_instructions string is injected into each HandoffAgentExecutor when building.
Go (this PR): agents receive the injected tool but no guidance about when to use it.
The PR description acknowledges this as "open for steer", but upstream treats it as a load-bearing default. Recommend either injecting the instructions unconditionally (matching the upstream default) or adding a WithHandoffInstructions(instructions string) *HandoffWorkflowBuilder method that defaults to the same canonical text. Either way it should be on-by-default, not opt-in, to match upstream semantics.
| cursor int | ||
| } | ||
|
|
||
| func newHandoffManager(entry *agent.Agent, toolTargets map[string]*agent.Agent, maximumIterationCount int) *GroupChatManager { |
There was a problem hiding this comment.
Parity gap — autonomous mode not present
Both upstream SDKs provide an opt-in autonomous mode: when the current agent produces a response that does not include a handoff call, instead of immediately ending the workflow the agent is re-invoked with a continuation prompt (up to a configurable per-agent turn limit). This is useful for long-running research/coding tasks where the agent needs multiple model turns before it decides to hand off.
.NET (HandoffWorkflowBuilderCore):
public TBuilder EnableAutonomousMode(
int turnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit,
string? continuationPrompt = null) { ... }
// per-agent overrides: EnableAutonomousModeForAgent(agent, turnLimit, prompt)
// termination condition: WithTerminationCondition(Func<..., ValueTask<bool>>)Python (HandoffWorkflowBuilder):
autonomous_mode: bool = False,
autonomous_mode_prompt: str | None = None,
autonomous_mode_turn_limit: int | None = None,Go (this PR): a turn with no handoff call always terminates the workflow immediately; there is no autonomous-mode opt-in and no termination-condition callback.
This is not a blocking correctness bug for the basic handoff scenario, but it is a significant behavioral gap versus the published .NET and Python APIs. Recommend tracking in a follow-up issue and noting the absence in the docstring / feature-comparison doc so callers know the limitation.
| return builder.Build() | ||
| } | ||
|
|
||
| // handoffToolName derives the handoff tool name presented to the model for a |
There was a problem hiding this comment.
Minor parity note — WithHandoff per-edge description/reason not supported
In .NET, WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) accepts an optional per-edge description. When absent, it falls back to the target agent's .Description or .Instructions. If none are available the builder throws, ensuring the model always receives a meaningful tool description for each handoff edge.
Go (this PR): the tool description is always derived from the target agent's Name()/ID() ("Hand off the conversation to the \"<name>\" agent."). There is no per-edge description override and no fallback to the agent's own description/instructions field. This is a minor gap but could produce less effective prompting when agent names are opaque IDs.
Implements the handoff orchestration gap from #520. Opening as a draft to align on the API/mechanism before finalizing — happy to adjust to the team's steer (the design questions in #520 are resolved here with sensible defaults, noted below).
What
A first-class handoff builder alongside the existing sequential / concurrent / group-chat builders:
A handoff workflow is a group chat whose next speaker is chosen by the current agent: each agent is given a
handoff_to_<target>tool for every target it may hand off to; calling that tool routes the shared conversation to the target. The first agent is the entry agent; a turn with no handoff call ends the workflow.How (reuse, not a new engine)
NewHandoffWorkflowBuilderinjects per-agent handoff tools and supplies a handoff-flavoredGroupChatManagerwhoseSelectNextAgentroutes on the most recent handoff tool call in the previous speaker's turn (a cursor tracks per-turn scanning and is checkpoint-persisted, mirroring the round-robin manager'sNextIndex).groupChatHostExecutor, edges, output designations, andprefixingWorkflowContextcheckpoint plumbing.agentworkflow.Config: aRunOptions []agent.Optionfield so the builder can inject the handoff tools into each hosted agent's run (hosting.go). No existing signatures change.Design decisions (the open questions from #520)
.NET-faithful auto-injectedhandoff_to_<target>function tools. Because they carry a handler they auto-execute, so the (resolved) call flows into history for the manager to read — noInterceptUnterminatedFunctionCallsneeded.GroupChatManagervariant; no duplicate engine.WithHandoff(billing, triage)).Tests
handoff_test.go(white-box, matching this package's convention) covers: routing on a handoff call, hand-back (triage→billing→triage), no-handoff termination, builder validation (nil/non-participant agents & targets), and handoff-manager checkpoint restore. All green under-race -shuffle=on; full suite passes. Updatesdocs/dotnet-go-sdk-feature-comparison.md.Open for steer
Naming (
WithHandoffvs edge-style), whether handoff instructions should be auto-added to agent system prompts (.NETdoes), and whether to add anexamples/03-workflowssample once the API is agreed — I'll follow up per the team's preference.