Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/subagents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> 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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"eve": patch
---

Classify the AI SDK stream-assembler desync (`text part <id> 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.
48 changes: 48 additions & 0 deletions packages/eve/src/harness/model-call-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
extractModelCallErrorDetails,
extractUnsupportedProviderToolTypes,
isNoOutputGeneratedError,
isStreamAssemblerDesyncError,
normalizeModelStreamError,
extractUpstreamRejectionMessage,
} from "#harness/model-call-error.js";
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down
38 changes: 38 additions & 0 deletions packages/eve/src/harness/model-call-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> 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.
*/
Expand All @@ -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
Expand Down
Loading