From 25069ea59452d14e1d3fd6a70a64af5e644c6b20 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 15 Jun 2026 15:24:38 +0000 Subject: [PATCH 1/2] fix(agent): stopped retrying interrupted tool streams Prevented auto-retry from regenerating write calls after a provider stream timeout has already exposed assistant content or tool-call arguments. Fixes #2683 --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent-loop.ts | 21 +++- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/session/agent-session.ts | 12 +++ .../test/agent-session-retry-cap.test.ts | 101 +++++++++++++++++- 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 46f3fdf64..33f9ad432 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed transient provider errors after streamed tool-call arguments so incomplete tool calls are marked as interrupted output instead of eligible for automatic retry ([#2683](https://github.com/can1357/oh-my-pi/issues/2683)). + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index e8b9eb3a0..9a5a619e0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -63,6 +63,9 @@ import type { } from "./types"; import { yieldIfDue } from "./utils/yield"; +/** Stop-details marker for a provider error after assistant content/tool args already streamed. */ +export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content"; + /** Sentinel returned by the abort race in `streamAssistantResponse`. */ const ABORTED: unique symbol = Symbol("agent-loop-aborted"); @@ -1336,14 +1339,26 @@ function retainCompletedToolCalls( completedToolCallIds: ReadonlySet, ): AssistantMessage { if (message.stopReason !== "error" && message.stopReason !== "aborted") return message; - let changed = false; + let droppedIncompleteToolCall = false; const content = message.content.filter(block => { if (block.type !== "toolCall") return true; const keep = completedToolCallIds.has(block.id); - if (!keep) changed = true; + if (!keep) droppedIncompleteToolCall = true; return keep; }); - return changed ? { ...message, content } : message; + if (!droppedIncompleteToolCall) return message; + return { + ...message, + content, + stopDetails: + message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL + ? message.stopDetails + : { + type: STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL, + category: message.stopDetails?.type ?? null, + explanation: message.stopDetails?.explanation ?? null, + }, + }; } function emitDiscardedHarmonyPartial( diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93cfaebf9..97d1f4415 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed auto-retry regenerating a large `write` call after the provider stream timed out mid-tool-call ([#2683](https://github.com/can1357/oh-my-pi/issues/2683)). + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c40946285..b5d69fa38 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -31,6 +31,7 @@ import { AppendOnlyContextManager, type AsideMessage, resolveTelemetry, + STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; @@ -8688,6 +8689,7 @@ export class AgentSession { // Context overflow is handled by compaction, not retry const contextWindow = this.model?.contextWindow ?? 0; if (isContextOverflow(message, contextWindow)) return false; + if (this.#streamInterruptedAfterObservableOutput(message)) return false; if (this.#isClassifierRefusal(message)) return true; if (this.#isStaleOpenAIResponsesReplayError(message)) return true; @@ -8695,6 +8697,16 @@ export class AgentSession { const err = message.errorMessage; return this.#isTransientErrorMessage(err) || isUsageLimitError(err); } + #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean { + if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true; + for (const block of message.content) { + if (block.type === "toolCall") return true; + if (block.type === "text" && block.text.length > 0) return true; + if (block.type === "thinking" && block.thinking.length > 0) return true; + if (block.type === "redactedThinking" && block.data.length > 0) return true; + } + return false; + } #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean { const currentApi = this.model?.api; diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index 0461c0f43..9b46dae2c 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import type { ApiKeyResolveContext, AssistantMessage } from "@oh-my-pi/pi-ai"; +import type { ApiKeyResolveContext, AssistantMessage, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -362,6 +363,104 @@ describe("AgentSession retry delay cap", () => { expect(last.stopReason).toBe("stop"); }); + it("does not auto-retry a timeout after streaming a write tool call", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) { + throw new Error("Expected bundled Anthropic test model to exist"); + } + + let streamCalls = 0; + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: requestedModel => { + streamCalls += 1; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const partial: AssistantMessage = { + role: "assistant", + content: [], + api: requestedModel.api, + provider: requestedModel.provider, + model: requestedModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tc-write", + name: "write", + arguments: { path: "doc/report.md", content: "large report chunk" }, + }; + partial.content.push(toolCall); + stream.push({ type: "start", partial }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ + type: "toolcall_delta", + contentIndex: 0, + delta: JSON.stringify(toolCall.arguments), + partial, + }); + stream.push({ + type: "error", + reason: "error", + error: { + ...partial, + stopReason: "error", + errorMessage: "The operation timed out.", + duration: 1000, + }, + }); + }); + return stream; + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxDelayMs": 5_000, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const retryStartEvents: AutoRetryStartEvent[] = []; + const retryEndEvents: AutoRetryEndEvent[] = []; + session.subscribe(event => { + if (event.type === "auto_retry_start") retryStartEvents.push(event); + if (event.type === "auto_retry_end") retryEndEvents.push(event); + }); + + await session.prompt("Write a large report"); + await session.waitForIdle(); + + expect(streamCalls).toBe(1); + expect(retryStartEvents).toHaveLength(0); + expect(retryEndEvents).toHaveLength(0); + const last = lastAssistant(session); + expect(last.stopReason).toBe("error"); + expect(last.errorMessage).toBe("The operation timed out."); + }); + it("retries on Bun HTTP/2 stream reset errors", async () => { // Regression: Bun's fetch surfaces HTTP/2 RST_STREAM as `Error: HTTP2StreamReset // fetching "". For more information, pass \`verbose: true\` ...`. The verbatim From 55000301bea84ed9a6672cf4a26b73c51d5e5aad Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 15 Jun 2026 15:48:06 +0000 Subject: [PATCH 2/2] fix(agent): preserved classifier refusal fallback Checked structured classifier refusals before the interrupted-output retry guard so provider refusals with explanatory content still use the configured fallback path. Fixes #2683 --- packages/coding-agent/src/session/agent-session.ts | 2 +- packages/coding-agent/test/agent-session-retry-fallback.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b5d69fa38..61517b9ed 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8689,9 +8689,9 @@ export class AgentSession { // Context overflow is handled by compaction, not retry const contextWindow = this.model?.contextWindow ?? 0; if (isContextOverflow(message, contextWindow)) return false; - if (this.#streamInterruptedAfterObservableOutput(message)) return false; if (this.#isClassifierRefusal(message)) return true; + if (this.#streamInterruptedAfterObservableOutput(message)) return false; if (this.#isStaleOpenAIResponsesReplayError(message)) return true; const err = message.errorMessage; diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 8c28e0d63..928b7e527 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -245,6 +245,7 @@ describe("AgentSession retry fallback", () => { if (model.provider === primaryModel.provider && model.id === primaryModel.id) { primaryAttempts += 1; mock.push({ + content: ["Classifier declined this turn."], stopReason: "error", stopDetails: refusalDetails, errorMessage: "Refusal (cyber): Classifier declined this turn.",