handoff is silently discarded when it shares a tool-call batch with transfer_task
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 3.3k
- Forks
- 462
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 273
Description
Description
handleHandoff mutates the runtime's shared current agent directly:
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_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. - Give
handoffan explicit session-level routing field thatresolveSessionAgentprefers, 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in pkg/runtime/agent_delegation.go at handleHandoff, runForwarding, and swapCurrentAgent, then reproduce the single-batch transfer_task and handoff case described in the issue. Add or use the failing regression test so the session remains routed to specialist after the batch, and verify the existing transfer_task behavior remains correct.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- ai, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100