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
- Open a session against an Anthropic Claude model.
- 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).
- 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).
Summary
Inside an active session, a meaningful percentage of tool calls (most reliably
exec/readin 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
openclaw2026.5.27 (reproducible on HEAD per source review)Model:
anthropic/claude-opus-4-7(anthropic-messagesAPI)Channel: webchat (main agent) — but the bug is in
session-tool-result-guard.tsso any channel is affected.Root cause
installSessionToolResultGuardtracks pending tool calls in an in-memoryMap<tool_use_id, toolName>.shouldFlushBeforeNewToolCallsreturnspending.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 realappendMessage(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 byrepairToolUseResultPairingon the next request.Repro
execorreadcalls in one turn (must end up as a single assistant message with paralleltool_useblocks).tool_resultwill appear, with no matching real result anywhere in the file.Why it durably corrupts the transcript
Once a synthetic
tool_resultis persisted, thetool_useit claims to resolve is "satisfied" from the guard's perspective. On the next turn,repairToolUseResultPairingdrops any late-arriving real result for that ID as an "orphan" (droppedOrphanCount += 1). The synthetic stays.Proposed fix
Defer the flush triggered by
shouldFlushBeforeNewToolCallsuntil 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 astrackToolCalls), but the structural fix is in the runner: enforce strict result-then-next-turn ordering at thesessionManager.appendMessageboundary.Minimum-blast-radius diff:
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 subsequentappendMessage(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).