From be71d53842e7c7273288d4308eaa6ffdcc5ffe0f Mon Sep 17 00:00:00 2001 From: DarkPhilosophy <19309990+DarkPhilosophy@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:08:47 +0300 Subject: [PATCH] fix(coding-agent): optimize orphaned tool-use stop guard --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 42 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0243ed841..321d5264a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Anthropic empty `toolUse` stops without tool calls corrupting session history by retrying them and removing orphaned turns even at the retry cap. + ## [15.10.1] - 2026-06-07 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f4558c2fd..deb63f71a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -283,6 +283,11 @@ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; export type AsyncJobSnapshotItem = Pick; const EMPTY_STOP_MAX_RETRIES = 3; +const NON_WHITESPACE_RE = /\S/; + +function hasNonWhitespace(value: string): boolean { + return NON_WHITESPACE_RE.test(value); +} export interface AsyncJobSnapshot { running: AsyncJobSnapshotItem[]; @@ -6558,25 +6563,26 @@ export class AgentSession { } #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean { - const { stopReason } = assistantMessage; - if (stopReason !== "stop" && stopReason !== "toolUse") return false; - - // Single pass over content; the three flags cover every emptiness rule below. - let hasText = false; - let hasThinking = false; - let hasToolCall = false; - for (const content of assistantMessage.content) { - if (content.type === "text") hasText ||= content.text.trim().length > 0; - else if (content.type === "thinking") hasThinking ||= content.thinking.trim().length > 0; - else if (content.type === "toolCall") hasToolCall = true; + switch (assistantMessage.stopReason) { + case "stop": + for (const content of assistantMessage.content) { + if (content.type === "toolCall") return false; + if (content.type === "text" && hasNonWhitespace(content.text)) return false; + if (content.type === "thinking" && hasNonWhitespace(content.thinking)) return false; + } + return true; + case "toolUse": + // An orphaned toolUse stop (no tool_use block) corrupts Anthropic history: + // a later tool_result has nothing to anchor to. Thinking alone cannot anchor + // a tool_result, so it does not rescue a toolUse stop here. + for (const content of assistantMessage.content) { + if (content.type === "toolCall") return false; + if (content.type === "text" && hasNonWhitespace(content.text)) return false; + } + return true; + default: + return false; } - - // An orphaned toolUse stop (no tool_use block) corrupts Anthropic history: - // a later tool_result has nothing to anchor to. Thinking alone cannot anchor - // a tool_result, so it does not rescue a toolUse stop here. - if (stopReason === "toolUse") return !hasText && !hasToolCall; - // A plain stop is empty only when it carries no usable content at all. - return !hasText && !hasThinking && !hasToolCall; } #emptyStopRetryReminder(): string {