Skip to content

handoff is silently discarded when it shares a tool-call batch with transfer_task #4242

Description

@bernard-code-lab

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:

  1. 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.
  2. Give handoff an explicit session-level routing field that resolveSessionAgent prefers, so it stops competing for the same mutable field.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/agentFor work that has to do with the general agent loop/agentic features of the apparea/runtimeRuntime engine, agent loop execution, tool dispatch, loop detectionarea/toolsFor features/issues/fixes related to the usage of built-in and MCP tools

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions