Skip to content

CRITICAL: truncated tool call permanently breaks the session — cleanup path unreachable, generated content destroyed #20

Description

@ScrewTSW

Note: this issue was filed under a wrong theory and revised several times as evidence came
in. The body below is the corrected account. Earlier comments contain retracted claims — in
particular that a corrupt file was written to disk (it was not; the file in question was
created manually by the user). See the retraction comment for what was withdrawn.

Summary

When a tool call's arguments are truncated mid-stream, Continue offers the incomplete call to
the user for acceptance
. Accepting it fails with a JSON parse error, nothing is applied, and
the generated content is recoverable only by copy-pasting from the chat transcript.

Observed 3× while generating a single large Python file.

Sequence

tool-call arguments truncated mid-stream
  → call promoted to `generated` WITHOUT validating arguments
  → surfaced in the UI as a normal, acceptable pending write
  → user clicks Accept
  → JSON.parse fails: "invalid string: missing closing quote"
  → nothing applied; no file created
  → content lost from the actionable path (still visible in transcript)

The user ultimately created the file by hand from the response block.

Two triggers, one defect

Truncation arises two ways, and they are currently indistinguishable:

Trigger signal.aborted Notes
Output window exhausted false Reasoning + prose + tool argument share one budget
User cancels generation true Reproduced at 879 bytes — nothing near any limit

The cancellation case is what proves this is not a context-window bug. Same error, same
mid-string cut, at a fraction of the size.

The exhaustion boundary also moves, because the argument gets whatever the reasoning and
prose leave behind:

effective_limit = window − (reasoning + prose)

Observed truncation columns: 31,447 → 31,395 → 879. Any test asserting a fixed byte
threshold will be flaky.

Root causes (verified in code)

1. core/tools/parseArgs.ts:17-24 — parse failure swallowed

try {
  return JSON.parse(toolCall.function?.arguments?.trim() || "{}");
} catch (e) {
  //console.error(...)   // diagnostic commented out
  return {};
}

{} conflates "no arguments" with "arguments were corrupt". Shared by Anthropic, Gemini,
Bedrock and callTool.ts.

2. gui/src/redux/slices/sessionSlice.ts:951-973setToolGenerated validates nothing

if (toolCallState) {
  toolCallState.status = "generated";   // unconditional

This is what makes a truncated call eligible for the approval UI. callToolById.ts:33-34
correctly refuses anything not generated — but the status claims generated, so it is admitted
and fails downstream, after the user has committed.

3. finish_reason never reaches the tool layer

Absent from core/index.d.ts; read once in openaiTypeConverters.ts:410 and dropped. So
exhaustion, cancellation, and a genuinely malformed model response are indistinguishable at the
point of decision — though only the first is resumable.

4. gui/src/redux/slices/sessionSlice.ts:581-584abortStream leaves calls in-flight

Aborts the controller and replaces it without marking in-flight tool calls. A cancelled call
keeps status: "generating" indefinitely. (Same shape as the reasoning-span bug fixed in #16
an in-flight state no path closes on cancellation.)

Not a security issue

Verified: a cancelled tool call cannot execute. callToolById.ts:33-34 hard-returns unless
status === "generated", and streamNormalInput.ts:300 returns early on abort. An interrupted
python3 << 'EOF' … payload could not have run.

Desired behaviour

Handle the two triggers differently, because the user's intent differs:

Cancellation — deliberate. Mark generatingcanceled. Never parse, never execute, never
offer for acceptance. Render as interrupted, not failed.

Exhaustion — accidental. The work exists and is visibly correct. Salvage it:

  • recover completed keys plus the partial trailing string value
  • require a fully-parsed destination path (never guess)
  • present as incomplete, with Continue / Discard / Keep — never a plain Accept that must fail
  • on Continue, re-prompt with the tail of the recovered content so the model appends at the right
    boundary rather than regenerating
  • cap resume attempts so an unattended agent cannot loop

Fix order

  1. setToolGenerated must refuse to promote unparseable arguments — stops the bad acceptance
    offer at its source
  2. parseArgs must surface a typed failure instead of {}
  3. Plumb finish_reason to the tool layer
  4. Salvage + user-gated resume (highest user-facing value)
  5. abortStream marks generatingcanceled

Tests

  • Truncated args → call is never offered as acceptable
  • Cancel mid-argument → status canceled, no execution, no side effects
  • Exhaustion + valid path → salvage offered with the recovered tail
  • Malformed JSON with finish_reason: stop → clear error, not resumable
  • Resume appends without duplicating the boundary region
  • No fixed byte threshold — the boundary moves with reasoning length

Environment

Fork main @ 63bc4ef9d; observed on a local model via the orchestrator.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions