Skip to content

fix: tolerate invalid tool-call JSON in language model wrapper (fixes #322123)#322127

Open
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
errors-fix/322123-invalid-tool-call-json-a47ee17bd69c919e
Open

fix: tolerate invalid tool-call JSON in language model wrapper (fixes #322123)#322127
vs-code-engineering[bot] wants to merge 1 commit into
mainfrom
errors-fix/322123-invalid-tool-call-json-a47ee17bd69c919e

Conversation

@vs-code-engineering

Copy link
Copy Markdown
Contributor

Summary

The bundled Copilot Chat extension throws Error('Invalid JSON for tool call') from the language-model response wrapper when an Anthropic model streams malformed JSON for a tool call's arguments. The throw happens inside an async "finished" callback that the streaming layer invokes fire-and-forget, so it surfaces as an unhandled promise rejection in error telemetry rather than cleanly aborting the request. Impact: noisy unhandlederror reports and a dropped tool call whenever the model emits invalid argument JSON, even though every sibling consumer of the same data already tolerates it.

Fixes #322123
Recommended reviewer: @bhavyaus

Culprit Commit

Not identified — pre-existing. The error-telemetry history for this bucket shows non-zero hits in every released extension version the data covers (0.50.0 through the affected 0.53.0), with the earliest occurrence in early June 2026. The throw predates the window that opened this issue and no single commit changed the triggering logic — this is a long-standing error-handling flaw (a re-bucketing of the same underlying condition), so the owner is taken from the feature area rather than a commit author.

Code Flow

sequenceDiagram
    participant Model as Anthropic model
    participant Stream as messagesApi streaming
    participant Wrapper as languageModelAccess callback
    participant Telemetry as Error telemetry

    Model->>Stream: streams partial_json deltas - malformed
    Note over Stream: Concatenates deltas faithfully -<br/>reconstruction is correct, not buggy
    Stream->>Wrapper: tool call arguments = invalid JSON
    Note over Wrapper: ⚠️ Root cause: JSON.parse throws inside<br/>a fire-and-forget finished callback
    Wrapper-->>Telemetry: 💥 Unhandled rejection -<br/>Invalid JSON for tool call
Loading

Affected Files

File Role Evidence
extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts crash site + fix L839-L848 (stack points at L843): the catch clause logged the error then throw new Error('Invalid JSON for tool call') inside the fire-and-forget finished callback
extensions/copilot/src/platform/endpoint/node/messagesApi.ts input source — faithful reconstruction (not buggy) Streaming accumulator concatenates input_json_delta.partial_json into copilotToolCalls[].arguments; the assembled bytes match exactly what the model sent. The outbound path at L287-L292 already tolerates the same condition ("Keep empty object if parse fails")
extensions/copilot/src/extension/agents/node/langModelServer.ts sibling consumer precedent L224: try { input = call.arguments ? JSON.parse(call.arguments) : {}; } catch { input = {}; }
extensions/copilot/src/extension/intents/node/toolCallingLoop.ts sibling consumer precedent L1590: normalizes empty args to '{}' and defers parsing rather than throwing

Repro Steps

This is non-deterministic — it requires an Anthropic model to stream syntactically invalid JSON in a tool call's arguments. To reproduce/observe:

  1. Issue a chat request routed to an Anthropic model (e.g. a Claude endpoint) that triggers tool calling.
  2. Have the model stream a tool call whose accumulated input_json_delta.partial_json is not valid JSON (a truncated or garbled argument stream). This happens intermittently under real traffic.
  3. provideLanguageModelResponse's finished callback hits the JSON.parse catch branch and throws.
  4. Because the callback is invoked fire-and-forget by the streaming layer, the throw escapes as an unhandled promise rejection reported as unhandlederror, and the tool call is dropped.

To force it deterministically in a test, make the endpoint emit a tool call with arguments set to a malformed string such as '{"a":'.

How the Fix Works

Chosen approach — languageModelAccess.ts (here the crash site is also the correct fix site, because the bad data originates outside our code):

The arguments string is produced by the model, an external/untrusted boundary — there is no internal producer to correct (the messagesApi accumulator faithfully reconstructs exactly what the model sent). The established convention across every other consumer of this same field is to tolerate invalid JSON and fall back to empty parameters. The change touches only the existing catch clause:

  • Keeps the this._logService.error(err, ...) diagnostic, so the malformed-JSON condition is still surfaced to logging/telemetry — the violation is not silenced.
  • Removes the throw new Error('Invalid JSON for tool call'). That throw lived inside an async fire-and-forget finished callback, so it never cleanly aborted the response — it only produced an unhandled rejection. Removing it loses no real error handling.
  • Falls back to parameters = {} and still calls progress.report(new LanguageModelToolCallPart(...)), so the tool call is surfaced to the consuming extension and the tool_use/tool_result pairing is preserved (silently dropping the tool call would desync the Anthropic conversation contract).

This follows the fix-at-the-trust-boundary principle: handle untrusted external input where it crosses into typed code, while keeping the diagnostic log rather than masking it. It mirrors three sibling consumers of the same data — langModelServer.ts (catch { input = {} }), messagesApi.ts outbound ("Keep empty object if parse fails"), and toolCallingLoop.ts (normalizes/defers instead of throwing).

After this change, the finished callback can no longer emit an unhandled Error('Invalid JSON for tool call'): the JSON.parse failure path logs the error and assigns parameters = {} instead of throwing, so progress.report always receives a valid object.

Alternatives considered:

  • Skip the tool call entirely on parse failure — rejected: dropping a tool_use without a matching tool_result desyncs the Anthropic message contract and can wedge the conversation; reporting {} keeps the pairing intact.
  • Await the finished callback so the rejection is catchable upstream — rejected: the fire-and-forget invocation is shared streaming infrastructure, so changing its await semantics is a broader, riskier change that would still need a parse-failure policy at this same spot.

Recommended Owner

@bhavyaus — feature-area owner for Anthropic model integration (feature-area fallback, used because the bug is pre-existing with no culprit commit). Liveness gate: PASS (recent commits through mid-June 2026, including Anthropic messages-API work). Team-membership gate: PASS (named area owner who authors changes into core and is on the team). @bryanchen-d is the secondary owner for this area and the current issue assignee. The prior area owner was excluded because that working-areas entry is intentionally empty pending handover.

Generated by errors-fix · 3.4K AIC · ⊞ 69.3K ·

…322123)

The model can stream malformed JSON for tool-call arguments. The language model wrapper threw a bare Error from a fire-and-forget finished callback, producing an unhandled rejection in error telemetry without cleanly aborting the response. Align with the other tool-call consumers (langModelServer, messagesApi) by logging the diagnostic and falling back to empty parameters so the tool call is still surfaced to the consuming extension.
Copilot AI review requested due to automatic review settings June 19, 2026 16:17

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@vs-code-engineering vs-code-engineering Bot marked this pull request as ready for review June 19, 2026 16:19
@vs-code-engineering vs-code-engineering Bot enabled auto-merge (squash) June 19, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Error] [GitHub.copilot-chat] unhandlederror-Invalid JSON for tool call

2 participants