Skip to content

Synthetic 'missing tool result' entries injected for parallel tool calls on Anthropic Claude, despite real results being produced #88168

Description

@jhartman00

Summary

Inside an active session, a meaningful percentage of tool calls (most reliably exec / read in parallel batches) return the synthetic string [openclaw] missing tool result in session history; inserted synthetic error result for transcript repair. instead of the real result. The tools actually ran (verified: side effects landed). Single-call turns succeed; parallel batches of 2-4 calls fail reliably.

Version

openclaw 2026.5.27 (reproducible on HEAD per source review)
Model: anthropic/claude-opus-4-7 (anthropic-messages API)
Channel: webchat (main agent) — but the bug is in session-tool-result-guard.ts so any channel is affected.

Root cause

installSessionToolResultGuard tracks pending tool calls in an in-memory Map<tool_use_id, toolName>. shouldFlushBeforeNewToolCalls returns pending.size > 0 && toolCallCount > 0, which means any incoming assistant message with new tool calls flushes all still-pending IDs as synthetic errors — including IDs whose real appendMessage(toolResult) is still in flight.

This creates a race between the runner's per-tool result-append path and the next assistant turn's append. The persisted JSONL ends up with tool_use(id=X)tool_result(id=X, isError=true, "missing tool result…") while the real result is dropped as an orphan by repairToolUseResultPairing on the next request.

Repro

  1. Open a session against an Anthropic Claude model.
  2. Ask the model to perform 3+ exec or read calls in one turn (must end up as a single assistant message with parallel tool_use blocks).
  3. Inspect the session JSONL: at least one synthetic-error tool_result will appear, with no matching real result anywhere in the file.

Why it durably corrupts the transcript

Once a synthetic tool_result is persisted, the tool_use it claims to resolve is "satisfied" from the guard's perspective. On the next turn, repairToolUseResultPairing drops any late-arriving real result for that ID as an "orphan" (droppedOrphanCount += 1). The synthetic stays.

Proposed fix

Defer the flush triggered by shouldFlushBeforeNewToolCalls until the runner has positively confirmed all pending tool results were attempted (success, error, or timeout) — not when a new assistant turn merely arrives. A minimal band-aid is to make the guard tick-aware (don't flush within the same tick as trackToolCalls), but the structural fix is in the runner: enforce strict result-then-next-turn ordering at the sessionManager.appendMessage boundary.

Minimum-blast-radius diff:

--- a/src/agents/session-tool-result-state.ts
+++ b/src/agents/session-tool-result-state.ts
@@
 export function createPendingToolCallState() {
   const pending = new Map<string, string | undefined>();
+  let lastTrackTimestamp = 0;
   return {
     trackToolCalls: (calls: Array<{ id: string; name?: string }>) => {
       for (const call of calls) pending.set(call.id, call.name);
+      lastTrackTimestamp = Date.now();
     },
-    shouldFlushBeforeNewToolCalls: (toolCallCount: number) =>
-      pending.size > 0 && toolCallCount > 0,
+    shouldFlushBeforeNewToolCalls: (toolCallCount: number) => {
+      if (pending.size === 0 || toolCallCount === 0) return false;
+      // If a brand-new assistant tool-use turn arrives within the
+      // same-tick window as pending tool calls were tracked, we are
+      // almost certainly racing the result-append for parallel calls.
+      return Date.now() - lastTrackTimestamp > 0;
+    },
   };
 }

This is a defensive band-aid, not the real fix. The real fix is in the runner: ensure appendMessage(toolResult) for every spawned tool completes (or errors) before any subsequent appendMessage(assistant) is attempted.

Related observation

The synthetic placeholder mentions "transcript repair," which misleads operators into investigating transport-layer bugs. Consider distinguishing between "truly missing" and "explicitly denied" or "explicit timeout" cases in the placeholder text, and emitting a structured log line so operators can correlate.

Related issue

Separately filed: approval-gate denials routed via followup-channel produce phantom synthetic placeholders (a different code path that produces the same surface error).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:needs-live-reproClawSweeper needs live local, crabbox, or manual validation to confirm this issue.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🐚 platinum hermitGood issue quality with a plausible reproduction path needing some confirmation.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions