Description
handleHandoff mutates the runtime's shared current agent directly:
|
r.executeOnAgentSwitchHooks(ctx, currentAgent, sess.ID, ca, next.Name(), agentSwitchKindHandoff) |
|
r.setCurrentAgent(next.Name()) |
r.executeOnAgentSwitchHooks(ctx, currentAgent, sess.ID, ca, next.Name(), agentSwitchKindHandoff)
r.setCurrentAgent(next.Name())
This happens outside the pin/switch machinery that runForwarding uses for transfer_task. When the model emits both tools in a single assistant response, the dispatcher runs them in parallel, and transfer_task swaps the current agent with a deferred restore:
func (r *LocalRuntime) swapCurrentAgent(...) func() {
r.setCurrentAgent(to.Name())
return func() {
r.setCurrentAgent(from.Name()) // unconditional
...
}
}
The restore writes the pre-swap agent back unconditionally. It has no way to know that a sibling handoff legitimately changed the current agent in the meantime, so it overwrites it. The handoff is discarded with no error, no event, and no log line — the conversation simply continues with the original agent.
The two tools disagree about what the current agent means. For transfer_task it is call-scoped state to be swapped and restored; for handoff it is session state that must persist into later turns. Restoring it is correct for one and destructive for the other.
Expected Behavior
After a batch containing a handoff, the session is routed to the handoff target, whatever else shared that batch.
Actual Behavior
The current agent is back at the caller. The handoff has no effect and nothing reports the loss.
Steps to Reproduce
Root emits [transfer_task(worker), handoff(specialist)] in one response:
b := newStreamBuilder()
b.AddToolCallName("call_t", transfertask.ToolNameTransferTask).
AddToolCallArguments("call_t", `{"agent":"worker","task":"chunk","expected_output":"r"}`)
b.AddToolCallName("call_h", handoff.ToolNameHandoff).
AddToolCallArguments("call_h", `{"agent":"specialist"}`)
root := agent.New("root", "root agent",
agent.WithModel(&queueProvider{id: "test/mock-model", streams: []chat.MessageStream{
b.AddToolCallStopWithUsage(10, 5).Build(),
newStreamBuilder().AddContent("root done").AddStopWithUsage(10, 5).Build(),
}}),
agent.WithSubAgents(worker),
agent.WithHandoffs(specialist),
agent.WithToolSets(transfertask.New(), handoff.New()),
)
rt := newDelegationRuntime(t, root, worker, specialist)
sess := session.New(session.WithUserMessage("go"), session.WithToolsApproved(true))
_, err := rt.Run(t.Context(), sess)
require.NoError(t, err)
// want "specialist", got "root"
fmt.Println(rt.CurrentAgentName(t.Context()))
Result over 5 consecutive runs:
agent after batch = "root" (expected "specialist")
Control: dropping the transfer_task call and leaving the handoff alone in the batch yields "specialist" on every run. The batch is the trigger, not the handoff itself.
Docker Agent version
main @ 5683379 (also reproduces on the branch for #4156).
Additional context
Found while fixing #4156 in #4180. That PR fixes the caller resolution half for handoff — it used to read the shared current agent to decide who was handing off, which a sibling transfer_task could have already swapped — by resolving from the dispatcher's pre-fan-out snapshot instead.
The mutation half is deliberately left out of that PR. transfer_task's swap is call-scoped and safe to route through the pin/switch machinery; handoff rewires the session for every later turn, so giving it the same treatment is a routing design decision rather than a bug fix, and it does not belong in a concurrency patch.
Rough options, in case they're useful:
- Have the
transfer_task restore be a compare-and-restore: only write from back if the current agent is still the value the swap installed. Smallest change; makes the restore non-destructive without moving handoff.
- Give
handoff an explicit session-level routing field that resolveSessionAgent prefers, so it stops competing for the same mutable field.
- Serialize tools that mutate the current agent within a batch.
Happy to send a PR for whichever direction you prefer — (1) is the smallest and I have the failing test ready.
Description
handleHandoffmutates the runtime's shared current agent directly:docker-agent/pkg/runtime/agent_delegation.go
Lines 783 to 784 in 5683379
This happens outside the pin/switch machinery that
runForwardinguses fortransfer_task. When the model emits both tools in a single assistant response, the dispatcher runs them in parallel, andtransfer_taskswaps the current agent with a deferred restore:The restore writes the pre-swap agent back unconditionally. It has no way to know that a sibling
handofflegitimately changed the current agent in the meantime, so it overwrites it. The handoff is discarded with no error, no event, and no log line — the conversation simply continues with the original agent.The two tools disagree about what the current agent means. For
transfer_taskit is call-scoped state to be swapped and restored; forhandoffit is session state that must persist into later turns. Restoring it is correct for one and destructive for the other.Expected Behavior
After a batch containing a
handoff, the session is routed to the handoff target, whatever else shared that batch.Actual Behavior
The current agent is back at the caller. The handoff has no effect and nothing reports the loss.
Steps to Reproduce
Root emits
[transfer_task(worker), handoff(specialist)]in one response:Result over 5 consecutive runs:
Control: dropping the
transfer_taskcall and leaving thehandoffalone in the batch yields"specialist"on every run. The batch is the trigger, not the handoff itself.Docker Agent version
main@5683379(also reproduces on the branch for #4156).Additional context
Found while fixing #4156 in #4180. That PR fixes the caller resolution half for
handoff— it used to read the shared current agent to decide who was handing off, which a siblingtransfer_taskcould have already swapped — by resolving from the dispatcher's pre-fan-out snapshot instead.The mutation half is deliberately left out of that PR.
transfer_task's swap is call-scoped and safe to route through the pin/switch machinery;handoffrewires the session for every later turn, so giving it the same treatment is a routing design decision rather than a bug fix, and it does not belong in a concurrency patch.Rough options, in case they're useful:
transfer_taskrestore be a compare-and-restore: only writefromback if the current agent is still the value the swap installed. Smallest change; makes the restore non-destructive without movinghandoff.handoffan explicit session-level routing field thatresolveSessionAgentprefers, so it stops competing for the same mutable field.Happy to send a PR for whichever direction you prefer — (1) is the smallest and I have the failing test ready.