Skip to content

HTTP 400: orphaned tool_result after errored/aborted assistant message dropped by transformMessages #10932

@ikennaokpala

Description

@ikennaokpala

Bug Description

When a conversation includes an assistant message with stopReason === "error" or "aborted" that contained toolCall blocks, subsequent API calls to Anthropic fail with:

HTTP 400 invalid_request_error: messages.110.content.1: unexpected tool_use_id found
in tool_result blocks: toolu_01Khaca7kS2D7fr6zYVubgtF. Each tool_result block must
have a corresponding tool_use block in the previous message.

The session becomes permanently broken — every subsequent message triggers this error until the session is manually reset.

Root Cause Analysis

Primary Bug: transformMessages() in pi-ai drops errored assistants without cleaning up their tool results

File: @mariozechner/pi-ai/dist/providers/transform-messages.js (lines 108–110)

if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
    continue; // ← assistant dropped, but its toolResult messages are NOT removed
}

The second pass of transformMessages() handles orphaned toolCall blocks (assistant messages missing tool results) by inserting synthetic error results. However, it does not handle the reverse case: orphaned toolResult messages whose corresponding assistant was dropped.

What happens step-by-step:

  1. During a conversation, an assistant response errors or is aborted mid-stream (network issue, rate limit, user cancellation) after it had already emitted toolCall blocks.
  2. The tools may have already started executing, producing toolResult messages in the session transcript.
  3. The assistant message is persisted with stopReason: "error" (or "aborted").
  4. On the next turn, transformMessages() processes the full history:
    • It encounters the errored assistant and drops it (continue on line 110).
    • Critically, pendingToolCalls was already cleared on line 100 (when handling the previous assistant's pending calls), and the errored assistant's tool calls are never tracked because the function returns early before reaching lines 113–117.
    • The orphaned toolResult messages pass through untouched (lines 120–122).
  5. The Anthropic message converter (anthropic.js:484–512) groups all consecutive toolResult messages into a single user message with multiple tool_result content blocks.
  6. The orphaned tool results get merged with the previous (valid) assistant's tool results.
  7. The Anthropic API sees tool_result blocks referencing tool_use_ids that don't exist in the immediately preceding assistant message → HTTP 400.

Concrete example of the message sequence:

messages[N]:   assistant A (toolCall id: "X")           → KEPT
messages[N+1]: toolResult   (toolCallId: "X")           → KEPT
messages[N+2]: assistant B  (toolCall id: "Y", stopReason: "error") → DROPPED
messages[N+3]: toolResult   (toolCallId: "Y")           → KEPT (ORPHAN!)
messages[N+4]: assistant C  (text response)             → KEPT
messages[N+5]: user         (new message)               → KEPT

After transformMessages:

result[0]: assistant A (tool_use id: "X")
result[1]: toolResult  (tool_use_id: "X")
result[2]: toolResult  (tool_use_id: "Y")  ← ORPHAN, no matching tool_use
result[3]: assistant C
result[4]: user

After Anthropic convertMessages:

API message 0: { role: "assistant", content: [{ type: "tool_use", id: "X", ... }] }
API message 1: { role: "user", content: [
    { type: "tool_result", tool_use_id: "X" },    ← valid
    { type: "tool_result", tool_use_id: "Y" }     ← INVALID - no matching tool_use
]}
API message 2: { role: "assistant", content: [...] }
API message 3: { role: "user", content: [...] }
→ HTTP 400: "unexpected tool_use_id found in tool_result blocks: Y"

Secondary Issue: transcript-sanitize extension not registered for agent loop

File: agents/pi-embedded-runner/extensions.js (function buildEmbeddedExtensionPaths)

The transcript-sanitize extension exists and correctly calls repairToolUseResultPairing() on context events. This function (in session-transcript-repair.js) properly handles orphaned tool results by dropping them. However, this extension is never registered in buildEmbeddedExtensionPaths():

export function buildEmbeddedExtensionPaths(params) {
    const paths = [];
    if (resolveCompactionMode(params.cfg) === "safeguard") {
        paths.push(resolvePiExtensionPath("compaction-safeguard"));
    }
    const pruning = buildContextPruningExtension(params);
    if (pruning.additionalExtensionPaths) {
        paths.push(...pruning.additionalExtensionPaths);
    }
    return paths;
    // ← transcript-sanitize is NEVER added to paths
}

repairToolUseResultPairing only runs once at session load time (via sanitizeSessionHistory in attempt.js:431), not during the agent loop. Even if it ran during the agent loop via the context event, it would execute before transformMessages (which runs inside pi-ai's provider layer), so it wouldn't catch orphans created by the transformMessages drop logic.

Suggested Fix

Fix 1 (in pi-ai): Clean up tool results when dropping errored/aborted assistants

In transform-messages.js, when dropping an errored/aborted assistant, track its tool call IDs and skip following tool results that reference them:

// Track IDs of tool calls from dropped (errored/aborted) assistants
const droppedToolCallIds = new Set();

// In the second pass, when processing assistant messages:
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
    // Collect tool call IDs so orphaned results can be skipped
    for (const block of assistantMsg.content) {
        if (block.type === "toolCall" && block.id) {
            droppedToolCallIds.add(block.id);
        }
    }
    continue;
}

// In the toolResult branch:
else if (msg.role === "toolResult") {
    if (droppedToolCallIds.has(msg.toolCallId)) {
        // Skip orphaned tool result from a dropped assistant
        continue;
    }
    existingToolResultIds.add(msg.toolCallId);
    result.push(msg);
}

Fix 2 (in openclaw): Register transcript-sanitize as a built-in extension

In extensions.js, always include the transcript-sanitize extension:

export function buildEmbeddedExtensionPaths(params) {
    const paths = [];
    // Always run transcript sanitization
    paths.push(resolvePiExtensionPath("transcript-sanitize"));
    
    if (resolveCompactionMode(params.cfg) === "safeguard") {
        paths.push(resolvePiExtensionPath("compaction-safeguard"));
    }
    const pruning = buildContextPruningExtension(params);
    if (pruning.additionalExtensionPaths) {
        paths.push(...pruning.additionalExtensionPaths);
    }
    return paths;
}

Note: Fix 2 alone would not fully resolve the issue because transformMessages runs after context event extensions, so orphans created by the drop logic in transformMessages would bypass the repair. Both fixes should be applied.

Environment

  • OpenClaw version: 2026.2.3-1
  • pi-ai version: 0.51.3
  • pi-agent-core version: 0.51.3
  • Provider: Anthropic (claude-opus-4-5)
  • Platform: macOS (Darwin 25.2.0)
  • Context pruning: enabled (cache-ttl, 1h TTL)
  • Compaction: safeguard mode

Steps to Reproduce

  1. Start a TUI session with an Anthropic model
  2. Have a conversation that involves tool usage
  3. Trigger an error/abort during an assistant response that has already emitted tool call blocks (e.g., network interruption, rate limit, or user cancellation mid-tool-execution)
  4. Send another message in the same session
  5. Observe the HTTP 400 error on every subsequent message

Workaround

Reset the session (e.g., /reset in the TUI) to clear the corrupted history containing the errored assistant message and its orphaned tool results.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions