Skip to content
Merged
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
67 changes: 67 additions & 0 deletions packages/runtime/src/__tests__/history-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
65 changes: 58 additions & 7 deletions packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 40 additions & 3 deletions packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, { input: number; output: number }>;
/** The FIRST tool step reports an unusable usage object (no token counts). */
firstStepUsageMissing?: boolean;
/** Tool-search availability with the deferred `Big` tool. */
Expand Down Expand Up @@ -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<typeof usage> | undefined => {
const override = options.usageByCall?.[call];
return override ? usage(override.input, override.output) : undefined;
};
const toolCallChunks = (
call: number,
toolName: string,
Expand All @@ -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<typeof usage>)
: usage(100, 20),
: usage(100, 20)),
},
];
const doneChunks = (call: number): LanguageModelV4StreamPart[] => [
Expand All @@ -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<LanguageModelV4StreamPart> => {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/runtime/src/ai-sdk-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Comment thread
Colafornia marked this conversation as resolved.
state.summarizerFailure = diagnosticReason;
}
return {
decision: 'fail',
diagnosticReason,
Expand Down Expand Up @@ -1389,7 +1396,12 @@ export class MidTurnCapacityCompactState {
flushedSteps = 0;
/** Exact historical image results omitted after a provider overflow. */
omittedImageToolResults = new Map<string, HistoricalImageToolResult>();
/** 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(
Expand Down
19 changes: 14 additions & 5 deletions packages/runtime/src/history-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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, {
Expand All @@ -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);

Expand Down Expand Up @@ -394,6 +402,7 @@ export async function planHistoryCompaction(
};
}
maxCoveredCount = proven;
retreated = true;
continue;
}
return {
Expand Down Expand Up @@ -461,7 +470,7 @@ export async function planHistoryCompaction(
estimatedTokensAfter,
};
}
return { decision: 'fail_open', reason: 'no_safe_completed_span' };
return noSafeSpan();
}

export interface HistoryCompactionPolicy {
Expand Down