diff --git a/packages/runtime/src/__tests__/history-compaction.test.ts b/packages/runtime/src/__tests__/history-compaction.test.ts index ab11cc35454..86a45ab10e0 100644 --- a/packages/runtime/src/__tests__/history-compaction.test.ts +++ b/packages/runtime/src/__tests__/history-compaction.test.ts @@ -339,6 +339,73 @@ describe('plan context compaction', () => { ]); }); + test('a mid-turn retreat with no safe span reports the summarizer failure', async () => { + // The proven boundary is a prior-run reply before the head anchor, so the + // retreat has no mid_turn coverage. The summarizer was still called and + // refused, so the result must say so rather than look like a pool that was + // never summarized. + let attempts = 0; + const result = await planHistoryCompaction( + planInput({ + orderedEvents: [ + user('old-user', 'turn-0'), + modelOnRun('old-model', 'turn-0', 'run-0', 'accepted by this route'), + ...longTurnEvents().slice(2), + ], + invocations: [runOn('run-0', 'model-a', 'conn-a')], + acceptedRoute: ROUTE_A, + summarize: () => { + attempts += 1; + throw new HistoryCompactSummarizerError('input_too_large'); + }, + }), + ); + + assert.equal(attempts, 1); + assert.deepEqual(result, { + decision: 'fail_open', + reason: 'summarizer_failed', + diagnosticReason: 'input_too_large', + }); + }); + + test('a mid-turn retreat still tries a proven span handoff put after the anchor', async () => { + // Handoff replay keeps the same logical turn's predecessor events — and the + // predecessor run's record — in the pool, so a reply after the head anchor + // can be the proven boundary. The retreat must still try it (#5790 review). + const attemptedCoverage: string[][] = []; + const plan = await planHistoryCompaction( + planInput({ + orderedEvents: [ + user('old-user', 'turn-0'), + modelOnRun('old-model', 'turn-0', 'run-0', 'old reply'), + user('anchor', 'turn-1'), + modelOnRun('handoff-reply', 'turn-1', 'run-0', 'source-run reply'), + call('call-x', 'cx', 'turn-1'), + result('res-x', 'cx', 'turn-1'), + ], + invocations: [runOn('run-0', 'model-a', 'conn-a')], + acceptedRoute: ROUTE_A, + summarize: ({ coveredRuntimeEvents }) => { + attemptedCoverage.push(coveredRuntimeEvents.map((event) => event.id)); + throw new HistoryCompactSummarizerError('input_too_large'); + }, + }), + ); + + // First attempt covers through the reserved tail cut; the retreat then + // retries the proven prefix ending on the predecessor run's reply. + assert.deepEqual(attemptedCoverage, [ + ['old-user', 'old-model', 'anchor', 'handoff-reply'], + ['old-user', 'old-model', 'anchor'], + ]); + assert.deepEqual(plan, { + decision: 'fail_open', + reason: 'summarizer_failed', + diagnosticReason: 'input_too_large', + }); + }); + test('fails open when only another route has ever been accepted', async () => { let attempts = 0; const result = await planHistoryCompaction( diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index d507cd84824..add55fad4ba 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -970,10 +970,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { }); test('sends an over-window request no shaper could rescue, rather than ending the turn', async () => { - // No prior turns and a window the first step's usage already exceeds: the - // pool is [anchor, one open call/result pair], so no safe completed span - // and nothing to compact. Only the provider can say whether that request - // fits, so it goes out and the turn runs to its own end. + // No prior turns and a window the first step's usage already exceeds: at + // that step the pool is [anchor, one completed call/result pair], which + // mid-turn coverage cannot fold. Only the provider can say whether that + // request fits, so it goes out and the turn runs to its own end. The miss + // is per-step — the grown pool may still fold a later request (#5790). const fixture = buildFixture({ contextWindow: 120, withoutPriorTurns: true, @@ -1107,6 +1108,55 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.equal(failedOpen[0]?.failOpenReason, 'provider_error'); }); + test('bounds an input-too-large rejection across later steps in the same turn (#5790 review)', async () => { + // No invocation proves a boundary, so the rejection is a plain summarizer + // failure and the Turn latches it — a later step never re-dispatches it. + const fixture = buildFixture({ + toolSteps: 3, + summarize: () => { + throw new HistoryCompactSummarizerError('input_too_large'); + }, + }); + + await runFixtureTurn(fixture, consumer); + + assert.equal(fixture.summarizerCalls, 1); + assert.equal(fixture.recorded.length, 0); + const failedOpen = compactionDecisions(fixture).filter( + (decision) => decision.decision === 'failedOpen', + ); + assert.equal(failedOpen[0]?.failOpenReason, 'input_too_large'); + }); + + test('a no_safe_completed_span attempt does not suppress the fold once the pool grows (#5790)', async () => { + // No prior turns: at the first trigger the pool is only the anchor plus + // one completed tool pair, which mid-turn coverage cannot fold (the anchor + // alone saves nothing). The attempt fails open WITHOUT calling the + // summarizer — a property of the pool at that step, not a failure — so it + // must not latch the turn. Once the next step lands a second pair the new + // attempt re-reads the grown ledger and folds. + const fixture = buildFixture({ + contextWindow: 150, + withoutPriorTurns: true, + toolSteps: 2, + }); + await runFixtureTurn(fixture, consumer); + + const complete = fixture.events.find((event) => event.type === 'complete'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); + const failedOpen = compactionDecisions(fixture).find( + (decision) => decision.decision === 'failedOpen', + ); + assert.equal(failedOpen?.failOpenReason, 'no_safe_completed_span'); + // The grown pool folded: exactly one summarizer call, one checkpoint. + assert.equal(fixture.summarizerCalls, 1); + assert.equal(fixture.recorded.length, 1); + const thirdPrompt = promptJson(fixture, 2); + assert.match(thirdPrompt, /maka_history_compact_checkpoint/); + assert.equal(thirdPrompt.includes('RAW_SPAN_ONE_'), false); + assert.equal(thirdPrompt.includes('RAW_SPAN_TWO_'), true); + }); + test('fails closed before provider dispatch when the durable ledger read fails', async () => { const fixture = buildFixture(); // Break the seam after construction: every trigger read now rejects. @@ -1808,9 +1858,10 @@ describe('the shipped runtime default drives the proactive long-turn journey (is assert.equal(fixture.events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); }); - test('an unrescuable turn under the shipped default still dispatches', async () => { - // No prior turns leaves no safe completed span. The request still goes out - // because only the provider can decide whether it fits. + test('a first trigger with nothing foldable still dispatches under the shipped default', async () => { + // No prior turns leaves the first trigger no safe completed span. The + // request still goes out because only the provider can decide whether it + // fits — and the per-step miss does not latch the turn (#5790). const fixture = buildFixture({ useRuntimeDefaultPolicy: true, contextWindow: 120, diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index b1975829832..31a0e812a3a 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -167,6 +167,8 @@ interface ReactiveFixtureOptions { providerNative?: boolean; /** Explicit send-level step budget forwarded to the backend. */ maxSteps?: number; + /** Per provider-call reported usage, keyed by 1-based call number. */ + usageByCall?: Record; /** The FIRST tool step reports an unusable usage object (no token counts). */ firstStepUsageMissing?: boolean; /** Tool-search availability with the deferred `Big` tool. */ @@ -242,6 +244,10 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture inputTokens: { total: input, noCache: input, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: output, text: output, reasoning: 0 }, }); + const usageOverride = (call: number): ReturnType | undefined => { + const override = options.usageByCall?.[call]; + return override ? usage(override.input, override.output) : undefined; + }; const toolCallChunks = ( call: number, toolName: string, @@ -263,9 +269,10 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture // An unusable first-step usage: the SDK accepts the object but the // adapter's normalization fails closed (undefined), the #972 shape. usage: - options.firstStepUsageMissing && call === 1 + usageOverride(call) ?? + (options.firstStepUsageMissing && call === 1 ? ({ inputTokens: {}, outputTokens: {} } as ReturnType) - : usage(100, 20), + : usage(100, 20)), }, ]; const doneChunks = (call: number): LanguageModelV4StreamPart[] => [ @@ -276,7 +283,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, - usage: usage(120, 10), + usage: usageOverride(call) ?? usage(120, 10), }, ]; const streamForCall = (call: number): ReadableStream => { @@ -1946,6 +1953,36 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.summarizerCalls(), 2); }); + test('a no_safe_completed_span fail-open does not block overflow recovery once the pool grows (#5790)', async () => { + // Step 1's large usage fires the proactive trigger while the durable pool + // is only the anchor plus one tool pair — a miss that never reached the + // summarizer yet (before the fix) latched the turn's failure flag. Step 2 + // lands a second pair and reports a small usage so the proactive trigger + // stays quiet, then the third request overflows: recovery must re-read + // the grown ledger and fold instead of inheriting the earlier miss. + const fixture = buildReactiveFixture({ + script: ['tool', 'tool', 'overflow', 'done'], + withoutPriorTurns: true, + declareContextWindow: true, + contextWindow: 190, + usageByCall: { 1: { input: 1_000, output: 50 }, 2: { input: 10, output: 5 } }, + }); + await runTurn(fixture); + + // Recovery folded the grown pool and resent: the turn completes for real. + assert.equal(complete(fixture)?.stopReason, 'end_turn'); + assert.equal(fixture.model.doStreamCalls.length, 4); + assert.equal( + fixture.events.some((event) => event.type === 'error'), + false, + ); + assert.equal(fixture.summarizerCalls(), 1); + assert.equal(fixture.recorded.length, 1); + // The resent request carries the folded checkpoint, not the raw span. + const resent = JSON.stringify(fixture.model.doStreamCalls[3]?.prompt); + assert.match(resent, /REACTIVE_SUMMARY_SENTINEL/); + }); + test('a later step that overflows again folds again after real progress', async () => { // The budget is one attempt per logical step, not one per send. The first // overflow folds and the retry succeeds; two more tool steps are accepted diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 39ca0f7582b..a8c716e33df 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -1110,14 +1110,21 @@ export class AiSdkCompaction { if (plan.decision === 'fail_open') { const diagnosticReason = plan.diagnosticReason ?? plan.reason; - // Latch every fail-open reason, not only the malformed ones. The baseline + // Latch only a fail-open the summarizer actually produced. The baseline // that fired this trigger survives a fail-open, so without the latch the // next step evaluates the same condition and dispatches the same doomed // summarizer call: a provider that answers slowly and fails (kimi's HTTP // 200 with an error body) produced 15 such calls over 47 minutes before // one main request (#4634). The latch covers the whole Turn and only // fail-opens; a successful fold spends no part of it (#4559). - state.summarizerFailure = diagnosticReason; + // + // no_safe_completed_span is not a summarizer failure — no call was made. + // It describes the event pool at this step, and a pool that has since + // grown a completed tool pair can present a safe cut, so the next + // attempt must re-read the ledger rather than inherit this miss (#5790). + if (plan.reason === 'summarizer_failed') { + state.summarizerFailure = diagnosticReason; + } return { decision: 'fail', diagnosticReason, @@ -1389,7 +1396,12 @@ export class MidTurnCapacityCompactState { flushedSteps = 0; /** Exact historical image results omitted after a provider overflow. */ omittedImageToolResults = new Map(); - /** Malformed summaries spend one bounded repair budget for this whole Turn. */ + /** + * One bounded retry budget for the whole Turn, spent only by a failure the + * summarizer actually produced (#4634). A structural miss like + * no_safe_completed_span never reaches it: the pool may grow a safe cut, so + * the next attempt re-reads the ledger instead of short-circuiting (#5790). + */ summarizerFailure: string | undefined; constructor( diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 6c4a15edc16..b33dd38865b 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -189,7 +189,9 @@ export interface PlanHistoryCompactionInput { * The invocations behind the ordered events, and the route this fold is * dispatched on. Together they name the newest reply this route produced, * which is the only span a retreat may target: a rejection of a larger one - * says nothing about a span another model accepted. + * says nothing about a span another model accepted. Handoff counts too: a + * predecessor run's reply inside the same logical turn can sit after the + * head anchor and still prove a mid_turn retreat. */ invocations?: readonly RuntimeInvocationRecord[]; acceptedRoute?: { modelId: string; connectionId?: string }; @@ -291,6 +293,14 @@ export async function planHistoryCompaction( if (phase !== 'standalone' && headAnchorIndex < 0) { return { decision: 'fail_open', reason: 'no_safe_completed_span' }; } + // After a retreat the summarizer has already been called and refused, so a + // smaller window with nothing safe to fold is that failure, not a pool that + // never reached the summarizer (#5790 review). + let retreated = false; + const noSafeSpan = (): PlanHistoryCompactionResult => + retreated + ? { decision: 'fail_open', reason: 'summarizer_failed', diagnosticReason: 'input_too_large' } + : { decision: 'fail_open', reason: 'no_safe_completed_span' }; let maxCoveredCount = input.orderedEvents.length; while (maxCoveredCount > 0) { const boundary = selectSafeCompactionPrefix(input.orderedEvents, { @@ -312,9 +322,7 @@ export async function planHistoryCompaction( : phase === 'mid_turn' ? boundary.ok && boundary.coveredCount > headAnchorIndex && boundary.coveredCount >= 2 : boundary.ok && boundary.coveredCount > 0 && boundary.coveredCount <= headAnchorIndex; - if (!boundary.ok || !hasSafeCoverage) { - return { decision: 'fail_open', reason: 'no_safe_completed_span' }; - } + if (!boundary.ok || !hasSafeCoverage) return noSafeSpan(); const coveredRuntimeEvents = input.orderedEvents.slice(0, boundary.coveredCount); const tailRuntimeEvents = input.orderedEvents.slice(boundary.coveredCount); @@ -394,6 +402,7 @@ export async function planHistoryCompaction( }; } maxCoveredCount = proven; + retreated = true; continue; } return { @@ -461,7 +470,7 @@ export async function planHistoryCompaction( estimatedTokensAfter, }; } - return { decision: 'fail_open', reason: 'no_safe_completed_span' }; + return noSafeSpan(); } export interface HistoryCompactionPolicy {