Skip to content

feat: run tool dispatcher calls in parallel#3467

Merged
dgageot merged 1 commit into
docker:mainfrom
rumpl:feat/parallel-tool-dispatch
Jul 4, 2026
Merged

feat: run tool dispatcher calls in parallel#3467
dgageot merged 1 commit into
docker:mainfrom
rumpl:feat/parallel-tool-dispatch

Conversation

@rumpl

@rumpl rumpl commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Run tool calls from a model batch concurrently in the toolexec dispatcher
  • Serialize interactive confirmations while keeping auto-approved calls parallel
  • Add concurrency-safe dispatcher tests and docs updates

Validation

  • go test ./pkg/runtime/toolexec
  • go test -race ./pkg/runtime/toolexec
  • go test -race ./pkg/runtime
  • task lint
  • task --force build
  • env -u DEEPSEEK_API_KEY -u CEREBRAS_API_KEY -u FIREWORKS_API_KEY -u TOGETHER_API_KEY -u HF_TOKEN -u MOONSHOT_API_KEY -u AI_GATEWAY_API_KEY -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_ACCOUNT_ID -u CLOUDFLARE_GATEWAY_ID task test

Note: a plain task test initially hit a live Moonshot alias API 404 because MOONSHOT_API_KEY was set locally; rerunning with live-provider env vars unset passed.

@rumpl rumpl requested a review from a team as a code owner July 4, 2026 00:09

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The parallelization design is solid. Each tool call gets its own *call struct (no shared per-call mutable state), session.AddMessage is internally goroutine-safe, outcomes[i] writes are to distinct indices, and the two-mutex approach (confirmationMu for serializing interactive confirmations, approvalMu for session permissions) is consistent with no lock-ordering inversions.

Specific correctness points verified:

  • stopOnce correctly prevents double-cancel; even if errBatchCanceledByUser fires before errBatchStoppedByHook, the outcomes loop after wg.Wait() still finds StopRun: true and returns (true, stopMessage) — the StopRun return value is never lost.
  • cancellationOutcome returning CallOutcome{} for hook-stopped siblings is handled correctly; the post-loop only checks StopRun, so empty outcomes are correctly skipped.
  • Lock ordering is consistent: the only nesting is confirmationMu → approvalMu (inside autoApprovalAfterConfirmationWait), and there is no path that acquires approvalMu first then confirmationMu.
  • Loop variable capture is safe under Go 1.22+ per-iteration semantics (confirmed by wg.Go API usage).

One minor latency note left inline.

}

slog.DebugContext(ctx, "Tools not approved, waiting for resume", "tool", c.tc.Function.Name, "session_id", c.sess.ID)
c.em.EmitToolCallConfirmation(c.tc, c.tool, c.a.Name(), c.confirmationMetadata(hookMeta))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] confirmationMu held across EmitToolCallConfirmation and NotifyUserInput

confirmationMu is acquired at line 657 and not released until one of the select case arms fires (lines ~683–687). This means it is held across EmitToolCallConfirmation (line 675) and NotifyUserInput (line 678), which can dispatch hooks and emit events — potentially slow operations.

Under concurrent batches, any tool call that needs user confirmation will block at confirmationMu.Lock() until the current confirmation completes (including the hook dispatch latency). This is a minor latency concern rather than a correctness bug — the serialization is intentional and no deadlock is possible since hook dispatchers don't acquire confirmationMu. Consider whether the hook calls could be moved before the lock acquisition if they don't need to be serialized.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The lock has to cover the confirmation event and wait because ResumeRequest is not keyed by tool-call ID; allowing a second confirmation to become visible before the first resolves would make delivery on the shared resume channel ambiguous. I added a short comment at the lock site to document that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification — that makes complete sense. Since ResumeRequest isn't keyed by tool-call ID, the serialization is architecturally required to prevent routing ambiguity on the shared channel. The comment you added documents that constraint nicely.

Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
@rumpl rumpl force-pushed the feat/parallel-tool-dispatch branch from 6c58412 to f47e336 Compare July 4, 2026 00:15
@aheritier aheritier added area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jul 4, 2026
if c.preYoloResult != nil && c.preYoloResult.Decision == hooks.DecisionAsk {
return PermissionDecision{}, false
}
decision := c.permissionDecision(c.tool.Annotations.ReadOnlyHint)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing readOnlyHint=true here re-applies the read-only fast-path inside askUser and silently skips a confirmation a pre_tool_use hook requested on a read-only tool. Pass false to match the deliberate Decide call in approveAndRun. A regression test where a read-only tool plus a hook returning Ask still prompts would guard this.

@dgageot dgageot merged commit 3de164f into docker:main Jul 4, 2026
11 checks passed
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/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants