Do not run a client tool twice when a tool call is readied twice - #330683
Do not run a client tool twice when a tool call is readied twice#330683Ryan Ewen (RyanEwen) wants to merge 3 commits into
Conversation
`_executeClientTool` is driven once per `ChatToolCallReady`, and a client tool call can receive two: one from the permission flow (carrying confirmation options, `preApproved` unset) and one from the agent's stream mapper on `content_block_stop` (`confirmed: not-needed`, pre-approved). The existing `startedClientToolCalls` guard cannot prevent the second run. It is armed from `markInvocationStarted`, which `_executeClientTool` only calls after awaiting `resolveToolInput`, so both readies pass the started check before either arms it. The two requests also differ (only in `preApproved`), so the `equals(observedRequest, request)` check treats the second as a new request rather than a duplicate. Observed in a dev container: the same `callId` invoked twice in the same millisecond from the same call site, once with `preApproved` undefined and once with `preApproved` set. The tool runs, and the turn then hangs indefinitely because only one of the two invocations is tracked. Arm a separate in-flight set synchronously when execution starts and clear it when the execution settles, so a second ready for a call that is already running is ignored while genuine retries still work.
There was a problem hiding this comment.
Pull request overview
Prevents duplicate client-tool execution when concurrent ready actions arrive.
Changes:
- Tracks in-flight client-tool calls synchronously.
- Suppresses execution while the call remains active.
- Clears tracking after execution settles.
Suppressed comments (2)
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:2333
- This call-wide guard also suppresses legitimate superseding attempts while
resolveToolInputis still pending. The existingsupersedes a hung referenced input read when the request changestest updates the same call from a never-resolving input URI to a second URI; the first attempt adds this key, so the update now returns here and the second input is never read or invoked. Track the active request/attempt and coalesce only duplicate ready variants (for example, equivalent execution payloads ignoring approval metadata) instead of blocking every request sharing the call key.
if (startedClientToolCalls.has(key) || inFlightClientToolCalls.has(key)) {
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:2356
- Add a regression test for the pre-
markInvocationStartedrace: keep referenced input resolution pending, deliver the two ready variants with different approval metadata, and assert exactly one invocation while preserving a genuine retry after settlement. The existing tests do not exercise this timing window.
inFlightClientToolCalls.add(key);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@microsoft-github-policy-service agree |
…rking The previous call-wide in-flight guard also suppressed legitimate superseding attempts. `supersedes a hung referenced input read when the request changes` drives the same call from a never-resolving input URI to a second one; the first attempt claimed the call key, so the update returned early and the second input was never read. Track the execution signature (tool name and tool input, ignoring approval metadata) rather than the call key alone. A repeat of the same execution is ignored, while a changed one still supersedes. Each attempt clears only its own entry so a superseding attempt is not lost. Add the regression test for the timing window the existing tests miss: both readies delivered while the referenced input read is still pending, asserting exactly one invocation.
|
Related: these three share a root cause. Noting it so they can be triaged together rather than as three unrelated patches. The workbench executes a client tool off the stream-mapper
If client tools executed only on the SDK-driven invocation, with the stream-mapper ready used for rendering only, all three would be structurally impossible: one authoritative trigger, never without real input, and registration ordered before execution. I have not proposed that as a PR because I do not know whether the stream-mapper ready is deliberately actionable. Starting the tool as soon as its arguments finish streaming, instead of waiting for the SDK round trip, is a plausible latency win, and the permission flow may already depend on the current ordering. That is a call for whoever owns the design. Each of the three fixes a reproducible bug on its own and carries a test that fails without it, so they are safe to land in the meantime. If the structural fix is preferred, #330683 and #330684 can reasonably be closed in its favour; #330730 is worth keeping either way, since buffering a result that arrives before its handler is the same defensive behaviour |
Problem
A client tool call can receive two
ChatToolCallReadyactions: one from the permission flow (carrying confirmation options,preApprovedunset) and one from the agent's stream mapper oncontent_block_stop(confirmed: not-needed, pre-approved)._executeClientToolis driven once per ready, so the tool runs twice.The existing
startedClientToolCallsguard cannot prevent this. It is armed frommarkInvocationStarted, which_executeClientToolonly calls after awaitingresolveToolInput, so both readies pass the started check before either arms it. The two requests also differ (only inpreApproved), so theequals(observedRequest, request)check treats the second as a new request rather than a duplicate.Symptom
The turn hangs indefinitely. The tool visibly succeeds and
chat/toolCallCompleteis reported, but only one of the two invocations is tracked, so the turn is never closed and nothing is logged.User-visible variants of the same double dispatch: a browser page opening before the approval prompt is answered, and answering it then opening a second one.
Evidence
Captured by logging a stack at
LanguageModelToolsService#invokeToolin a dev container window:Same call id, same call site, same millisecond, differing only in approval state.
Fix
Arm a separate in-flight set synchronously when execution starts, and clear it when the execution settles. A second ready for a call that is already running is ignored, while genuine retries still work because the set is cleared on completion.
Verification
With the change applied, each tool call is invoked exactly once and turns complete:
Reproduced originally in a compose dev container on Windows (VS Code 1.133.0). Local windows were unaffected.