Skip to content

fix: make transfer_task concurrency-safe under parallel tool calls - #4180

Open
bernard-code-lab wants to merge 1 commit into
docker:mainfrom
bernard-code-lab:fix/4156-concurrent-transfer-task
Open

fix: make transfer_task concurrency-safe under parallel tool calls#4180
bernard-code-lab wants to merge 1 commit into
docker:mainfrom
bernard-code-lab:fix/4156-concurrent-transfer-task

Conversation

@bernard-code-lab

Copy link
Copy Markdown

Fixes #4156

Problem

When a model issues several transfer_task calls in one API response, Dispatcher.Process runs them in parallel goroutines via concurrent.MapSlice. Each goroutine re-resolved its caller through resolveSessionAgent, which reads the shared agentRouter.current that a sibling's swapCurrentAgent had already mutated:

T1: resolveSessionAgent(sess) -> "orchestrator"  -> validates OK
    runForwarding -> swapCurrentAgent -> current = "drafter"
T2: resolveSessionAgent(sess) -> "drafter"       <- reads the mutated value
    validateAgentInList("drafter", "drafter", ...) -> FAIL

The reported symptom is the loud one:

Agent drafter cannot transfer task to drafter: target agent not in sub-agents list.
No agents are configured in this list.

There is a quieter consequence too. runForwarding only pins the child session when the parent is already pinned, so a foreground delegation's child stays unpinned and loop.go re-resolves its agent from current on every iteration. With the deferred restores interleaving, a child could execute turns with another sibling's identity, toolset and model — no error, wrong output. validateDelegation inherits the same wrong caller, which can also produce a spurious delegation cycle detected.

atomic.Pointer keeps current free of data races, but it cannot make the read → validate → mutate → read sequence serializable.

Fix

Two orthogonal changes.

1. Resolve the caller from the batch snapshot. Dispatcher.Process already resolves the caller exactly once, before the fan-out, and stores it on the call that backs every handler's tools.Runtime. It just was not reachable. callRuntime now exposes it, and LocalRuntime.callerAgent prefers it over session resolution, falling back for hosts that provide no snapshot. No handler signature changes — rt tools.Runtime was already a parameter of ToolHandlerFunc.

2. Let one delegation own the shared current agent. runForwarding claims it with a CAS; a sibling that loses the claim pins its child instead of stomping the swap. This is the same isolation mode background delegations already use.

run_skill shares runForwarding and had the same latent defect when it landed in a batch alongside transfer_task; the same change fixes it.

Behavior change

With a single transfer_task in the batch — the common case — behavior is unchanged: the swap happens, the child stays unpinned, and the existing foreground tests pass untouched.

With N parallel transfers, the 2nd..Nth child is pinned. Pinned children do not take the handoff / force_handoff path (loop.go guards on sess.AgentName == ""). That is a pre-existing property of pinning, now reachable in a new scenario, and it is documented on SwitchCurrentAgent. If you would rather have no variance between the single and parallel cases, the alternative is to pin every child whenever the batch holds more than one delegation — same trade, but it gives up the untouched single-transfer path.

Out of scope

run_background_agent resolves its caller the same way, but agenttool.Handler.HandleRun does not receive a tools.Runtime, so fixing it means changing a signature outside pkg/runtime. Left for a follow-up; its children are already pinned, which bounds the impact.

Testing

Two regression tests, both verified to fail with the source changes reverted:

  • TestTransferTask_UsesBatchCallerSnapshotNotSharedCurrentAgent — deterministic, reproduces the exact error string from the issue.
  • TestTransferTask_ConcurrentForegroundTransfersStayIsolated — end-to-end through RunStreamDispatcher.Processconcurrent.MapSlice, with three transfer_task calls in one assistant response. Without the fix two of the three children return no output and the race detector fires on a shared test stream, which is the cross-wiring made visible: two children had resolved to the same agent. With the fix it is stable over 20 runs under -race.
go build ./...                    Success
go test -race ./pkg/runtime/...   1141 passed
go test ./...                     6003 passed, 90 packages
golangci-lint run                 0 issues
go run ./lint .                   1929 files, no offenses
go mod tidy --diff                clean

task lint / task test were run as their underlying commands, since task is not installed locally.

@bernard-code-lab
bernard-code-lab requested a review from a team as a code owner September 6, 2026 20:52
@aheritier aheritier added area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only. status/needs-signed-commits Some commits in the PR are signed with a valid SSH/GPG key labels Sep 6, 2026
@aheritier

Copy link
Copy Markdown
Collaborator

👋 Some commits in this PR are not signed and verified by GitHub. Please sign your commits with a GPG or SSH key registered in your GitHub account, then force-push.

Commits that are not verified: 7b3dc36

See GitHub's guide on signing commits for setup instructions. I've added status/needs-signed-commits; it will be removed automatically once every commit in this PR carries a valid GitHub-verified signature.

When a model issues several transfer_task calls in one response, the
dispatcher runs them in parallel goroutines. Each one re-resolved its
caller from the shared current agent that a sibling's swapCurrentAgent
had already mutated, so the later calls validated the target against the
wrong caller and failed with "target agent not in sub-agents list", and
a child that did start could run its turns as another sibling's agent.

Resolve the caller from the snapshot Dispatcher.Process already takes
before the fan-out, exposed on the per-call tools.Runtime handle, and let
only one delegation at a time own the shared current agent — siblings
that lose the claim pin their child session instead, the isolation mode
background delegations already use.

run_skill shares runForwarding and had the same latent defect when it
landed in a batch alongside transfer_task; it is fixed by the same change.

run_background_agent resolves its caller the same way, but agenttool's
HandleRun does not receive a tools.Runtime, so that path is left for a
follow-up. Its children are already pinned, which bounds the impact.

Fixes docker#4156

Signed-off-by: bernard-code-lab <rafaelc869@gmail.com>
@bernard-code-lab
bernard-code-lab force-pushed the fix/4156-concurrent-transfer-task branch from 7b3dc36 to b6fdcaf Compare September 7, 2026 00:24
@aheritier aheritier removed the status/needs-signed-commits Some commits in the PR are signed with a valid SSH/GPG key label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

transfer_task is not concurrency-safe: parallel calls race on current-agent pointer, later calls fail with "No agents are configured in this list"

2 participants