Skip to content

partial-builder: malformed tool arguments emitted as healthy toolcall_end — no error signal downstream #2574

@igouss

Description

@igouss

Problem

When the API stream is truncated mid-tool-call, `PartialMessageBuilder` silently emits a `toolcall_end` event with `arguments: { _raw: "" }`. Downstream consumers (GSD tool handlers, TTSR, activity log) receive what looks like a completed tool call with valid structure. There is no signal that the arguments are garbage.

Root Cause

File: `src/resources/extensions/claude-code-cli/partial-builder.ts`, lines 243–249

// content_block_stop handler
if (block.type === "toolCall") {
    const jsonStr = this.toolJsonAccum.get(streamIndex) ?? "{}";
    try {
        block.arguments = JSON.parse(jsonStr);
    } catch {
        block.arguments = { _raw: jsonStr };  // ← silent fallback, looks healthy
    }
    return { type: "toolcall_end", contentIndex, toolCall: block, partial: this.partial };
    //       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //       emitted as a normal toolcall_end with no error indicator
}

The catch block stores the raw string in a well-known key (`_raw`) but emits the same `toolcall_end` event type as a successful parse. No consumer can distinguish a truncated tool call from a completed one without inspecting every argument object for the `_raw` sentinel.

Downstream Consequences

  1. GSD's classifyProviderError() receives the JSON syntax error string from wherever pi ultimately surfaces it, doesn't match any transient pattern, and pauses auto-mode permanently (filed separately as classifyProviderError treats stream-truncation JSON parse errors as permanent — should be transient #2572).

  2. Tool handlers receiving `{ _raw: "..." }` either crash with a confusing validation error, silently no-op, or — for write-path tools like `gsd_plan_slice` — could attempt to persist malformed data.

  3. TTSR rule matching in `src/resources/extensions/ttsr/index.ts:66` checks for `"partialJson" in contentBlock` and reads partial path hints — it operates on the streaming delta, not the finalized arguments, so it won't see `_raw`. Consistent labeling would make this pattern unnecessary.

  4. Activity log (via `session-forensics.ts`) records the tool call with `arguments: {}` (the initial empty object) and the `partialJson` field, making recovery briefings misleading about what the last tool call was actually trying to do.

Expected Behavior

When argument JSON fails to parse at `content_block_stop`, the event should signal failure explicitly. Two options:

Option A — dedicated event type (preferred):

} catch (e) {
    return {
        type: "toolcall_error",
        contentIndex,
        toolCall: block,
        partial: this.partial,
        reason: "malformed_arguments",
        rawJson: jsonStr,
    };
}

Option B — flag on existing event:

} catch (e) {
    block.arguments = {};
    return {
        type: "toolcall_end",
        contentIndex,
        toolCall: block,
        partial: this.partial,
        malformed: true,        // ← explicit signal
        rawArguments: jsonStr,
    };
}

Option A is cleaner — it allows consumers to handle the case without inspecting every `toolcall_end` payload. Option B is a smaller diff.

Either way, consumers (stream-adapter, GSD) should propagate this as a stream-level error rather than a completed tool call.

Relation to Existing Issues

Environment

  • GSD version: 2.49.0
  • Model: claude-sonnet-4-6
  • Observed in: activity log `038-plan-slice-M001-axr2oo-S06.jsonl`, final assistant message

Auto-generated by `/gsd forensics`

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions