docker / docker/docker-agent

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

Open
#4,242 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area/agent area/runtime area/tools
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:

https://github.com/docker/docker-agent/blob/5683379ca3518397174acc12a19d32e0ffb1a515/pkg/runtime/agent_delegation.go#L783-L784

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.