From 7e0bd363e97af24f26da4472f5520403a005a768 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Fri, 31 Jul 2026 07:21:44 +0530 Subject: [PATCH] fix(eve): classify gateway stream-assembler desync as terminal, not recoverable (#1227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI stream assembler's desync (`text part not found`, raised from consumeStreamContent when an interleaved provider stream references a part the assembler no longer tracks — observed with deepseek/deepseek-v4-flash reasoning parts via the AI Gateway) fell through classifyModelCallError's recoverable catch-all. eve then parked the session for a durable step retry that re-ran the entire tool loop from the start: production measured 15-70 minute silent replay loops and 50-69M replayed cache-read tokens per run. A malformed stream frame is deterministic — replaying the turn re-emits the same broken sequence, and there is no partial model state to recover. Add a dedicated isStreamAssemblerDesyncError matcher (message-prefix + cause-chain walk) and return "terminal" so the step fails fast instead of replaying. Signed-off-by: iroiro147 --- docs/subagents.mdx | 2 +- .../gateway-stream-desync-terminal-1227.md | 7 +++ .../eve/src/harness/model-call-error.test.ts | 48 +++++++++++++++++++ packages/eve/src/harness/model-call-error.ts | 38 +++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/eve/.changeset/gateway-stream-desync-terminal-1227.md diff --git a/docs/subagents.mdx b/docs/subagents.mdx index 883998953..181e94c39 100644 --- a/docs/subagents.mdx +++ b/docs/subagents.mdx @@ -108,7 +108,7 @@ Each delegated subagent spins up its own child session and stream. The parent st Cancelling a parent turn also requests cancellation of every active child it started, recursively through nested and remote subagents. Each affected session emits its own `turn.cancelled` → `session.waiting` boundary; the cancelled parent does not synthesize tool results or emit `subagent.completed`. A child that already completed or parked has no active turn and is a benign no-op. -Subagent model calls automatically retry classified transient provider failures, including overload errors delivered after a stream starts. eve makes at most three fresh model-call attempts, repeating only the current uncommitted call so completed earlier steps, tool results, and sandbox work remain available to the child. Other recoverable task errors fall back to Workflow's durable step retry from the last committed session snapshot. Exhausting the transient model-call attempts or the dedicated empty-response reissue returns one failed task result instead of stacking both retry budgets; terminal errors fail immediately. +Subagent model calls automatically retry classified transient provider failures, including overload errors delivered after a stream starts. eve makes at most three fresh model-call attempts, repeating only the current uncommitted call so completed earlier steps, tool results, and sandbox work remain available to the child. Other recoverable task errors fall back to Workflow's durable step retry from the last committed session snapshot. Exhausting the transient model-call attempts or the dedicated empty-response reissue returns one failed task result instead of stacking both retry budgets; terminal errors fail immediately. A malformed-provider stream desync (`text part not found` from the AI stream assembler) is terminal, not recoverable: replaying the turn re-emits the same broken frame sequence, so eve fails the step instead of replaying the whole tool loop. ## When to split diff --git a/packages/eve/.changeset/gateway-stream-desync-terminal-1227.md b/packages/eve/.changeset/gateway-stream-desync-terminal-1227.md new file mode 100644 index 000000000..7f8bc972a --- /dev/null +++ b/packages/eve/.changeset/gateway-stream-desync-terminal-1227.md @@ -0,0 +1,7 @@ +--- +"eve": patch +--- + +Classify the AI SDK stream-assembler desync (`text part not found`) as terminal instead of recoverable (#1227) + +When a provider emits an interleaved stream the assembler cannot track (observed with `deepseek/deepseek-v4-flash` reasoning parts through the AI Gateway), `classifyModelCallError` now returns `"terminal"` so the step fails instead of parking the session for a durable full-turn replay. Replaying the whole tool loop cannot fix a malformed stream — the provider emits the same broken frame sequence — so the previous `recoverable` catch-all turned single subagent turns into 15-70 minute silent replay loops and inflated runs to 50-69M replayed cache-read tokens. `isStreamAssemblerDesyncError` is exported for the classifier and future call-site guards. diff --git a/packages/eve/src/harness/model-call-error.test.ts b/packages/eve/src/harness/model-call-error.test.ts index 7044135ca..0b6ff1fb3 100644 --- a/packages/eve/src/harness/model-call-error.test.ts +++ b/packages/eve/src/harness/model-call-error.test.ts @@ -6,6 +6,7 @@ import { extractModelCallErrorDetails, extractUnsupportedProviderToolTypes, isNoOutputGeneratedError, + isStreamAssemblerDesyncError, normalizeModelStreamError, extractUpstreamRejectionMessage, } from "#harness/model-call-error.js"; @@ -153,6 +154,29 @@ describe("normalizeModelStreamError", () => { * rendering (details payload, OTel span exceptions) is covered in * `src/internal/logging.test.ts`. */ +describe("isStreamAssemblerDesyncError", () => { + it("matches the AI SDK stream assembler desync message", () => { + expect(isStreamAssemblerDesyncError(new Error("text part 8bfaeea5cfe5bd29 not found"))).toBe( + true, + ); + }); + + it("matches when nested in a cause chain", () => { + const wrapped = new Error("model stream failed", { + cause: new Error("text part 1a2b3c4d not found"), + }); + expect(isStreamAssemblerDesyncError(wrapped)).toBe(true); + }); + + it("rejects other errors and non-matching shapes", () => { + expect(isStreamAssemblerDesyncError(new Error("text part not found"))).toBe(false); + expect(isStreamAssemblerDesyncError(new Error("tool result part abc not found"))).toBe(false); + expect(isStreamAssemblerDesyncError(new Error("overloaded"))).toBe(false); + expect(isStreamAssemblerDesyncError("text part deadbeef not found")).toBe(false); + expect(isStreamAssemblerDesyncError(undefined)).toBe(false); + }); +}); + describe("classifyModelCallError", () => { it("returns recoverable for an empty model response, never retry", () => { // "retry" would re-run executeModelCall against step hooks whose @@ -170,6 +194,30 @@ describe("classifyModelCallError", () => { expect(classifyModelCallError(wrapped)).toBe("terminal"); }); + it("returns terminal for a stream-assembler desync, even when marked retryable", () => { + // A deterministic malformed stream frame cannot be fixed by replaying + // the turn, so it must never drop into the recoverable durable-replay + // path (https://github.com/vercel/eve/issues/1227). + const err = Object.assign(new Error("text part 8bfaeea5cfe5bd29 not found"), { + isRetryable: true, + }); + expect(classifyModelCallError(err)).toBe("terminal"); + + const wrapped = new Error("model stream failed", { + cause: new Error("text part 1a2b3c4d not found"), + }); + expect(classifyModelCallError(wrapped)).toBe("terminal"); + }); + + it("returns terminal for a bare stream-assembler desync, not the recoverable catch-all", () => { + // The production failure arrives as a plain Error with no gateway + // signals, no status code, and no retryable flag; without the dedicated + // branch it fell through to the catch-all `recoverable` classification + // that parked the session for a full-turn durable replay. + const bare = new Error("text part 8bfaeea5cfe5bd29 not found"); + expect(classifyModelCallError(bare)).toBe("terminal"); + }); + it("returns retry when the AI SDK marks the error as retryable", () => { const err = Object.assign(new Error("upstream flaky"), { isRetryable: true }); expect(classifyModelCallError(err)).toBe("retry"); diff --git a/packages/eve/src/harness/model-call-error.ts b/packages/eve/src/harness/model-call-error.ts index 91e32f73e..651091c2e 100644 --- a/packages/eve/src/harness/model-call-error.ts +++ b/packages/eve/src/harness/model-call-error.ts @@ -255,6 +255,34 @@ export function isNoOutputGeneratedError(error: unknown): boolean { return false; } +/** + * Anchored matcher for the AI SDK stream assembler's desync failure + * (`text part not found`), raised from `consumeStreamContent` when + * an interleaved provider stream (observed with `deepseek/deepseek-v4-flash` + * reasoning parts via the AI Gateway) references a part the assembler no + * longer tracks. Matched on message prefix rather than a stable error name — + * the SDK throws a plain `Error` here, and the part id is a per-stream hex + * token, so the check anchors on the fixed `text part ... not found` shape. + */ +const STREAM_ASSEMBLER_DESYNC_REGEX = /^text part [0-9a-z]+ not found$/i; + +/** + * True when the error (or any error in its cause chain) is the AI SDK + * stream assembler's desync failure: a content part references an id the + * assembler never opened or already closed. Replaying the turn cannot fix a + * malformed stream — the provider emits the same broken frame sequence — so + * the runtime must not park the session for a durable full-turn replay. + */ +export function isStreamAssemblerDesyncError(error: unknown): boolean { + for (const candidate of walkCauseChain(error)) { + const message = readErrorMessage(candidate); + if (message !== "" && STREAM_ASSEMBLER_DESYNC_REGEX.test(message.trim())) { + return true; + } + } + return false; +} + /** * Classifies a model-call failure into the runtime's recovery policy. */ @@ -263,6 +291,16 @@ export function classifyModelCallError(error: unknown): "retry" | "recoverable" return "terminal"; } + // The stream-assembler desync is a deterministic malformed-frame failure + // from the provider's stream. Classifying it as recoverable parked the + // session for a durable replay that re-ran the entire tool loop from the + // start (15-70 min loops, tens of millions of replayed cache-read tokens + // per incident in production). There is no partial model state to recover + // and no transient condition to wait out, so fail the step instead. + if (isStreamAssemblerDesyncError(error)) { + return "terminal"; + } + // Not "retry": the empty response already resolved the step hooks' // one-shot stepResult promise, so a same-hooks retry would read the // stale empty result. The harness reissues with fresh hooks instead