From dd939d1432974a4ab611dc00393261a5856299fd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 23:36:55 +0800 Subject: [PATCH 1/9] fix(runtime): price artifact media inside the context budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image Tool Result serializes to a one-line reference on the ledger and to real bytes in the provider request, so every sizing site that measured the durable projection priced a screenshot at ~0 tokens. Compaction was never triggered by images, the prune never selected them, and the request went over the window with the budget reporting room to spare. model sees" and "how large the request is" only coincide for text. This adds the missing half: `effectiveToolResultMedia` is the one answer to what a Tool Result rehydrates into, covering both artifact parts and the pre-artifact image results the decoder still hands to materialization raw. Media stays in tokens rather than folding into the char count, because `charsPerToken` calibrates text and would otherwise make an image cheaper on a session with a low text ratio. Reactive overflow recovery reads that same decode instead of the raw execution fact — the fifth consumer #4348 did not reach — and, when the provider reported the rejected request's size, drops the largest images only until that overshoot is covered rather than every image at once. Refs #4458, #4283 Generated-by: Claude Code --- packages/core/src/attachments.ts | 13 +++ .../effective-history-compaction.test.ts | 26 ++++++ .../provider-image-overflow-recovery.test.ts | 77 +++++++++++++++++- .../runtime/src/active-tool-result-prune.ts | 7 +- packages/runtime/src/ai-sdk-compaction.ts | 13 ++- .../src/durable-tool-result-projection.ts | 61 +++++++++++++- packages/runtime/src/model-history.ts | 72 ++++++++++++----- .../src/provider-image-overflow-recovery.ts | 80 ++++++++++--------- .../src/tool-result-archive-transition.ts | 28 ++++++- 9 files changed, 309 insertions(+), 68 deletions(-) diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index 06550236c1..6725e44472 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -73,6 +73,19 @@ export const MAX_READ_IMAGE_BYTES = 5 * 1024 * 1024; export const MAX_MODEL_IMAGE_EDGE = 2000; export const READ_IMAGE_TOO_LARGE_MESSAGE = `Image exceeds the ${MAX_READ_IMAGE_BYTES / 1024 / 1024}MB model input limit; downscale it and try again.`; +/** + * What one image costs the request once materialization rehydrates it. + * + * A flat per-modality constant, because no character count answers this: both + * an artifact part and a legacy image result reduce to a one-line reference, + * and providers price an image by the area they resize it to. It sits above + * Anthropic's ~1,600-token ceiling for an image up to 1.15 megapixels and in + * the same range as the constants other agents use (opencode 1,500, Codex + * 1,844). Erring high is the safe direction: every consumer is reversible, so + * the worst an over-count buys is one compaction that was not needed. + */ +export const MATERIALIZED_IMAGE_TOKENS = 2_000; + /** Leaves room for Base64 expansion, text, and tool schemas under provider request limits. */ export const MAX_PROVIDER_IMAGE_REQUEST_BYTES = 12 * 1024 * 1024; export const PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE = `Image was read, but the per-request image budget (${MAX_PROVIDER_IMAGE_REQUEST_BYTES / 1024 / 1024}MB across all images this turn) was exceeded; earlier images were sent and this one was omitted. Read fewer or smaller images.`; diff --git a/packages/runtime/src/__tests__/effective-history-compaction.test.ts b/packages/runtime/src/__tests__/effective-history-compaction.test.ts index 3c09d826d1..dd5ed901a6 100644 --- a/packages/runtime/src/__tests__/effective-history-compaction.test.ts +++ b/packages/runtime/src/__tests__/effective-history-compaction.test.ts @@ -65,6 +65,17 @@ describe('effective model history feeds budgeting and compaction', () => { assert.ok(estimateRuntimeEventsTokens([raw], 1) > RAW_SECRET.length); }); + test('budgeting prices an artifact by what materialization rehydrates', () => { + const text = toolResultEvent('evt-3', RAW_SECRET, textProjection(PROJECTED)); + const artifact = toolResultEvent('evt-3', RAW_SECRET, artifactProjection(PROJECTED)); + + // The two projections serialize to comparable strings, but only one of them + // puts image bytes in the request. + assert.ok( + estimateRuntimeEventsTokens([artifact], 1) - estimateRuntimeEventsTokens([text], 1) >= 1000, + ); + }); + test('summarization cannot read raw output the projection replaced', async () => { let seen: Parameters[0] | undefined; const summarize = buildLlmHistorySummarizer({ @@ -149,6 +160,21 @@ function textProjection(text: string): DurableToolResultProjection { return { version: 1, kind: 'text', text }; } +function artifactProjection(text: string): DurableToolResultProjection { + return { + version: 1, + kind: 'content', + parts: [ + { kind: 'text', text }, + { + kind: 'artifact', + mediaType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + ], + }; +} + function userEvent(id: string, text: string): RuntimeEvent { return { id, diff --git a/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts b/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts index a58861f151..a2534498ca 100644 --- a/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts +++ b/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts @@ -26,8 +26,20 @@ import type { ModelMessage } from '../model-protocol.js'; import { collectHistoricalImageToolResults, omitHistoricalImageToolResults, + selectHistoricalImageOmissions, } from '../provider-image-overflow-recovery.js'; +/** A pre-artifact image result: no durable projection, materialized raw. */ +function legacyImageResultEvent(toolCallId: string, relativePath: string): RuntimeEvent { + const event = imageResultEvent(toolCallId, { + kind: 'session_file', + sessionId: 'session-1', + relativePath, + }); + delete (event.content as { modelProjection?: unknown }).modelProjection; + return event; +} + function imageResultEvent(toolCallId: string, ref: StorageRef): RuntimeEvent { return { id: `event-${toolCallId}`, @@ -49,6 +61,14 @@ function imageResultEvent(toolCallId: string, ref: StorageRef): RuntimeEvent { mimeType: 'image/png', ref, }, + modelProjection: { + version: 1, + kind: 'content', + parts: [ + { kind: 'text', text: 'Image read successfully.' }, + { kind: 'artifact', mediaType: 'image/png', ref }, + ], + }, isError: false, }, } as unknown as RuntimeEvent; @@ -89,7 +109,7 @@ describe('provider image overflow recovery projection', () => { imageResultEvent('prior-image-call', { kind: 'session_file', sessionId: 'session-1', - relativePath: 'screenshots/screenshot.png', + relativePath: 'artifact-screenshot-1', }), ]; const messages = [ @@ -110,7 +130,7 @@ describe('provider image overflow recovery projection', () => { assert.equal(rendered.includes('USER_IMAGE'), true); assert.equal(rendered.includes('NEW_IMAGE'), true); assert.equal(rendered.includes('PRIOR_IMAGE'), false); - assert.match(rendered, /screenshots\/screenshot\.png/); + assert.match(rendered, /artifact-screenshot-1/); assert.match(rendered, /repeat the preceding Read tool call/i); }); @@ -121,7 +141,7 @@ describe('provider image overflow recovery projection', () => { imageResultEvent('prior-image-call', { kind: 'session_file', sessionId: 'session-1', - relativePath: 'screenshot.png', + relativePath: 'artifact-screenshot-1', }), ]); @@ -151,4 +171,55 @@ describe('provider image overflow recovery projection', () => { assert.match(prompt(result.messages), /read-image:owner-1/); }); + + test('prices an image artifact by its materialized cost, not its reference text', () => { + const eligible = collectHistoricalImageToolResults([ + imageResultEvent('prior-image-call', { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-screenshot-1', + }), + ]); + + assert.equal(eligible.get('prior-image-call')?.estimatedTokens, 2000); + }); + + test('still recovers a pre-artifact image result that has no durable projection', () => { + const eligible = collectHistoricalImageToolResults([ + legacyImageResultEvent('prior-image-call', 'screenshots/screenshot.png'), + ]); + const result = omitHistoricalImageToolResults( + [toolImageMessage('prior-image-call', 'PRIOR_IMAGE')], + eligible, + ); + + assert.equal(eligible.get('prior-image-call')?.estimatedTokens, 2000); + assert.equal(result.omittedParts, 1); + assert.match(prompt(result.messages), /screenshots\/screenshot\.png/); + }); + + test('drops the largest images only until the overshoot is covered', () => { + const eligible = collectHistoricalImageToolResults([ + imageResultEvent('call-a', { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-a', + }), + imageResultEvent('call-b', { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-b', + }), + imageResultEvent('call-c', { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'artifact-c', + }), + ]); + + assert.equal(selectHistoricalImageOmissions(eligible, 1).size, 1); + assert.equal(selectHistoricalImageOmissions(eligible, 2500).size, 2); + assert.equal(selectHistoricalImageOmissions(eligible, 99_000).size, 3); + assert.equal(selectHistoricalImageOmissions(eligible, undefined).size, 3); + }); }); diff --git a/packages/runtime/src/active-tool-result-prune.ts b/packages/runtime/src/active-tool-result-prune.ts index 11a335e41e..cc69ec449c 100644 --- a/packages/runtime/src/active-tool-result-prune.ts +++ b/packages/runtime/src/active-tool-result-prune.ts @@ -52,6 +52,7 @@ import { import { archiveToolResultAsTransition, serializedToolResultProjection, + toolResultProjectionEstimatedTokens, type ToolResultArchiveTransitionServices, } from './tool-result-archive-transition.js'; import { @@ -289,7 +290,11 @@ async function rewriteToolResultPart(input: { if (!address || address.toolName !== part.toolName) return { changed: false }; const sourceProjection = address.projection; const serializedResult = serializedToolResultProjection(sourceProjection); - const originalEstimatedTokens = estimateTokens(serializedResult.length, input.charsPerToken); + const originalEstimatedTokens = toolResultProjectionEstimatedTokens( + sourceProjection, + serializedResult, + input.charsPerToken, + ); if ( input.supersession ? originalEstimatedTokens < input.minSupersededResultEstimatedTokens diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 7747e2e273..7416d125c2 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -120,6 +120,7 @@ import { collectHistoricalImageToolResults, type HistoricalImageToolResult, omitHistoricalImageToolResults, + selectHistoricalImageOmissions, } from './provider-image-overflow-recovery.js'; /** @@ -1353,7 +1354,17 @@ export class AiSdkCompaction { if (input.retryAlreadyUsed || !state) return undefined; if (this.modelAdapter.classifyError(input.error) !== 'ContextLength') return undefined; - const eligibleImages = collectHistoricalImageToolResults(state.priorContentEvents); + // The provider counted the rejected request, so the overshoot is known: + // give back that much of the image cost, not every image in the history. + // Without a usable count nothing bounds the drop, so it stays all-or-nothing. + const overshootTokens = + state.lastRequestInputTokens !== undefined + ? state.lastRequestInputTokens - state.capacity.tokens + : undefined; + const eligibleImages = selectHistoricalImageOmissions( + collectHistoricalImageToolResults(state.priorContentEvents), + overshootTokens, + ); const imageOmission = omitHistoricalImageToolResults(input.currentMessages, eligibleImages); if (imageOmission.omittedParts > 0) { state.omittedImageToolResults = new Map( diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index dd52899777..18b13e54dd 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -29,7 +29,7 @@ import { type DurableToolResultProjection, type DurableToolResultProjectionPart, } from '@maka/core/durable-tool-result-projection'; -import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { MATERIALIZED_IMAGE_TOKENS, MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import { isCanonicalArtifactEntityId, normalizeArtifactImagePreviewMime, @@ -173,6 +173,63 @@ export function durableProjectionToToolResultOutput( } } +/** One image a Tool Result puts in the request. */ +export interface MaterializedToolResultMedia { + mediaType: string; + /** How the model is told to name it once it is gone. */ + label: string; +} + +function projectionArtifactMedia( + projection: DurableToolResultProjection, +): MaterializedToolResultMedia[] { + if (projection.kind !== 'content') return []; + return projection.parts.flatMap((part) => + part.kind === 'artifact' + ? [ + { + mediaType: part.mediaType, + label: part.ref.kind === 'session_context' ? part.ref.refId : part.ref.relativePath, + }, + ] + : [], + ); +} + +/** + * The images one Tool Result puts in the request: the artifact parts of its + * durable projection, or — for a pre-artifact image the decoder still hands to + * materialization raw — that legacy result. + */ +export function effectiveToolResultMedia( + effective: EffectiveToolResultProjection, + sessionId: string, +): MaterializedToolResultMedia[] { + if (effective.kind === 'projection') return projectionArtifactMedia(effective.projection); + if (effective.kind !== 'legacy_output') return []; + const image = sessionImageResult(effective.output, sessionId); + if (!image) return []; + return [ + { + mediaType: image.mimeType, + label: image.ref.kind === 'session_context' ? image.ref.refId : image.ref.relativePath, + }, + ]; +} + +/** Tokens the artifact parts of one projection cost once materialized. */ +export function estimateProjectionMediaTokens(projection: DurableToolResultProjection): number { + return projectionArtifactMedia(projection).length * MATERIALIZED_IMAGE_TOKENS; +} + +/** Tokens the images of one decoded Tool Result cost once materialized. */ +export function estimateEffectiveMediaTokens( + effective: EffectiveToolResultProjection, + sessionId: string, +): number { + return effectiveToolResultMedia(effective, sessionId).length * MATERIALIZED_IMAGE_TOKENS; +} + /** * The one pure decision of WHICH source a replayed Tool Result materializes * from: a durable projection wins, and only a response that has none (legacy @@ -204,7 +261,7 @@ export function rewriteDurableToolResultProjectionArtifactRefs( }; } -type EffectiveToolResultProjection = +export type EffectiveToolResultProjection = | { kind: 'projection'; projection: DurableToolResultProjection; diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 94c76425b7..4b4bd99ab4 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -73,6 +73,7 @@ import type { import { decodeEffectiveToolResultProjection, durableProjectionToToolResultOutput, + estimateEffectiveMediaTokens, } from './durable-tool-result-projection.js'; import { estimateTokens, stableJsonLength, turnKey } from './context-budget-helpers.js'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; @@ -126,9 +127,9 @@ export function admitProviderReasoningReplayItems( // ============================================================================ /** - * Model-visible character count of a `function_response`'s EFFECTIVE value — - * the durable projection when the response has one, never the raw execution - * fact the projection already bounded or redacted. + * Size of a `function_response`'s EFFECTIVE value — the durable projection + * when the response has one, never the raw execution fact the projection + * already bounded or redacted. * * Memoized on the content object because a legacy response (no durable * projection) is re-derived through the whole compatibility codec, and one @@ -136,7 +137,7 @@ export function admitProviderReasoningReplayItems( * compactable-content filter, and checkpoint prefix matching all walk the * history. Content is immutable once committed, so identity is a sound key. */ -const effectiveToolResultChars = new WeakMap(); +const effectiveToolResultSizes = new WeakMap(); /** Model-visible character count of one materialized Tool Result output. */ function estimateToolResultOutputChars(output: ToolResultOutput): number { @@ -157,21 +158,36 @@ function estimateToolResultOutputChars(output: ToolResultOutput): number { } } -export function estimateEffectiveToolResultChars( +interface EffectiveToolResultSize { + chars: number; + mediaTokens: number; +} + +function effectiveToolResultSize( content: Extract, sessionId: string, -): number { - const memoized = effectiveToolResultChars.get(content); +): EffectiveToolResultSize { + const memoized = effectiveToolResultSizes.get(content); if (memoized !== undefined) return memoized; const effective = decodeEffectiveToolResultProjection(content, sessionId); - const chars = - effective.kind === 'projection' - ? estimateToolResultOutputChars(durableProjectionToToolResultOutput(effective.projection)) - : effective.kind === 'invalid_legacy' - ? effective.message.length - : stableJsonLength(effective.output); - effectiveToolResultChars.set(content, chars); - return chars; + const size: EffectiveToolResultSize = { + chars: + effective.kind === 'projection' + ? estimateToolResultOutputChars(durableProjectionToToolResultOutput(effective.projection)) + : effective.kind === 'invalid_legacy' + ? effective.message.length + : stableJsonLength(effective.output), + mediaTokens: estimateEffectiveMediaTokens(effective, sessionId), + }; + effectiveToolResultSizes.set(content, size); + return size; +} + +export function estimateEffectiveToolResultChars( + content: Extract, + sessionId: string, +): number { + return effectiveToolResultSize(content, sessionId).chars; } export function estimateRuntimeEventChars(event: RuntimeEvent): number { @@ -186,16 +202,30 @@ export function estimateRuntimeEventChars(event: RuntimeEvent): number { return total; } +/** + * Tokens one event costs beyond its characters. Stays in tokens instead of + * folding into the char count: `charsPerToken` calibrates text, and applying it + * to media would make an image cheaper on a session with a low text ratio. + */ +function estimateRuntimeEventMediaTokens(event: RuntimeEvent): number { + const content = event.content; + return content?.kind === 'function_response' + ? effectiveToolResultSize(content, event.sessionId).mediaTokens + : 0; +} + export function estimateRuntimeEventsTokens( events: readonly RuntimeEvent[], charsPerToken = 4, ): number { - const chars = events.reduce( - (total, event) => - event.modelVisibility === 'hidden' ? total : total + estimateRuntimeEventChars(event), - 0, - ); - return estimateTokens(chars, charsPerToken); + let chars = 0; + let mediaTokens = 0; + for (const event of events) { + if (event.modelVisibility === 'hidden') continue; + chars += estimateRuntimeEventChars(event); + mediaTokens += estimateRuntimeEventMediaTokens(event); + } + return estimateTokens(chars, charsPerToken) + mediaTokens; } export function groupEventsByTurn( diff --git a/packages/runtime/src/provider-image-overflow-recovery.ts b/packages/runtime/src/provider-image-overflow-recovery.ts index 71a90e5e66..2dfc3215e3 100644 --- a/packages/runtime/src/provider-image-overflow-recovery.ts +++ b/packages/runtime/src/provider-image-overflow-recovery.ts @@ -19,10 +19,17 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ModelMessage } from './model-protocol.js'; +import { + decodeEffectiveToolResultProjection, + effectiveToolResultMedia, + estimateEffectiveMediaTokens, +} from './durable-tool-result-projection.js'; export interface HistoricalImageToolResult { toolName: string; artifactLabel: string; + /** What dropping this result's images gives back, by the same ruler the budget uses. */ + estimatedTokens: number; } export interface HistoricalImageOmissionResult { @@ -37,32 +44,11 @@ function isRecord(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object'; } -function storageRefLabel(value: unknown): string | undefined { - if (!isRecord(value) || typeof value.kind !== 'string') return undefined; - if ( - (value.kind === 'session_file' || value.kind === 'workspace_file') && - typeof value.relativePath === 'string' && - value.relativePath.length > 0 - ) { - return value.relativePath; - } - if ( - value.kind === 'session_context' && - typeof value.refId === 'string' && - value.refId.length > 0 - ) { - return value.refId; - } - if ( - value.kind === 'external_file' && - typeof value.absolutePath === 'string' && - value.absolutePath.length > 0 - ) { - return value.absolutePath; - } - return undefined; -} - +/** + * The image Tool Results a rejected request can give back, read through the + * same decode the budget measures — not from the raw execution fact, which the + * durable projection may already have bounded or redacted. + */ export function collectHistoricalImageToolResults( events: readonly RuntimeEvent[], ): Map { @@ -70,22 +56,40 @@ export function collectHistoricalImageToolResults( for (const event of events) { const content = event.content; if (content?.kind !== 'function_response' || content.isError === true) continue; - const result = content.result; - if ( - !isRecord(result) || - result.kind !== 'image' || - typeof result.mimeType !== 'string' || - result.mimeType.length === 0 - ) { - continue; - } - const artifactLabel = storageRefLabel(result.ref); - if (!artifactLabel) continue; - collected.set(content.id, { toolName: content.name, artifactLabel }); + const effective = decodeEffectiveToolResultProjection(content, event.sessionId); + const [media] = effectiveToolResultMedia(effective, event.sessionId); + if (!media || media.label.length === 0) continue; + collected.set(content.id, { + toolName: content.name, + artifactLabel: media.label, + estimatedTokens: estimateEffectiveMediaTokens(effective, event.sessionId), + }); } return collected; } +/** + * The cheapest set of image Tool Results whose removal covers `targetTokens`, + * largest first. An overflow is a fixed overshoot, so dropping everything the + * model can still see costs visual context the retry never needed. An unknown + * target keeps the old all-or-nothing behaviour. + */ +export function selectHistoricalImageOmissions( + eligible: ReadonlyMap, + targetTokens: number | undefined, +): Map { + if (targetTokens === undefined || targetTokens <= 0) return new Map(eligible); + const selected = new Map(); + let covered = 0; + const byCostDesc = [...eligible].sort(([, a], [, b]) => b.estimatedTokens - a.estimatedTokens); + for (const [toolCallId, image] of byCostDesc) { + if (covered >= targetTokens) break; + selected.set(toolCallId, image); + covered += image.estimatedTokens; + } + return selected; +} + function isInlineImageFilePart(value: unknown): value is UnknownRecord { if ( !isRecord(value) || diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index f96588a4e8..08f38d186b 100644 --- a/packages/runtime/src/tool-result-archive-transition.ts +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -56,7 +56,10 @@ import { turnKey, utf8ByteLength, } from './context-budget-helpers.js'; -import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import { + durableProjectionToToolResultOutput, + estimateProjectionMediaTokens, +} from './durable-tool-result-projection.js'; import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js'; import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, @@ -90,6 +93,23 @@ export function serializedToolResultProjection(projection: DurableToolResultProj ); } +/** + * What one Tool Result costs the request, in tokens. + * + * Its serialized bytes plus the media its artifact parts rehydrate into. An + * artifact serializes to a short reference and materializes to real image + * bytes, so measuring the string alone would price a screenshot at nothing. + */ +export function toolResultProjectionEstimatedTokens( + projection: DurableToolResultProjection, + serialized: string, + charsPerToken: number, +): number { + return ( + estimateTokens(serialized.length, charsPerToken) + estimateProjectionMediaTokens(projection) + ); +} + /** The replacement a pruned Tool Result projects to. */ export function archivedToolResultProjection( placeholder: ArchivedToolResultPlaceholder, @@ -289,7 +309,11 @@ export function collectStaleToolResultArchiveCandidates( if (!sourceProjection) continue; const serializedResult = serializedToolResultProjection(sourceProjection); const originalBytes = utf8ByteLength(serializedResult); - const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken); + const originalEstimatedTokens = toolResultProjectionEstimatedTokens( + sourceProjection, + serializedResult, + charsPerToken, + ); if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; candidates.push({ runtimeEventId: event.id, From fa5c1a9d5625fb95d455a197ff821da06b5bb2f0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 23:53:28 +0800 Subject: [PATCH 2/9] fix(runtime): measure a materialized image by what it bills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn payload measure is `JSON.stringify(messages).length`, and materialization has already turned every artifact reference into real bytes by the time it runs. A 200 KB screenshot reaches the request as base64, so the measure priced it at ~67,000 tokens against a provider that charges a few thousand. Under the 48,384-token fallback capacity — which `policy_fallback` enforces from step 0 — one image was enough to end the turn before a single provider call (#4458). The policy was never wrong: an estimate anchored on the provider's own input count should stop a request that cannot fit. The ruler was. This substitutes the same per-modality constant the ledger's ruler uses for a media part's serialized bytes, so both measures answer the same question and the capacity contract keeps working — no test in that reviewed contract changes. Refs #4458, #4283 Generated-by: Claude Code --- .../mid-turn-capacity-backend.test.ts | 22 ++++++++++++ packages/runtime/src/ai-sdk-compaction.ts | 36 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) 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 ce4bc494c6..207e17f51b 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1476,6 +1476,28 @@ describe('mid-turn capacity default-on safety guards (issue #882 PR 3)', () => { assert.equal(promptJson(fixture, 2).includes('RAW_SPAN_ONE_'), true); }); + test('measures a materialized image by what it bills, not by its serialized bytes', async () => { + // apache/maka#4458. The unknown model's 48,384-token fallback capacity is + // enforced from step 0. A 200 KB screenshot reaches the request as base64, + // so measuring the serialized payload prices these two images at ~130,000 + // tokens and ends the turn before a single provider call — while the + // provider itself would charge a few thousand. + const fixture = buildFixture({ + withoutContextWindow: true, + currentImage: true, + imageBytes: 200_000, + priorShape: 'image_tool', + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.model.doStreamCalls.length > 0, true); + const complete = fixture.events.find((event) => event.type === 'complete'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); + // Nothing was folded or dropped: at the real cost the request fits. + assert.equal(fixture.summarizerCalls, 0); + assert.doesNotMatch(promptJson(fixture, 0), /omitted after provider context overflow/); + }); + test('compacts one oversized prior turn before an unknown-model request', async () => { const fixture = buildFixture({ useRuntimeDefaultPolicy: true, diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 7416d125c2..b97b479c83 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -116,6 +116,7 @@ import { resolveContextBudgetCapacity, type ContextBudgetCapacity, } from './context-budget-policy.js'; +import { MATERIALIZED_IMAGE_TOKENS } from '@maka/core/attachments'; import { collectHistoricalImageToolResults, type HistoricalImageToolResult, @@ -979,6 +980,7 @@ export class AiSdkCompaction { providerTools, activeToolsForStep, systemPromptChars, + charsPerToken, ); const forcedEstimate = state.forcedTriggerEstimate; state.forcedTriggerEstimate = undefined; @@ -1263,6 +1265,7 @@ export class AiSdkCompaction { providerTools, activeToolsForStep, systemPromptChars, + charsPerToken, ); if (replacedPayloadChars >= input.referencePayloadChars) { return { @@ -1393,6 +1396,7 @@ export class AiSdkCompaction { input.providerTools, input.activeTools, input.systemPromptChars, + this.input.contextBudget?.charsPerToken ?? 4, ); const phase = input.stepNumber === 0 ? 'pre_turn' : 'mid_turn'; const outcome = await this.compactActiveRequestHistory({ @@ -1516,6 +1520,7 @@ export class AiSdkCompaction { providerTools, result?.activeTools ?? options.activeTools ?? fallbackActiveTools(), systemPromptChars, + charsPerToken, ); let payloadChars = finalPayloadChars(); if ( @@ -1797,20 +1802,49 @@ export class MidTurnCapacityCompactState { * requests — signed deltas cancel it — but the cold-start estimate (no usable * usage sample) is the whole payload, so omitting it would under-estimate by * exactly the system prompt and let an over-window request stream. + * + * A media part is worth what the provider charges for it, not what it + * serializes to: materialized bytes reach the request as base64 or a byte map, + * where a 500 KB screenshot serializes to ~667K chars. Measuring that string + * makes one image look like a whole context window, which is how an affordable + * request became a terminal verdict (#4458). Billing it at the same constant + * the ledger's ruler uses keeps the two measures commensurable. */ function midTurnRequestPayloadChars( messages: readonly ModelMessage[], providerTools: readonly MakaTool[], activeTools: readonly string[], systemPromptChars: number, + charsPerToken: number, ): number { + let mediaParts = 0; + const serializedMessages = JSON.stringify(messages, (_key, value) => { + if (!isMaterializedMediaPart(value)) return value; + mediaParts += 1; + return { type: value.type, mediaType: value.mediaType }; + }); return ( Math.max(0, Math.floor(systemPromptChars)) + - JSON.stringify(messages).length + + (serializedMessages?.length ?? 0) + + mediaParts * MATERIALIZED_IMAGE_TOKENS * Math.max(1, charsPerToken) + toolSchemaCharsForDiagnostics(providerTools, activeTools) ); } +/** A `file` part carrying inline bytes, as opposed to a URL the provider fetches. */ +function isMaterializedMediaPart( + value: unknown, +): value is { type: 'file'; mediaType: string; data: object } { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const part = value as { type?: unknown; mediaType?: unknown; data?: unknown }; + return ( + part.type === 'file' && + typeof part.mediaType === 'string' && + part.data !== null && + typeof part.data === 'object' + ); +} + /** * Outcome of folding the durable turn ledger into a replacement projection. * Shared by the proactive projection stage (which maps it to keepProjection / From 84c1da9b62b65bca68e42b4372374a09d79ae4d3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 00:15:41 +0800 Subject: [PATCH 3/9] fix(runtime): stop inventing a context window nobody declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveContextBudgetCapacity` answered "what is this model's context window?" by adding the policy's 32,000-token history budget to its 16,384-token compaction reserve and calling the sum 48,384. Both inputs are choices about how much history to keep. Neither is a fact about the model, and the sum is a fact about nothing. It then cost twice. The fabricated number got step-0 enforcement that a declared window does not, because `source === 'policy_fallback'` was threaded into the verdict — one consumer, existing only to compensate for the fabrication. And where nothing could be fabricated at all (DeepSeek publishes no window and its policy sets no history budget), the capacity came back undefined, which skipped mid-turn state entirely — leaving the one provider with no proactive threshold ALSO without reactive overflow recovery, which needs no window because it runs off a real rejection. Capacity is now the declared window or nothing. An undeclared window is a mode, not a number: no proactive threshold, no verdict, no summarizer input ceiling — and recovery all the same. `ContextBudgetCapacity` and its `source` discriminator are gone with the fabrication that needed them. Refs #4458, #4283 Generated-by: Claude Code --- .../context-budget-mid-turn-policy.test.ts | 11 ++--- .../mid-turn-capacity-backend.test.ts | 14 ++++--- .../overflow-reactive-recovery.test.ts | 38 ++++++++++++++++- .../runtime/src/ai-sdk-compaction-contract.ts | 5 ++- packages/runtime/src/ai-sdk-compaction.ts | 42 +++++++------------ packages/runtime/src/context-budget-policy.ts | 21 ---------- 6 files changed, 70 insertions(+), 61 deletions(-) diff --git a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts index ef483bedb7..6f36d1ea44 100644 --- a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts +++ b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts @@ -22,7 +22,7 @@ import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { buildDefaultContextBudgetPolicy, - resolveContextBudgetCapacity, + resolveSelectedModelContextWindow, } from '../context-budget-policy.js'; test('context policy is independent of process environment overrides', () => { @@ -96,17 +96,18 @@ describe('window-bounded reserve derivation (issue #882 PR 3 review P2)', () => // No window: the flat 32_000 fallback budget and the classic reserve. assert.equal(policy?.maxHistoryEstimatedTokens, 32_000); assert.deepEqual(policy?.historyCompact?.midTurn, { enabled: true, reserveTokens: 16_384 }); - assert.deepEqual( - resolveContextBudgetCapacity( + // Both are policy choices about how much history to keep. Neither is a + // fact about the model, so nothing derives a context window from them. + assert.equal( + resolveSelectedModelContextWindow( { ...gpt4Connection(), defaultModel: 'custom-model', models: [{ id: 'custom-model' }], } as LlmConnection, 'custom-model', - policy, ), - { tokens: 48_384, source: 'policy_fallback' }, + undefined, ); }); }); 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 207e17f51b..4ea6b2dde9 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1579,7 +1579,12 @@ describe('mid-turn capacity default-on safety guards (issue #882 PR 3)', () => { assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); - test('rejects an oversized complete step-zero payload before provider dispatch', async () => { + test('lets an undeclared window dispatch a payload no local number can judge', async () => { + // A 200,000-char system prompt against a model that declares no window. + // The runtime used to add its 32,000-token history budget to the 16,384 + // reserve, call the sum a context window, and end the turn on it. Both are + // policy choices about how much history to keep; neither says what this + // model accepts, so the request goes out and the provider answers. const fixture = buildFixture({ useRuntimeDefaultPolicy: true, withoutContextWindow: true, @@ -1587,12 +1592,9 @@ describe('mid-turn capacity default-on safety guards (issue #882 PR 3)', () => { }); await runFixtureTurn(fixture); - assert.equal(fixture.model.doStreamCalls.length, 0); + assert.equal(fixture.model.doStreamCalls.length > 0, true); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); test('keeps user_stop when stopping an oversized pre-turn summary', async () => { diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index f1c2ebc488..753b7437b6 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -128,6 +128,11 @@ const BIG_RESULT = 'BIG_RESULT_'.repeat(200); interface ReactiveFixtureOptions { script: CallKind[]; contextWindow?: number; + /** + * A model that declares no context window, on a provider whose default + * policy sets no history budget either — so nothing can synthesize one. + */ + withoutContextWindow?: boolean; reserveTokens?: number; midTurnEnabled?: boolean; withoutPriorTurns?: boolean; @@ -582,7 +587,12 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture ...(options.providerNative ? { slug: 'codex-subscription', providerType: 'openai-codex' as const } : {}), - models: [{ id: 'mock-model-id', contextWindow }], + ...(options.withoutContextWindow ? { providerType: 'deepseek' as const } : {}), + models: [ + options.withoutContextWindow + ? { id: 'mock-model-id' } + : { id: 'mock-model-id', contextWindow }, + ], }, apiKey: 'sk-test', ...(options.reasoningReplayTail ? { providerStateIdentity: PROVIDER_STATE_IDENTITY } : {}), @@ -638,7 +648,9 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture : {}), contextBudget: { name: 'reactive-test', - maxHistoryEstimatedTokens: 100_000, + // An undeclared window on this provider carries no history budget either, + // matching what the default policy builds for it. + ...(options.withoutContextWindow ? {} : { maxHistoryEstimatedTokens: 100_000 }), historyCompact: { enabled: true, ...(midTurnEnabled ? { midTurn: { enabled: true, reserveTokens } } : {}), @@ -1371,6 +1383,28 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.recorded.length, 0); }); + test('recovers a model whose context window nothing can supply', async () => { + // DeepSeek publishes no window and its default policy sets no history + // budget, so no number could be synthesized and mid-turn state was skipped + // entirely. That left the one provider with no proactive threshold ALSO + // without reactive recovery. The overflow is a real provider rejection, so + // recovery needs no window of its own. + const fixture = buildReactiveFixture({ + script: ['tool', 'overflow', 'done'], + bigPriors: true, + withoutContextWindow: true, + }); + await runTurn(fixture); + + assert.equal(complete(fixture)?.stopReason, 'end_turn'); + assert.equal( + fixture.events.some((event) => event.type === 'error'), + false, + ); + assert.equal(fixture.recorded.length, 1); + assert.equal(fixture.recorded[0]!.phase, 'mid_turn'); + }); + test('recovers from a plain-object in-stream error part, not just Error instances (review round-8 P1-1)', async () => { // Providers deliver in-stream failures as parsed plain objects (or bare // strings), never Error instances. The recovery decision must classify diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index 4f009b1e08..71a65895d8 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -47,9 +47,12 @@ export interface HistoryCompactSummaryInput { /** * Estimated provider-input ceiling for this compaction call. A compactor * should fail before dispatch when its projection cannot fit this budget. + * The ceiling is absent when the selected model declares no context window: + * there is no honest number to fit against, and inventing one is what #4458 + * was about. */ inputBudget?: { - maxEstimatedTokens: number; + maxEstimatedTokens?: number; charsPerToken: number; }; abortSignal?: AbortSignal; diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index b97b479c83..cd547103da 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -112,10 +112,7 @@ import { exceedsHighWater, planHistoryCompaction, } from './history-compaction.js'; -import { - resolveContextBudgetCapacity, - type ContextBudgetCapacity, -} from './context-budget-policy.js'; +import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; import { MATERIALIZED_IMAGE_TOKENS } from '@maka/core/attachments'; import { collectHistoricalImageToolResults, @@ -805,7 +802,10 @@ export class AiSdkCompaction { * Mid-turn capacity compaction eligibility (issue #882 PR 1). Explicit * opt-in via `historyCompact.midTurn.enabled`; requires the checkpoint * writer seams plus the durable turn-ledger read, the persisted head anchor - * for this turn, and a bounded capacity window. + * for this turn. A model that declares no context window still gets this + * state: without a window there is nothing to enforce proactively, but + * reactive recovery runs off a real provider rejection and needs no window + * at all — gating the state on one is what left those models with neither. */ public buildMidTurnCapacityCompactState( input: BackendSendInput, @@ -836,12 +836,6 @@ export class AiSdkCompaction { ) { return undefined; } - const capacity = resolveContextBudgetCapacity( - this.input.connection, - this.input.modelId, - policy, - ); - if (capacity === undefined) return undefined; const priorContentEvents = (input.runtimeContext ?? []) .filter((event) => event.turnId !== input.turnId) .filter(isHistoryCompactContentEvent); @@ -849,7 +843,7 @@ export class AiSdkCompaction { headAnchor, priorContentEvents, input.runtimeContextRunHeaders ?? [], - capacity, + resolveSelectedModelContextWindow(this.input.connection, this.input.modelId), ); } @@ -996,7 +990,7 @@ export class AiSdkCompaction { }); if ( forcedEstimate === undefined && - !exceedsHighWater(estimate, state.capacity.tokens, reserveTokens) + (state.capacity === undefined || !exceedsHighWater(estimate, state.capacity, reserveTokens)) ) { return keepProjection(); } @@ -1200,7 +1194,9 @@ export class AiSdkCompaction { ...(previousCheckpoint ? { previousCheckpoint } : {}), newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], inputBudget: { - maxEstimatedTokens: Math.max(1, state.capacity.tokens - reserveTokens), + ...(state.capacity !== undefined + ? { maxEstimatedTokens: Math.max(1, state.capacity - reserveTokens) } + : {}), charsPerToken, }, ...(abortSignal ? { abortSignal } : {}), @@ -1362,7 +1358,7 @@ export class AiSdkCompaction { // Without a usable count nothing bounds the drop, so it stays all-or-nothing. const overshootTokens = state.lastRequestInputTokens !== undefined - ? state.lastRequestInputTokens - state.capacity.tokens + ? state.lastRequestInputTokens - (state.capacity ?? state.lastRequestInputTokens) : undefined; const eligibleImages = selectHistoricalImageOmissions( collectHistoricalImageToolResults(state.priorContentEvents), @@ -1523,10 +1519,7 @@ export class AiSdkCompaction { charsPerToken, ); let payloadChars = finalPayloadChars(); - if ( - (options.stepNumber >= 1 || state.capacity.source === 'policy_fallback') && - !state.exhaustedDetail - ) { + if (state.capacity !== undefined && options.stepNumber >= 1 && !state.exhaustedDetail) { const estimateFinal = (): number => estimateNextRequestTokens({ ...(state.lastRequestInputTokens !== undefined @@ -1540,11 +1533,7 @@ export class AiSdkCompaction { const capacityAttemptedThisStep = state.replacedStepNumber === options.stepNumber || state.lastShapeFailure?.stepNumber === options.stepNumber; - if ( - options.stepNumber >= 1 && - estimate > state.capacity.tokens && - !capacityAttemptedThisStep - ) { + if (options.stepNumber >= 1 && estimate > state.capacity && !capacityAttemptedThisStep) { // One bounded capacity re-entry: the trigger threshold is // approximate on purpose (recoverable), so a miss must become a // rescue attempt before it can become a terminal verdict. Re-run @@ -1571,7 +1560,7 @@ export class AiSdkCompaction { payloadChars = finalPayloadChars(); estimate = estimateFinal(); } - if (estimate > state.capacity.tokens) { + if (estimate > state.capacity) { const failure = state.lastShapeFailure?.stepNumber === options.stepNumber ? state.lastShapeFailure @@ -1786,7 +1775,8 @@ export class MidTurnCapacityCompactState { readonly headAnchor: RuntimeEvent, readonly priorContentEvents: readonly RuntimeEvent[], readonly priorRunHeaders: readonly AgentRunHeader[], - readonly capacity: ContextBudgetCapacity, + /** The model's declared context window, absent when it declares none. */ + readonly capacity: number | undefined, ) {} } diff --git a/packages/runtime/src/context-budget-policy.ts b/packages/runtime/src/context-budget-policy.ts index ef94524602..507e613209 100644 --- a/packages/runtime/src/context-budget-policy.ts +++ b/packages/runtime/src/context-budget-policy.ts @@ -126,24 +126,3 @@ function narrowestPositiveLimit(...values: Array): number | ); return positiveValues.length > 0 ? Math.min(...positiveValues) : undefined; } - -export interface ContextBudgetCapacity { - tokens: number; - source: 'selected_model' | 'policy_fallback'; -} - -export function resolveContextBudgetCapacity( - connection: RuntimeExecutionConnection, - modelId: string | undefined, - policy: ContextBudgetPolicy | undefined, -): ContextBudgetCapacity | undefined { - const selectedWindow = resolveSelectedModelContextWindow(connection, modelId); - if (selectedWindow !== undefined) { - return { tokens: selectedWindow, source: 'selected_model' }; - } - - const historyBudget = finitePositive(policy?.maxHistoryEstimatedTokens); - const reserveTokens = finitePositive(policy?.historyCompact?.midTurn?.reserveTokens); - if (historyBudget === undefined || reserveTokens === undefined) return undefined; - return { tokens: historyBudget + reserveTokens, source: 'policy_fallback' }; -} From 5da2071f50744be64466f031299276cf78504572 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 01:41:06 +0800 Subject: [PATCH 4/9] fix(runtime): let the provider decide whether a request fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local estimate could end a live turn with zero provider calls, through two gates: the pre-turn history budget and the mid-turn capacity verdict. Both answered a question only the provider can answer, and both answered it from a number nobody measured. Delete both. The estimate keeps its one legitimate job — deciding when to compact early — and a rejection is recovered from by compacting and retrying once. The bounded capacity re-entry stays: it is reversible. `context_budget_exhausted` survives as a CompleteStopReason so persisted sessions still decode and present, but nothing produces it any more. This also dissolves the reason media sizing needed a trustworthy number: every consumer of MATERIALIZED_IMAGE_TOKENS is now reversible, so a flat constant that errs high can only ever buy a compaction. Deleted with the verdict: `exhaustedDetail` and its four branches, `ActiveRequestCompactionOutcome`'s terminal detail and its eleven producers, and the shape-failure record's detail. Removed alongside, all unreachable in production: targeted image omission (its overshoot came from a request the provider ACCEPTED, so the target was never positive), the duplicate inline-image predicate, the media pricing in active-tool-result-prune (extractPayload returns early for the content shape every image result has), and two dead imports. Losing those consumers leaves the media sizing wrappers with one caller each, so `estimateProjectionMediaTokens`, `estimateEffectiveMediaTokens` and `toolResultProjectionEstimatedTokens` fold into the two call sites that remain. Test coverage lost, stated rather than hidden: the cold-start estimate's system-prompt term had the verdict as its only observable, and its fixture suppresses usage so no diagnostic exists to read instead. Refs #4458, #4283 --- .../src/__tests__/ai-sdk-backend.test.ts | 8 +- .../mid-turn-capacity-backend.test.ts | 271 +++--------------- .../overflow-reactive-recovery.test.ts | 34 +-- .../provider-image-overflow-recovery.test.ts | 40 +-- .../runtime/src/active-tool-result-prune.ts | 7 +- packages/runtime/src/ai-sdk-backend.ts | 93 +----- .../runtime/src/ai-sdk-compaction-contract.ts | 4 +- packages/runtime/src/ai-sdk-compaction.ts | 173 +++-------- packages/runtime/src/context-budget-policy.ts | 1 - .../src/durable-tool-result-projection.ts | 21 +- packages/runtime/src/history-compaction.ts | 18 +- packages/runtime/src/model-history.ts | 5 +- .../src/provider-image-overflow-recovery.ts | 36 +-- .../src/tool-result-archive-transition.ts | 30 +- 14 files changed, 127 insertions(+), 614 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index eef8f01c1b..a021546196 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6006,7 +6006,7 @@ describe('AiSdkBackend model history', () => { ); }); - test('blank summary preserves history and stops before an oversized request', async () => { + test('a blank summary preserves history and never records a checkpoint', async () => { const model = completionModel(); const storedMessages: StoredMessage[] = []; const events: SessionEvent[] = []; @@ -6067,12 +6067,14 @@ describe('AiSdkBackend model history', () => { events.push(event); } - assert.equal(model.doStreamCalls.length, 0); + // A blank summary is not a checkpoint; the raw history goes out unchanged. assert.equal(recordCalls, 0); + assert.equal(model.doStreamCalls.length, 1); + assert.match(JSON.stringify(model.doStreamCalls[0]?.prompt), /BLANK_RETAINED_TAIL/); const terminal = events.find( (event): event is Extract => event.type === 'complete', ); - assert.equal(terminal?.stopReason, 'context_budget_exhausted'); + assert.equal(terminal?.stopReason, 'end_turn'); }); test('replays a matching Codex V3 checkpoint as native provider state', async () => { 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 4ea6b2dde9..60a832629e 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -817,9 +817,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.equal(replayJson.includes('RAW_SPAN_TWO_'), true); }); - test('ends the turn with context_budget_exhausted when over the window with no safe span', async () => { + 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. + // 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. const fixture = buildFixture({ contextWindow: 120, reserveTokens: 100, @@ -828,18 +830,12 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { await runFixtureTurn(fixture, consumer); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // Explicit outcome, not a raw provider error. + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); assert.equal( fixture.events.some((event) => event.type === 'error'), false, ); - // The over-budget request was aborted before it could stream (the second - // doStream attempt sees an already-aborted signal and rejects). - assert.equal(fixture.model.doStreamCalls.length <= 2, true); + assert.equal(fixture.model.doStreamCalls.length, 3); assert.equal( fixture.events.some( (event) => @@ -847,11 +843,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { event.toolName === 'Read' && JSON.stringify(event.args).includes('two.md'), ), - false, + true, ); }); - test('preserves no_safe_completed_span when no summary input can fit', async () => { + test('a summary that cannot fit its own input fails open and still dispatches', async () => { const fixture = buildFixture({ contextWindow: 150, reserveTokens: 100, @@ -864,57 +860,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { await runFixtureTurn(fixture, consumer); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); assert.equal(fixture.summarizerCalls > 0, true); }); - test('ends the turn with summarizer_failed detail when over the window and the summary fails', async () => { - // Estimate at the first boundary ≈ 120 real usage + result chars/4 ≈ 200; - // window 150 puts it over the hard cap while priors leave a safe span. - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - summarize: () => { - throw new Error('summarizer down'); - }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - }); - - test('ends the turn with head_anchor_exceeds_capacity when even the minimal projection cannot fit', async () => { - // Big priors leave a safe span, the summary succeeds, and the fold - // GENUINELY shrinks the payload — but the last request's real input - // (1400 tokens) is so large that even the [block, anchor, open pair] - // projection stays over the 150-token window: the irreducible remainder - // exceeds capacity. (A non-shrinking fold is a different failure — - // summarizer_failed via replacement_not_smaller.) - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - priorChars: 2_000, - firstStepUsage: { input: 1_400, output: 20 }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'head_anchor_exceeds_capacity'); - // The fold itself was valid and durable; it just could not rescue. - assert.equal(fixture.recorded.length, 1); - }); - - test('fails open with write_failed diagnostics when the checkpoint write fails under the window', async () => { + test('fails open with write_failed diagnostics when the checkpoint write fails', async () => { const fixture = buildFixture({ record: () => { throw new Error('disk full'); @@ -975,41 +925,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { await runFixtureTurn(fixture, consumer); + // One repair budget for the whole turn: a later step never re-runs it. assert.equal(fixture.summarizerCalls, 1); assert.equal(fixture.recorded.length, 0); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'malformed_summary_missing_section'); - }); - - test('exhausts with write_failed in the durable diagnostics when the write fails over the window', async () => { - // Big priors make folding rescue the over-window estimate, so the plan - // compacts and the failure happens AT the recorder — over the window that - // is the explicit exhausted outcome, and the durable diagnostics must - // carry write_failed even though the terminal enum has no write member. - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - priorChars: 2_000, - record: () => { - throw new Error('disk full'); - }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - - const lastCall = fixture.llmCalls.at(-1); - const exhaustedDecision = (lastCall?.contextBudget?.compactionDecisions ?? []).find( - (decision) => decision.phase === 'mid_turn' && decision.reason === 'context_budget_exhausted', - ); - assert.equal(exhaustedDecision?.skippedReasonCounts?.write_failed, 1); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); test('fails closed before provider dispatch when the durable ledger read fails', async () => { @@ -1051,90 +971,20 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.match(thirdPrompt, /active_current_turn_tool_result_pruned_before_next_step/); }); - test('a rolling second compaction that still exceeds the window ends explicitly (review finding A)', async () => { - // Review round-3 finding A: the old post-fold re-estimate subtracted the - // RAW covered span from a usage estimate anchored to the ALREADY-compacted - // previous request, over-crediting the second fold and letting a - // still-over-window request stream. The final-payload owner measures the - // real replacement projection instead: the third step's huge result makes - // even [second block, anchor, tail] exceed the window, so the turn must - // end with the explicit outcome — never send the over-window request. + test('a second mid-turn compaction rolls forward from the first checkpoint', async () => { const fixture = buildFixture({ priorChars: 2_000, rollingOverflow: true }); await runFixtureTurn(fixture, consumer); - // The first fold happened and its projection was used (three requests ran). assert.equal(fixture.recorded.length, 2); assert.equal(fixture.recorded[0]?.phase, 'mid_turn'); - // The second fold rolled forward from the first checkpoint... assert.equal(fixture.recorded[1]?.previousCheckpointId, fixture.recorded[0]?.checkpointId); - // ...but its replacement still exceeds the window: explicit outcome. - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'head_anchor_exceeds_capacity'); - // The over-window fourth request never streamed. - assert.equal( - fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), - false, - ); }); - test('an aborted multi-step send records the accumulated usage of the completed steps', async () => { - // The terminal LLM-call record is fail-closed on usage evidence (#972), - // and an aborted send may never resolve the SDK's final usage promise. But - // every COMPLETED step reported real usage at its finish-step boundary, - // so the terminal record must carry that accumulated sum — the capacity - // verdict diagnostics ride this record and the completed steps' cost is - // real. Three steps stream (100/20 + 150/30 + 150/30) before the step-4 - // verdict aborts the send. - const fixture = buildFixture({ priorChars: 2_000, rollingOverflow: true }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal( - complete?.type === 'complete' ? complete.stopReason : undefined, - 'context_budget_exhausted', - ); - // Three requests streamed; the fourth is rejected locally before the - // provider adapter is called and therefore reports no usage. - assert.equal(fixture.model.doStreamCalls.length, 3); - const lastCall = fixture.llmCalls.at(-1); - assert.equal(lastCall?.status, 'error'); - assert.equal(lastCall?.errorClass, 'ContextBudgetExhausted'); - assert.equal(lastCall?.inputTokens, 400); - assert.equal(lastCall?.outputTokens, 80); - assert.equal(lastCall?.totalTokens, 480); - }); - - test('an unusable completed-step usage sample fails the whole record closed — no partial sum (review round-7)', async () => { - // #972 semantics: incomplete usage evidence fails closed. The first - // completed step's usage is unusable (normalization returns undefined), - // so the sum of the remaining steps (150/30 + 150/30) is a PARTIAL cost. - // LlmCallRecord has no partial marker — downstream reads any record as - // the whole call — so the truthful outcome is no record at all; the - // terminal result stays observable on the durable CompleteEvent. - const fixture = buildFixture({ - priorChars: 2_000, - rollingOverflow: true, - firstStepUsage: 'missing', - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal( - complete?.type === 'complete' ? complete.stopReason : undefined, - 'context_budget_exhausted', - ); - assert.equal(fixture.llmCalls.length, 0); - }); - - test('the verdict is issued after pruning — a prune-rescuable step is not exhausted (review finding C)', async () => { + test('a prune-rescuable step is rescued by the prune, not compacted (review finding C)', async () => { // Review round-3 finding C repro: one huge tool result, no safe completed // span for the capacity hook, but the active tool-result prune (which runs // AFTER the capacity hook) archives the result down to a placeholder that - // fits the window. A verdict inside the capacity hook would have declared - // context_budget_exhausted before the rescue could run. + // fits the window. const fixture = buildFixture({ contextWindow: 500, reserveTokens: 100, @@ -1162,9 +1012,8 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { test('the trigger counts same-turn tool-schema growth from tool_search (review finding D)', async () => { // Review round-3 finding D repro: the model activates a ~12.7k-char tool // group mid-turn. The schema lands in every later request, so the payload - // estimate must count it: the next request cannot fit the 500-token window - // and the pool has no safe completed span, so the turn ends explicitly - // instead of streaming a ~3k-token request into a 500-token window. + // estimate must count it — without that the 500-token window is never + // crossed and the trigger never fires at all. const fixture = buildFixture({ contextWindow: 500, reserveTokens: 100, @@ -1173,16 +1022,11 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { }); await runFixtureTurn(fixture, consumer); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // The over-window second request never streamed the expanded schema. - assert.equal( - fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), - false, + // The pool has no safe completed span, so the fired trigger fails open. + const failedOpen = compactionDecisions(fixture).find( + (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', ); + assert.equal(failedOpen?.failOpenReason, 'no_safe_completed_span'); }); test('a fold that cannot shrink the real payload is refused, not applied (runaway summary)', async () => { @@ -1260,11 +1104,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); - test('an over-window runaway summary terminates as summarizer_failed, not head_anchor_exceeds_capacity (review finding 4)', async () => { - // A non-shrinking replacement proves the summarizer's output is unusable, - // not that the irreducible remainder (anchor + overhead) exceeds - // capacity — the terminal detail must say so; the diagnostic reason keeps - // the precise replacement_not_smaller cause. + test('a runaway summary is rejected as replacement_not_smaller and never persisted', async () => { const fixture = buildFixture({ contextWindow: 150, reserveTokens: 100, @@ -1273,54 +1113,13 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { }); await runFixtureTurn(fixture, consumer); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - const lastCall = fixture.llmCalls.at(-1); - const exhaustedDecision = (lastCall?.contextBudget?.compactionDecisions ?? []).find( - (decision) => decision.phase === 'mid_turn' && decision.reason === 'context_budget_exhausted', + const failedOpen = compactionDecisions(fixture).find( + (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', ); - assert.equal(exhaustedDecision?.skippedReasonCounts?.replacement_not_smaller, 1); - // The rejected checkpoint was never persisted. + assert.equal(failedOpen?.failOpenReason, 'replacement_not_smaller'); assert.equal(fixture.recorded.length, 0); }); - test('the cold-start estimate covers the FULL provider input including the system prompt (review round-5 finding 2)', async () => { - // The system prompt travels in the separate `system` field, not in - // messages. With usage missing, a cold-start estimate over messages+tools - // alone (~2150 tokens) stays under the 2900 high water and lets a real - // ~3650-token request stream into a 3000-token window. The single payload - // measure must include the system prompt: constant between adjacent - // requests (signed deltas unaffected), decisive for cold start. - const fixture = buildFixture({ - contextWindow: 3_000, - reserveTokens: 100, - withoutPriorTurns: true, - hugeFirstResult: true, - finalAtSecondCall: true, - firstStepUsage: 'missing', - systemPromptChars: 6_000, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // The over-window second request never streamed. - assert.equal( - fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), - false, - ); - // Every completed step's usage was unusable, so there is no usage - // evidence at all: the fail-closed terminal record is skipped and the - // exhausted outcome is observable only through the CompleteEvent above. - assert.equal(fixture.llmCalls.length, 0); - }); - test("a completed step's assistant text is never dropped from the replacement (review finding B)", async () => { // Review round-3 finding B repro: the FIRST step emits assistant text AND // a tool call, and the trigger fires at that step's own boundary. The old @@ -1520,7 +1319,7 @@ describe('mid-turn capacity default-on safety guards (issue #882 PR 3)', () => { assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); - test('rejects one oversized prior turn locally when its summary fails', async () => { + test('dispatches an oversized prior turn when its summary fails, after trying once', async () => { const fixture = buildFixture({ useRuntimeDefaultPolicy: true, withoutContextWindow: true, @@ -1531,12 +1330,9 @@ describe('mid-turn capacity default-on safety guards (issue #882 PR 3)', () => { await runFixtureTurn(fixture); assert.equal(fixture.summarizerCalls, 1); - assert.equal(fixture.model.doStreamCalls.length, 0); + assert.equal(fixture.model.doStreamCalls.length > 0, true); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); }); test('compacts an oversized latest turn when an older turn is also retained', async () => { @@ -1710,11 +1506,10 @@ describe('the shipped runtime default drives the proactive long-turn journey (is ); }); - test('an unrescuable turn under the shipped default ends with the explicit context_budget_exhausted outcome', async () => { + test('an unrescuable turn under the shipped default still dispatches', async () => { // Same runtime-derived default (window 120 → reserve 30, high water 90): // no prior turns leaves no safe completed span, and the request genuinely - // exceeds the window — the turn must end with the first-class outcome, not - // a raw provider error. + // exceeds the local window — which only the provider can act on. const fixture = buildFixture({ useRuntimeDefaultPolicy: true, contextWindow: 120, @@ -1723,10 +1518,8 @@ describe('the shipped runtime default drives the proactive long-turn journey (is await runFixtureTurn(fixture); const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); + assert.equal(fixture.model.doStreamCalls.length > 0, true); assert.equal( fixture.events.some((event) => event.type === 'error'), false, diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 753b7437b6..573f3d9137 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -1784,20 +1784,15 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.events.filter((event) => event.type === 'steering_message').length, 1); }); - test('a checkpoint fold never sneaks an injected steering message past the capacity verdict', async () => { - // Round-5 F2: injected steering is PINNED out of the foldable span. If a - // mid-turn fold could cover it, the verdict would credit the fold with - // chars the request never actually sheds (the accumulator re-appends the - // directive), passing a request whose real payload it never measured. + test('a checkpoint fold never covers an injected steering message', async () => { + // Round-5 F2: injected steering is PINNED out of the foldable span, so a + // measurement of the folded request is a measurement of what the provider + // actually receives — the accumulator re-appends the directive either way. // - // Scenario: steer once at step 1 (6k chars — folds fine, stays in the - // tail, measured). At step 2 a second, window-breaking steer (12k chars) - // arrives and the fold's cut can now reach PAST the first steering - // event. Unpinned, the fold covers it, the verdict sees a shrunken - // payload and passes, and the post-verdict re-append ships an unmeasured - // over-window request to a happy end_turn. Pinned, the fold cannot cover - // it, the honest estimate exceeds the window, and the verdict terminates - // explicitly BEFORE the request goes out. + // Scenario: steer once at step 1 (6k chars), then again at step 2 (12k) + // so the fold's cut can reach PAST the first steering event. Unpinned, + // the fold would swallow the first steer and the request that goes out + // would carry chars nothing measured. const fixture = buildReactiveFixture({ script: ['tool', 'tool', 'done'], contextWindow: 2_000, @@ -1825,15 +1820,10 @@ describe('reactive overflow recovery in the streaming backend', () => { return []; }); - // The verdict measured the pinned, steering-inclusive payload and refused - // it explicitly instead of completing on an unmeasured over-window request - // (unpinned, the fold hides the first steer from the measurement and the - // turn ends happily on end_turn). The third request is rejected locally - // before the provider adapter is called. - assert.equal(complete(fixture)?.stopReason, 'context_budget_exhausted'); - assert.equal(fixture.model.doStreamCalls.length, 2); - // Both steers were durably delivered to the ledger before the verdict — - // they are owned by history, not lost. + // The first steer survives the fold verbatim in the last request: it was + // pinned out of the covered span, not summarized away. + assert.match(JSON.stringify(fixture.model.doStreamCalls.at(-1)?.prompt), /PIN_STEER_ONE/); + // Both steers were durably delivered to the ledger — owned by history. assert.equal(fixture.events.filter((event) => event.type === 'steering_message').length, 2); }); diff --git a/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts b/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts index a2534498ca..a68fa28348 100644 --- a/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts +++ b/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts @@ -26,7 +26,6 @@ import type { ModelMessage } from '../model-protocol.js'; import { collectHistoricalImageToolResults, omitHistoricalImageToolResults, - selectHistoricalImageOmissions, } from '../provider-image-overflow-recovery.js'; /** A pre-artifact image result: no durable projection, materialized raw. */ @@ -172,18 +171,6 @@ describe('provider image overflow recovery projection', () => { assert.match(prompt(result.messages), /read-image:owner-1/); }); - test('prices an image artifact by its materialized cost, not its reference text', () => { - const eligible = collectHistoricalImageToolResults([ - imageResultEvent('prior-image-call', { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-screenshot-1', - }), - ]); - - assert.equal(eligible.get('prior-image-call')?.estimatedTokens, 2000); - }); - test('still recovers a pre-artifact image result that has no durable projection', () => { const eligible = collectHistoricalImageToolResults([ legacyImageResultEvent('prior-image-call', 'screenshots/screenshot.png'), @@ -193,33 +180,8 @@ describe('provider image overflow recovery projection', () => { eligible, ); - assert.equal(eligible.get('prior-image-call')?.estimatedTokens, 2000); + assert.equal(eligible.size, 1); assert.equal(result.omittedParts, 1); assert.match(prompt(result.messages), /screenshots\/screenshot\.png/); }); - - test('drops the largest images only until the overshoot is covered', () => { - const eligible = collectHistoricalImageToolResults([ - imageResultEvent('call-a', { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-a', - }), - imageResultEvent('call-b', { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-b', - }), - imageResultEvent('call-c', { - kind: 'session_file', - sessionId: 'session-1', - relativePath: 'artifact-c', - }), - ]); - - assert.equal(selectHistoricalImageOmissions(eligible, 1).size, 1); - assert.equal(selectHistoricalImageOmissions(eligible, 2500).size, 2); - assert.equal(selectHistoricalImageOmissions(eligible, 99_000).size, 3); - assert.equal(selectHistoricalImageOmissions(eligible, undefined).size, 3); - }); }); diff --git a/packages/runtime/src/active-tool-result-prune.ts b/packages/runtime/src/active-tool-result-prune.ts index cc69ec449c..11a335e41e 100644 --- a/packages/runtime/src/active-tool-result-prune.ts +++ b/packages/runtime/src/active-tool-result-prune.ts @@ -52,7 +52,6 @@ import { import { archiveToolResultAsTransition, serializedToolResultProjection, - toolResultProjectionEstimatedTokens, type ToolResultArchiveTransitionServices, } from './tool-result-archive-transition.js'; import { @@ -290,11 +289,7 @@ async function rewriteToolResultPart(input: { if (!address || address.toolName !== part.toolName) return { changed: false }; const sourceProjection = address.projection; const serializedResult = serializedToolResultProjection(sourceProjection); - const originalEstimatedTokens = toolResultProjectionEstimatedTokens( - sourceProjection, - serializedResult, - input.charsPerToken, - ); + const originalEstimatedTokens = estimateTokens(serializedResult.length, input.charsPerToken); if ( input.supersession ? originalEstimatedTokens < input.minSupersededResultEstimatedTokens diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 69f83d569f..4713b885e0 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1030,21 +1030,15 @@ class TurnScope { ) {} } -type PriorReplayResult = - | { - status: 'ready'; - messages: ModelMessage[]; - gate: RuntimeEventReplayFallbackGate | 'stored_message_projection'; - diagnostics: RuntimeEventModelReplayPlan['diagnostics']; - runtimeEventCount?: number; - contextBudget?: ContextBudgetDiagnostic; - latestHistoryCompactCheckpoint?: HistoryCompactCheckpoint; - } - | { - status: 'context_budget_exhausted'; - detail: ContextBudgetExhaustedDetail; - contextBudget?: ContextBudgetDiagnostic; - }; +type PriorReplayResult = { + status: 'ready'; + messages: ModelMessage[]; + gate: RuntimeEventReplayFallbackGate | 'stored_message_projection'; + diagnostics: RuntimeEventModelReplayPlan['diagnostics']; + runtimeEventCount?: number; + contextBudget?: ContextBudgetDiagnostic; + latestHistoryCompactCheckpoint?: HistoryCompactCheckpoint; +}; export class AiSdkBackend implements AgentBackend { readonly kind: BackendKind = 'ai-sdk'; @@ -1736,20 +1730,6 @@ export class AiSdkBackend implements AgentBackend { yield* this.drain(queue); return; } - if (priorReplayResult.status === 'context_budget_exhausted') { - trace.modelStreamCompleted('context_budget_exhausted'); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: priorReplayResult.detail, - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } const priorReplay = priorReplayResult; if (input.continuation && priorReplay.messages.length === 0) { const replay = priorReplayFailureTrace(priorReplay); @@ -1994,7 +1974,7 @@ export class AiSdkBackend implements AgentBackend { // owner measures the final payload and decides pass/terminate. const requestProjection = midTurnState && midTurnCapacityHook && shapedProjection - ? this.compaction.buildMidTurnFinalRequestVerdict({ + ? this.compaction.buildMidTurnFinalRequestRescue({ shaped: shapedProjection, reentry: composeRequestProjection( undefined, @@ -2006,8 +1986,6 @@ export class AiSdkBackend implements AgentBackend { fallbackActiveTools: () => currentRepairToolNames(), charsPerToken: this.input.contextBudget?.charsPerToken ?? 4, systemPromptChars: midTurnSystemPromptChars, - onDiagnosticPatch: onMidTurnDiagnosticPatch, - abortController: turnAbortController, }) : shapedProjection; @@ -2038,11 +2016,6 @@ export class AiSdkBackend implements AgentBackend { messages: requestMessages, }) : undefined; - if (midTurnState?.exhaustedDetail) { - throw new Error( - `context budget exhausted before provider dispatch: ${midTurnState.exhaustedDetail}`, - ); - } const projectedMessages = shaped?.messages ?? requestMessages; const finalChildSummaryStep = this.input.header.collaborationMode === 'agent' && @@ -2402,7 +2375,7 @@ export class AiSdkBackend implements AgentBackend { settledWatchdogTimeout?.error ?? (providerOutcome.kind === 'completed' ? undefined : providerOutcome.failure); - if (attemptFailure && !scope.aborted && !midTurnState?.exhaustedDetail) { + if (attemptFailure && !scope.aborted) { const failure = settledWatchdogTimeout || providerOutcome.kind === 'completed' ? this.modelAdapter.normalizeFailure(attemptFailure) @@ -2544,16 +2517,6 @@ export class AiSdkBackend implements AgentBackend { throw Object.assign(new Error('aborted'), { name: 'AbortError' }); } - // Mid-turn exhaustion aborts the SDK stream, but streamText ends - // gracefully on abort instead of throwing; route to the explicit - // outcome regardless of how the stream wound down. - if (midTurnState?.exhaustedDetail) { - throw Object.assign( - new Error(`mid-turn context budget exhausted: ${midTurnState.exhaustedDetail}`), - { name: 'MidTurnContextBudgetExhaustedError' }, - ); - } - // Catch-all: flush any residual step content if the provider closed the // stream without a trailing `finish-step` for the last step. const providerStepId = currentStepMessageId; @@ -2884,20 +2847,7 @@ export class AiSdkBackend implements AgentBackend { // BOTH exits — user stop and provider error / watchdog timeout — so // partialOutputRetained reflects what the user actually saw. await flushStep().catch(() => {}); - if (!scope.aborted && midTurnState?.exhaustedDetail) { - // Mid-turn compaction could not produce a provider-safe request: end - // the turn with the explicit first-class outcome, not a raw error. - streamErrorClass = 'ContextBudgetExhausted'; - trace.modelStreamCompleted('context_budget_exhausted'); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: midTurnState.exhaustedDetail, - } satisfies CompleteEvent); - } else if (scope.aborted) { + if (scope.aborted) { queue.push({ type: 'abort', id: this.newId(), @@ -3429,7 +3379,6 @@ export class AiSdkBackend implements AgentBackend { const needsCompaction = maxHistoryTokens !== undefined && estimateRuntimeEventsTokens(runtimeContext, contextBudget?.charsPerToken) > maxHistoryTokens; - let compactionFailure: ContextBudgetExhaustedDetail | undefined; if ( needsCompaction && contextBudget?.historyCompact?.enabled === true && @@ -3486,14 +3435,6 @@ export class AiSdkBackend implements AgentBackend { }); } } - if (compactResult.outcome.kind === 'failed') { - compactionFailure = - compactResult.outcome.reason === 'no_safe_completed_span' - ? 'no_safe_completed_span' - : isMalformedHistoryCompactSummaryReason(compactResult.outcome.reason) - ? compactResult.outcome.reason - : 'summarizer_failed'; - } contextBudgetDiagnostic = mergeContextBudgetDiagnostic( contextBudgetDiagnostic ?? buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget), @@ -3501,16 +3442,6 @@ export class AiSdkBackend implements AgentBackend { ); } - if ( - maxHistoryTokens !== undefined && - estimateRuntimeEventsTokens(runtimeContext, contextBudget?.charsPerToken) > maxHistoryTokens - ) { - return { - status: 'context_budget_exhausted', - detail: compactionFailure ?? 'no_safe_completed_span', - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - }; - } // The boundary belongs to the runtime-event projection above. A gate that // falls back to the stored-message projection returns a prompt no // checkpoint shaped, so it reports none rather than one the request never diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index 71a65895d8..1bdd348587 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -47,9 +47,7 @@ export interface HistoryCompactSummaryInput { /** * Estimated provider-input ceiling for this compaction call. A compactor * should fail before dispatch when its projection cannot fit this budget. - * The ceiling is absent when the selected model declares no context window: - * there is no honest number to fit against, and inventing one is what #4458 - * was about. + * The ceiling is absent when the selected model declares no context window. */ inputBudget?: { maxEstimatedTokens?: number; diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index cd547103da..e23114ab1b 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -95,7 +95,7 @@ import { import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; -import type { ContextBudgetExhaustedDetail, SessionEvent } from '@maka/core/events'; +import type { SessionEvent } from '@maka/core/events'; import type { AsyncEventQueue } from './async-queue.js'; import type { MakaTool } from './tool-runtime.js'; import { @@ -117,8 +117,8 @@ import { MATERIALIZED_IMAGE_TOKENS } from '@maka/core/attachments'; import { collectHistoricalImageToolResults, type HistoricalImageToolResult, + isInlineImageFilePart, omitHistoricalImageToolResults, - selectHistoricalImageOmissions, } from './provider-image-overflow-recovery.js'; /** @@ -803,9 +803,8 @@ export class AiSdkCompaction { * opt-in via `historyCompact.midTurn.enabled`; requires the checkpoint * writer seams plus the durable turn-ledger read, the persisted head anchor * for this turn. A model that declares no context window still gets this - * state: without a window there is nothing to enforce proactively, but - * reactive recovery runs off a real provider rejection and needs no window - * at all — gating the state on one is what left those models with neither. + * state: reactive recovery runs off a real provider rejection and needs no + * window at all. */ public buildMidTurnCapacityCompactState( input: BackendSendInput, @@ -856,13 +855,10 @@ export class AiSdkCompaction { * `[compact block, verbatim head anchor]`. * * This hook never terminates the turn: every failure fails open with a - * diagnostic and records itself for the final-request estimate owner, which - * re-measures the payload after ALL shaping (including active tool-result - * pruning, which runs later and can still rescue the step) and issues the - * context_budget_exhausted verdict only when the request that would really - * go out exceeds the window. The trigger threshold here is deliberately - * approximate — a missed or spurious trigger is recoverable; the verdict is - * not, so it does not live here. + * diagnostic and the request goes out. The trigger threshold is approximate + * on purpose — a missed or spurious trigger costs at most one compaction, + * and whether the request actually fits is the provider's answer, not a + * local estimate's. */ public buildMidTurnCapacityCompactProjection( turnId: string, @@ -900,7 +896,7 @@ export class AiSdkCompaction { projectedMessages ? { messages: projectedMessages } : undefined; // Step 0 is shaped by the pre_turn path; the mid-turn trigger only runs // between steps, once completed-step usage and events exist. - if (options.stepNumber < 1 || state.exhaustedDetail) return keepProjection(); + if (options.stepNumber < 1) return keepProjection(); // Real usage for the last finished step, read synchronously from the // SDK's own step results (the same numbers the finish-step chunk @@ -947,15 +943,10 @@ export class AiSdkCompaction { }); return keepProjection(); }; - // A shaping failure additionally records itself for the final-request - // estimate owner: when the final payload is still over the window, the - // owner turns this step's failure into the terminal detail instead of - // re-entering a shaper that already attempted and failed. - const shapeFailure = ( - detail: ContextBudgetExhaustedDetail, - diagnosticReason: string, - ): RequestProjection | undefined => { - state.lastShapeFailure = { stepNumber: options.stepNumber, detail, diagnosticReason }; + // A shaping failure records the step so the rescue re-entry does not + // re-run a shaper that already attempted and failed this step. + const shapeFailure = (diagnosticReason: string): RequestProjection | undefined => { + state.lastShapeFailure = { stepNumber: options.stepNumber }; return failOpen(diagnosticReason); }; @@ -1014,7 +1005,7 @@ export class AiSdkCompaction { abortSignal, }); if (outcome.decision === 'fail') { - return shapeFailure(outcome.detail, outcome.diagnosticReason); + return shapeFailure(outcome.diagnosticReason); } acceptedProjection = { sourceSignatures: incomingMessages.map(modelMessageSignature), @@ -1072,7 +1063,6 @@ export class AiSdkCompaction { if (state.malformedSummaryFailure) { return { decision: 'fail', - detail: state.malformedSummaryFailure, diagnosticReason: state.malformedSummaryFailure, }; } @@ -1121,14 +1111,12 @@ export class AiSdkCompaction { if (abortSignal?.aborted) { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'ledger_wait_aborted', }; } if (queue.consumerDetached) { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'ledger_wait_aborted', }; } @@ -1142,7 +1130,6 @@ export class AiSdkCompaction { } catch { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'ledger_read_failed', }; } @@ -1154,7 +1141,6 @@ export class AiSdkCompaction { if (!currentTurnEvents.some((event) => event.id === state.headAnchor.id)) { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'head_anchor_not_durable', }; } @@ -1212,9 +1198,6 @@ export class AiSdkCompaction { } return { decision: 'fail', - detail: isMalformedHistoryCompactSummaryReason(diagnosticReason) - ? diagnosticReason - : plan.reason, diagnosticReason, }; } @@ -1235,7 +1218,6 @@ export class AiSdkCompaction { ) { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'replacement_unmaterializable', }; } @@ -1266,7 +1248,6 @@ export class AiSdkCompaction { if (replacedPayloadChars >= input.referencePayloadChars) { return { decision: 'fail', - detail: 'summarizer_failed', diagnosticReason: 'replacement_not_smaller', }; } @@ -1282,7 +1263,6 @@ export class AiSdkCompaction { if (!replayFit.fits) { return { decision: 'fail', - detail: 'head_anchor_exceeds_capacity', diagnosticReason: `replay_rejected_${replayFit.reason}`, }; } @@ -1295,7 +1275,6 @@ export class AiSdkCompaction { } catch { return { decision: 'fail', - detail: 'summarizer_failed', diagnosticReason: 'write_failed', }; } @@ -1353,17 +1332,7 @@ export class AiSdkCompaction { if (input.retryAlreadyUsed || !state) return undefined; if (this.modelAdapter.classifyError(input.error) !== 'ContextLength') return undefined; - // The provider counted the rejected request, so the overshoot is known: - // give back that much of the image cost, not every image in the history. - // Without a usable count nothing bounds the drop, so it stays all-or-nothing. - const overshootTokens = - state.lastRequestInputTokens !== undefined - ? state.lastRequestInputTokens - (state.capacity ?? state.lastRequestInputTokens) - : undefined; - const eligibleImages = selectHistoricalImageOmissions( - collectHistoricalImageToolResults(state.priorContentEvents), - overshootTokens, - ); + const eligibleImages = collectHistoricalImageToolResults(state.priorContentEvents); const imageOmission = omitHistoricalImageToolResults(input.currentMessages, eligibleImages); if (imageOmission.omittedParts > 0) { state.omittedImageToolResults = new Map( @@ -1457,8 +1426,8 @@ export class AiSdkCompaction { * invariant. Every request-projection stage only shapes; this wrapper measures the * FINAL outgoing (messages, tools) payload — the bytes the provider will * actually see, after capacity compaction, active tool-result pruning, and - * semantic/active-full compaction have all run — and issues the one - * safety-critical verdict: + * semantic/active-full compaction have all run — and spends the last + * chance to shrink it: * * - estimate = the last request's real INPUT tokens + signed char/4 delta * against the previous request's measured payload (recorded here on @@ -1468,18 +1437,12 @@ export class AiSdkCompaction { * cold start rather than a zero baseline; * - over the window with no capacity attempt this step (the approximate * trigger missed, e.g. growth the trigger under-weighted), force ONE - * capacity re-entry — the verdict must not terminate a turn a shaper can - * still rescue, and one bounded re-entry preserves termination; - * - still over the window → context_budget_exhausted, with the terminal - * detail taken from this step's capacity outcome: a replacement that - * remains too large is head_anchor_exceeds_capacity (the irreducible - * remainder exceeds capacity); a recorded shaping failure keeps its own - * detail and diagnostic reason. + * capacity re-entry. * - * Step 0 is shaped by the pre_turn path. It is still measured here so an - * unshapable first request cannot bypass the capacity invariant. + * Still over afterwards, the request goes out anyway: only the provider + * knows whether it fits, and a rejection is recovered from. */ - public buildMidTurnFinalRequestVerdict(input: { + public buildMidTurnFinalRequestRescue(input: { shaped: RequestProjectionStage; reentry: RequestProjectionStage; state: MidTurnCapacityCompactState; @@ -1487,8 +1450,6 @@ export class AiSdkCompaction { fallbackActiveTools: () => readonly string[]; charsPerToken: number; systemPromptChars: number; - onDiagnosticPatch: (patch: Partial) => void; - abortController?: AbortController | null; }): RequestProjectionStage { const { shaped, @@ -1498,8 +1459,6 @@ export class AiSdkCompaction { fallbackActiveTools, charsPerToken, systemPromptChars, - onDiagnosticPatch, - abortController, } = input; return async (options) => { let result = await Promise.resolve(shaped(options)); @@ -1519,7 +1478,7 @@ export class AiSdkCompaction { charsPerToken, ); let payloadChars = finalPayloadChars(); - if (state.capacity !== undefined && options.stepNumber >= 1 && !state.exhaustedDetail) { + if (state.capacity !== undefined && options.stepNumber >= 1) { const estimateFinal = (): number => estimateNextRequestTokens({ ...(state.lastRequestInputTokens !== undefined @@ -1529,18 +1488,16 @@ export class AiSdkCompaction { charsPerToken, coldStartChars: payloadChars, }); - let estimate = estimateFinal(); + const estimate = estimateFinal(); const capacityAttemptedThisStep = state.replacedStepNumber === options.stepNumber || state.lastShapeFailure?.stepNumber === options.stepNumber; - if (options.stepNumber >= 1 && estimate > state.capacity && !capacityAttemptedThisStep) { - // One bounded capacity re-entry: the trigger threshold is - // approximate on purpose (recoverable), so a miss must become a - // rescue attempt before it can become a terminal verdict. Re-run - // only the capacity + prune shapers over the already-shaped - // projection; a second attempt after a same-step failure is - // pointless (the failure was not a trigger miss) and would double - // recorder counters and summarizer calls. + if (estimate > state.capacity && !capacityAttemptedThisStep) { + // One bounded capacity re-entry. Re-run only the capacity + prune + // shapers over the already-shaped projection; a second attempt + // after a same-step failure is pointless (the failure was not a + // trigger miss) and would double recorder counters and summarizer + // calls. state.forcedTriggerEstimate = estimate; const reshaped = await Promise.resolve( reentry({ @@ -1558,34 +1515,6 @@ export class AiSdkCompaction { }; } payloadChars = finalPayloadChars(); - estimate = estimateFinal(); - } - if (estimate > state.capacity) { - const failure = - state.lastShapeFailure?.stepNumber === options.stepNumber - ? state.lastShapeFailure - : undefined; - const replacedThisStep = state.replacedStepNumber === options.stepNumber; - const detail: ContextBudgetExhaustedDetail = replacedThisStep - ? 'head_anchor_exceeds_capacity' - : (failure?.detail ?? 'no_safe_completed_span'); - const diagnosticReason = replacedThisStep - ? 'head_anchor_exceeds_capacity' - : (failure?.diagnosticReason ?? 'no_safe_completed_span'); - state.exhaustedDetail = detail; - onDiagnosticPatch({ - ...compactionDecisionDiagnosticPatch({ - stage: 'activeStep', - sourceKind: 'runtimeEvents', - decision: 'unchanged', - phase: 'mid_turn', - boundaryKind: 'historyCompact', - reason: 'context_budget_exhausted', - skippedReasonCounts: { [diagnosticReason]: 1 }, - }), - }); - abortController?.abort(new Error(`mid-turn context budget exhausted: ${detail}`)); - return result; } } state.lastRequestPayloadChars = payloadChars; @@ -1732,8 +1661,6 @@ export class MidTurnCapacityCompactState { previousCheckpoint: HistoryCompactCheckpoint | undefined; /** Checkpoint accepted during this send; pins every later durable projection. */ projectionCheckpoint: HistoryCompactCheckpoint | undefined; - /** Set when the turn must end with a context_budget_exhausted outcome. */ - exhaustedDetail: ContextBudgetExhaustedDetail | undefined; /** * Step whose request the capacity hook replaced. Semantic/active-full * compaction yields on that exact step so one step never runs two @@ -1756,18 +1683,11 @@ export class MidTurnCapacityCompactState { /** Exact historical image results omitted after a provider overflow. */ omittedImageToolResults = new Map(); /** - * The capacity hook's most recent shaping failure. The owner reads it (for - * the same step only) to pick the terminal detail and diagnostic reason - * when the final payload is over the window, and to avoid re-entering a - * shaper that already attempted and failed this step. + * The step of the capacity hook's most recent shaping failure. The rescue + * re-entry reads it so it never re-runs a shaper that already attempted and + * failed on the same step. */ - lastShapeFailure: - | { - stepNumber: number; - detail: ContextBudgetExhaustedDetail; - diagnosticReason: string; - } - | undefined; + lastShapeFailure: { stepNumber: number } | undefined; /** Malformed summaries spend one bounded repair budget for this whole Turn. */ malformedSummaryFailure: MalformedHistoryCompactSummaryReason | undefined; @@ -1783,15 +1703,15 @@ export class MidTurnCapacityCompactState { /** * Char measure of the FULL provider-visible request input: the system prompt * (sent through the separate `system` field), the (projected) messages, and - * the serialized schemas of the active tool subset. The capacity trigger and - * the final-request estimate owner both measure with this ONE function, so - * their raw payload comparisons against `lastRequestPayloadChars` are - * commensurable and + * the serialized schemas of the active tool subset. Media is billed in tokens + * converted to chars, so the whole measure stays in one unit. The capacity + * trigger and the rescue re-entry both measure with this ONE function, so + * their comparisons against `lastRequestPayloadChars` are commensurable and * same-turn tool-schema growth (a `tool_search` activation) is counted like * any other payload growth. The system prompt is constant between adjacent * requests — signed deltas cancel it — but the cold-start estimate (no usable * usage sample) is the whole payload, so omitting it would under-estimate by - * exactly the system prompt and let an over-window request stream. + * exactly the system prompt. * * A media part is worth what the provider charges for it, not what it * serializes to: materialized bytes reach the request as base64 or a byte map, @@ -1809,7 +1729,7 @@ function midTurnRequestPayloadChars( ): number { let mediaParts = 0; const serializedMessages = JSON.stringify(messages, (_key, value) => { - if (!isMaterializedMediaPart(value)) return value; + if (!isInlineImageFilePart(value)) return value; mediaParts += 1; return { type: value.type, mediaType: value.mediaType }; }); @@ -1821,20 +1741,6 @@ function midTurnRequestPayloadChars( ); } -/** A `file` part carrying inline bytes, as opposed to a URL the provider fetches. */ -function isMaterializedMediaPart( - value: unknown, -): value is { type: 'file'; mediaType: string; data: object } { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const part = value as { type?: unknown; mediaType?: unknown; data?: unknown }; - return ( - part.type === 'file' && - typeof part.mediaType === 'string' && - part.data !== null && - typeof part.data === 'object' - ); -} - /** * Outcome of folding the durable turn ledger into a replacement projection. * Shared by the proactive projection stage (which maps it to keepProjection / @@ -1845,7 +1751,6 @@ function isMaterializedMediaPart( type ActiveRequestCompactionOutcome = | { decision: 'fail'; - detail: ContextBudgetExhaustedDetail; diagnosticReason: string; } | { diff --git a/packages/runtime/src/context-budget-policy.ts b/packages/runtime/src/context-budget-policy.ts index 507e613209..bb7d5577e3 100644 --- a/packages/runtime/src/context-budget-policy.ts +++ b/packages/runtime/src/context-budget-policy.ts @@ -21,7 +21,6 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; import { relayModelProfile } from '@maka/core/model-thinking'; import type { ContextBudgetPolicy } from './context-budget.js'; -import { finitePositive } from './context-budget-helpers.js'; export interface BuildDefaultContextBudgetPolicyOptions { name?: string; diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index 18b13e54dd..376d374e65 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -29,7 +29,7 @@ import { type DurableToolResultProjection, type DurableToolResultProjectionPart, } from '@maka/core/durable-tool-result-projection'; -import { MATERIALIZED_IMAGE_TOKENS, MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import { isCanonicalArtifactEntityId, normalizeArtifactImagePreviewMime, @@ -175,12 +175,12 @@ export function durableProjectionToToolResultOutput( /** One image a Tool Result puts in the request. */ export interface MaterializedToolResultMedia { - mediaType: string; /** How the model is told to name it once it is gone. */ label: string; } -function projectionArtifactMedia( +/** The images the artifact parts of one durable projection put in the request. */ +export function projectionArtifactMedia( projection: DurableToolResultProjection, ): MaterializedToolResultMedia[] { if (projection.kind !== 'content') return []; @@ -188,7 +188,6 @@ function projectionArtifactMedia( part.kind === 'artifact' ? [ { - mediaType: part.mediaType, label: part.ref.kind === 'session_context' ? part.ref.refId : part.ref.relativePath, }, ] @@ -211,25 +210,11 @@ export function effectiveToolResultMedia( if (!image) return []; return [ { - mediaType: image.mimeType, label: image.ref.kind === 'session_context' ? image.ref.refId : image.ref.relativePath, }, ]; } -/** Tokens the artifact parts of one projection cost once materialized. */ -export function estimateProjectionMediaTokens(projection: DurableToolResultProjection): number { - return projectionArtifactMedia(projection).length * MATERIALIZED_IMAGE_TOKENS; -} - -/** Tokens the images of one decoded Tool Result cost once materialized. */ -export function estimateEffectiveMediaTokens( - effective: EffectiveToolResultProjection, - sessionId: string, -): number { - return effectiveToolResultMedia(effective, sessionId).length * MATERIALIZED_IMAGE_TOKENS; -} - /** * The one pure decision of WHICH source a replayed Tool Result materializes * from: a durable projection wins, and only a response that has none (legacy diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 1ee5fc1fb0..93286b3e30 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -46,10 +46,8 @@ import { * selected model's context window. This module is turn-agnostic and side-effect * free, and it only SHAPES: it selects the largest safe covered prefix and * builds the checkpoint + replacement projection, failing open when it cannot. - * The safety-critical pass/terminate verdict is NOT issued here — the backend's - * final-request estimate owner measures the actual outgoing (messages, tools) - * payload after every shaping hook has run and decides `context_budget_exhausted` - * there, so the verdict is always about the request that really goes out. + * Nothing here decides whether a request fits: that answer belongs to the + * provider, and a rejection is recovered from by compacting and retrying once. */ export interface EstimateNextRequestTokensInput { @@ -139,8 +137,8 @@ export type SafePrefixBoundary = * - it leaves at least `reserveTailEvents` trailing events as the verbatim tail. * * Returns `no_safe_completed_span` when no such cut exists (e.g. the remaining - * pool is a single atomic call/result pair), which the caller surfaces as an - * explicit `context_budget_exhausted` outcome rather than a provider error. + * pool is a single atomic call/result pair); the caller then fails open and + * sends the request unchanged. */ export function selectSafeCompactionPrefix( events: readonly RuntimeEvent[], @@ -279,12 +277,8 @@ export type HistoryCompactionFailReason = 'no_safe_completed_span' | 'summarizer * Execute a triggered compaction command by deterministically folding the * largest safe prefix. Trigger policy is owned by callers; once this function * is called it always attempts the transaction. This plan is a pure shaper: - * when it cannot fold a safe - * completed prefix it FAILS OPEN (keep the raw projection + diagnostic) and - * never terminates the turn itself. The two failure tiers — fail open under - * the window, explicit `context_budget_exhausted` over it — are applied by the - * backend's final-request estimate owner, which re-measures the actual outgoing - * payload after all shaping (including this fold) has been applied. + * when it cannot fold a safe completed prefix it FAILS OPEN (keep the raw + * projection + diagnostic) and the request goes out unchanged. */ export async function planHistoryCompaction( input: PlanHistoryCompactionInput, diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 4b4bd99ab4..840e7392f0 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -73,8 +73,9 @@ import type { import { decodeEffectiveToolResultProjection, durableProjectionToToolResultOutput, - estimateEffectiveMediaTokens, + effectiveToolResultMedia, } from './durable-tool-result-projection.js'; +import { MATERIALIZED_IMAGE_TOKENS } from '@maka/core/attachments'; import { estimateTokens, stableJsonLength, turnKey } from './context-budget-helpers.js'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; @@ -177,7 +178,7 @@ function effectiveToolResultSize( : effective.kind === 'invalid_legacy' ? effective.message.length : stableJsonLength(effective.output), - mediaTokens: estimateEffectiveMediaTokens(effective, sessionId), + mediaTokens: effectiveToolResultMedia(effective, sessionId).length * MATERIALIZED_IMAGE_TOKENS, }; effectiveToolResultSizes.set(content, size); return size; diff --git a/packages/runtime/src/provider-image-overflow-recovery.ts b/packages/runtime/src/provider-image-overflow-recovery.ts index 2dfc3215e3..480c20ada0 100644 --- a/packages/runtime/src/provider-image-overflow-recovery.ts +++ b/packages/runtime/src/provider-image-overflow-recovery.ts @@ -22,14 +22,11 @@ import type { ModelMessage } from './model-protocol.js'; import { decodeEffectiveToolResultProjection, effectiveToolResultMedia, - estimateEffectiveMediaTokens, } from './durable-tool-result-projection.js'; export interface HistoricalImageToolResult { toolName: string; artifactLabel: string; - /** What dropping this result's images gives back, by the same ruler the budget uses. */ - estimatedTokens: number; } export interface HistoricalImageOmissionResult { @@ -59,38 +56,15 @@ export function collectHistoricalImageToolResults( const effective = decodeEffectiveToolResultProjection(content, event.sessionId); const [media] = effectiveToolResultMedia(effective, event.sessionId); if (!media || media.label.length === 0) continue; - collected.set(content.id, { - toolName: content.name, - artifactLabel: media.label, - estimatedTokens: estimateEffectiveMediaTokens(effective, event.sessionId), - }); + collected.set(content.id, { toolName: content.name, artifactLabel: media.label }); } return collected; } -/** - * The cheapest set of image Tool Results whose removal covers `targetTokens`, - * largest first. An overflow is a fixed overshoot, so dropping everything the - * model can still see costs visual context the retry never needed. An unknown - * target keeps the old all-or-nothing behaviour. - */ -export function selectHistoricalImageOmissions( - eligible: ReadonlyMap, - targetTokens: number | undefined, -): Map { - if (targetTokens === undefined || targetTokens <= 0) return new Map(eligible); - const selected = new Map(); - let covered = 0; - const byCostDesc = [...eligible].sort(([, a], [, b]) => b.estimatedTokens - a.estimatedTokens); - for (const [toolCallId, image] of byCostDesc) { - if (covered >= targetTokens) break; - selected.set(toolCallId, image); - covered += image.estimatedTokens; - } - return selected; -} - -function isInlineImageFilePart(value: unknown): value is UnknownRecord { +/** A `file` part carrying inline image bytes, not a URL the provider fetches. */ +export function isInlineImageFilePart( + value: unknown, +): value is { type: 'file'; mediaType: string; data: object } { if ( !isRecord(value) || value.type !== 'file' || diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index 08f38d186b..ad5b72106a 100644 --- a/packages/runtime/src/tool-result-archive-transition.ts +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -40,6 +40,7 @@ * explain, and none in which a completed tool effect is repeated. */ +import { MATERIALIZED_IMAGE_TOKENS } from '@maka/core/attachments'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import { DURABLE_TOOL_RESULT_PROJECTION_VERSION } from '@maka/core/durable-tool-result-projection'; import { @@ -58,7 +59,7 @@ import { } from './context-budget-helpers.js'; import { durableProjectionToToolResultOutput, - estimateProjectionMediaTokens, + projectionArtifactMedia, } from './durable-tool-result-projection.js'; import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js'; import { @@ -93,23 +94,6 @@ export function serializedToolResultProjection(projection: DurableToolResultProj ); } -/** - * What one Tool Result costs the request, in tokens. - * - * Its serialized bytes plus the media its artifact parts rehydrate into. An - * artifact serializes to a short reference and materializes to real image - * bytes, so measuring the string alone would price a screenshot at nothing. - */ -export function toolResultProjectionEstimatedTokens( - projection: DurableToolResultProjection, - serialized: string, - charsPerToken: number, -): number { - return ( - estimateTokens(serialized.length, charsPerToken) + estimateProjectionMediaTokens(projection) - ); -} - /** The replacement a pruned Tool Result projects to. */ export function archivedToolResultProjection( placeholder: ArchivedToolResultPlaceholder, @@ -309,11 +293,11 @@ export function collectStaleToolResultArchiveCandidates( if (!sourceProjection) continue; const serializedResult = serializedToolResultProjection(sourceProjection); const originalBytes = utf8ByteLength(serializedResult); - const originalEstimatedTokens = toolResultProjectionEstimatedTokens( - sourceProjection, - serializedResult, - charsPerToken, - ); + // An artifact serializes to a short reference and materializes to real + // image bytes, so the string alone would price a screenshot at nothing. + const originalEstimatedTokens = + estimateTokens(serializedResult.length, charsPerToken) + + projectionArtifactMedia(sourceProjection).length * MATERIALIZED_IMAGE_TOKENS; if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; candidates.push({ runtimeEventId: event.id, From de49fd86d9f9088847308a8e751d0afd419b4718 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 03:46:14 +0800 Subject: [PATCH 5/9] feat(runtime): persist the last provider request anchor across turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn capacity estimate anchors on the last request's real input tokens paired with the payload chars measured for that same request, but both halves lived only inside one send. Every turn therefore started with no anchor at all, and the only sizing left was chars/4 over the whole payload — roughly half the real count for CJK text. Persist the pair on the token_usage record. `input` there is the reconciled per-send sum (#996) and anchors nothing; `lastRequestAnchor` is the last request alone, so the next turn can read it back off the runtime context it already loads. The two numbers are one nested object because only the pair means anything: an anchor from one request with a baseline from another is off by a whole step's growth, and the schema should say so rather than a runtime branch. Seed the mid-turn state from it, gated on the anchoring run using the same model over the same connection — a token count is only transferable within one tokenizer. The reverse scan takes the newest anchor-bearing record and stops: a rejected anchor means cold start, never a fallback to an older, worse-paired one. Overflow recovery now clears both halves for the same reason. The estimate sites also drop a pairing whose signed delta is wider than the whole payload. Within a send that cannot happen without a restructuring that already resets the baseline; across a turn boundary it means the prior tail was re-materialized down a different path than the request the anchor was reported for, and a pairing that far off estimates worse than none. This commit only makes the anchor available; nothing consumes it at step 0 yet. Reading a session written by this version on an older binary rejects the token_usage record, as with every closed-allowlist field before it. --- .../usage-record-last-request-anchor.test.ts | 66 ++++++++++ packages/core/src/runtime-event.ts | 1 + packages/core/src/session.ts | 1 + packages/core/src/usage-record-schema.ts | 38 +++++- .../mid-turn-capacity-backend.test.ts | 21 ++++ .../runtime-event-read-model.test.ts | 49 ++++++++ packages/runtime/src/ai-sdk-backend.ts | 16 +++ packages/runtime/src/ai-sdk-compaction.ts | 114 +++++++++++++++--- .../runtime/src/runtime-event-backfill.ts | 3 + .../runtime/src/runtime-event-read-model.ts | 4 + .../src/session-event-runtime-mapper.ts | 3 + 11 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/__tests__/usage-record-last-request-anchor.test.ts diff --git a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts new file mode 100644 index 0000000000..659169ae0d --- /dev/null +++ b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isLastRequestAnchor, isTokenUsageFields } from '../usage-record-schema.js'; +import { decodeCanonicalMessage } from '../session.js'; + +const usage = { input: 370, output: 60 }; + +test('a last-request anchor is only valid as a complete positive pair', () => { + assert.equal(isLastRequestAnchor({ inputTokens: 120, payloadChars: 4_000 }), true); + assert.equal(isLastRequestAnchor({ inputTokens: 120 }), false); + assert.equal(isLastRequestAnchor({ payloadChars: 4_000 }), false); + assert.equal(isLastRequestAnchor({ inputTokens: 0, payloadChars: 4_000 }), false); + assert.equal(isLastRequestAnchor({ inputTokens: 120, payloadChars: 0 }), false); + assert.equal( + isLastRequestAnchor({ inputTokens: 120, payloadChars: 4_000, stepNumber: 2 }), + false, + ); +}); + +test('token-usage fields carry the anchor and reject a broken one', () => { + assert.equal( + isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 } }), + true, + ); + assert.equal(isTokenUsageFields(usage), true); + assert.equal(isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120 } }), false); +}); + +test('a half-written anchor fails the whole token_usage message decode', () => { + const message = { + type: 'token_usage', + id: 'usage-1', + turnId: 'turn-1', + ts: 1, + ...usage, + }; + assert.deepEqual( + decodeCanonicalMessage({ + ...message, + lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 }, + }), + { ...message, lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 } }, + ); + assert.throws(() => + decodeCanonicalMessage({ ...message, lastRequestAnchor: { payloadChars: 4_000 } }), + ); +}); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 52d23cc797..09738b35ad 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -675,6 +675,7 @@ const RUNTIME_TOKEN_USAGE_SHAPE = defineObjectShape()( 'promptSegments', 'contextBudget', 'providerRequestTraceId', + 'lastRequestAnchor', ], ); const RUNTIME_REFS_SHAPE = defineObjectShape()( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 25ddd9168e..3f597ef946 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1140,6 +1140,7 @@ const TOKEN_USAGE_MESSAGE_SHAPE = defineObjectShape()( 'promptSegments', 'contextBudget', 'providerRequestTraceId', + 'lastRequestAnchor', ], ); const TURN_STATE_MESSAGE_SHAPE = defineObjectShape()( diff --git a/packages/core/src/usage-record-schema.ts b/packages/core/src/usage-record-schema.ts index a6e831f1e4..4761ec7eaf 100644 --- a/packages/core/src/usage-record-schema.ts +++ b/packages/core/src/usage-record-schema.ts @@ -283,6 +283,41 @@ export interface TokenUsageFields { contextBudget?: ContextBudgetDiagnostic; /** Links this aggregate to per-physical-request AgentRun trace rows. */ providerRequestTraceId?: string; + /** + * The send's LAST provider request, as a pair: the input tokens the provider + * reported for it, and the wire payload chars the runtime measured for that + * same request. `input` above is the send's sum across steps and cannot + * anchor anything; this pair can, so the next turn estimates its first + * request from real usage instead of guessing the whole payload at char/4. + * + * Only the pair means anything — an anchor taken from one request and a + * baseline from another is off by a whole step's growth — so the two numbers + * live in one object that is written and read together. Absent means no + * anchor, and the estimate falls back to the cold start. + */ + lastRequestAnchor?: LastRequestAnchor; +} + +/** Real input tokens of one provider request, paired with its measured payload chars. */ +export interface LastRequestAnchor { + inputTokens: number; + payloadChars: number; +} + +const LAST_REQUEST_ANCHOR_SHAPE = defineObjectShape()( + ['inputTokens', 'payloadChars'], + [], +); + +export function isLastRequestAnchor(value: unknown): value is LastRequestAnchor { + return ( + isRecord(value) && + hasExactShape(value, LAST_REQUEST_ANCHOR_SHAPE) && + isFiniteNumber(value.inputTokens) && + isFiniteNumber(value.payloadChars) && + value.inputTokens > 0 && + value.payloadChars > 0 + ); } export function isTokenUsageFields(value: unknown): value is TokenUsageFields { @@ -303,7 +338,8 @@ export function isTokenUsageFields(value: unknown): value is TokenUsageFields { (value.promptSegments === undefined || (Array.isArray(value.promptSegments) && value.promptSegments.every(isPromptSegmentEstimate))) && - (value.contextBudget === undefined || isContextBudgetDiagnostic(value.contextBudget)) + (value.contextBudget === undefined || isContextBudgetDiagnostic(value.contextBudget)) && + (value.lastRequestAnchor === undefined || isLastRequestAnchor(value.lastRequestAnchor)) ); } 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 60a832629e..39323626d0 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1506,6 +1506,27 @@ describe('the shipped runtime default drives the proactive long-turn journey (is ); }); + test('persists the LAST request as the anchor while input stays the send sum', async () => { + // `input` is the reconciled per-send sum (#996) and anchors nothing: an + // estimate started from it would be off by every earlier step. The + // persisted anchor is the last request alone, paired with the payload + // chars measured for that same request. + const fixture = buildFixture({ contextWindow: 1_000_000, finalAtSecondCall: true }); + await runFixtureTurn(fixture); + + const usage = fixture.messages.find( + (message): message is { type: 'token_usage'; input: number; lastRequestAnchor?: unknown } => + (message as { type?: string }).type === 'token_usage', + ); + // Two steps: 100 + 120 reported input, and the send sum is both. + assert.equal(usage?.input, 220); + const anchor = usage?.lastRequestAnchor as + | { inputTokens: number; payloadChars: number } + | undefined; + assert.equal(anchor?.inputTokens, 120); + assert.equal((anchor?.payloadChars ?? 0) > 0, true); + }); + test('an unrescuable turn under the shipped default still dispatches', async () => { // Same runtime-derived default (window 120 → reserve 30, high water 90): // no prior turns leaves no safe completed span, and the request genuinely diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 885ab74297..081ee6edc3 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -32,6 +32,7 @@ import { projectRuntimeEventsToStoredMessagesWithArchiveStatuses, } from '../runtime-event-read-model.js'; import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; +import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; import { BackendRegistry, SessionManager, type SessionStore } from '../session-manager.js'; const ts = 1_800_000_000_000; @@ -1997,6 +1998,54 @@ describe('compareRuntimeReadModelMessages', () => { assert.strictEqual(compareRuntimeReadModelMessages(projected, legacy).compatible, false); }); + test('carries the cross-turn request anchor both ways and compares on it', () => { + const lastRequestAnchor = { inputTokens: 120, payloadChars: 48_000 }; + const anchored = ev({ + id: 'evt-token-anchor', + role: 'system', + author: 'system', + actions: { tokenUsage: { input: 370, output: 60, lastRequestAnchor } }, + }); + const projected = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-anchor-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'read the file' }, + }), + anchored, + ], + { runHeaders: [header] }, + ); + const usage = projected.messages.find((message) => message.type === 'token_usage'); + assert.partialDeepStrictEqual(usage, { type: 'token_usage', input: 370, lastRequestAnchor }); + + const backfilled = backfillRuntimeEventsFromStoredMessages({ + run: header, + messages: projected.messages, + now: () => ts, + }); + assert.deepStrictEqual( + backfilled.events.find((event) => event.actions?.tokenUsage)?.actions?.tokenUsage + ?.lastRequestAnchor, + lastRequestAnchor, + ); + + assert.strictEqual( + compareRuntimeReadModelMessages( + [usage as StoredMessage], + [ + { + ...(usage as Extract), + lastRequestAnchor: undefined, + }, + ], + ).compatible, + false, + ); + }); + test('rejects mismatched replay-critical token usage fields', () => { const usage: Extract = { type: 'token_usage', diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 4713b885e0..678f08cb12 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -178,6 +178,7 @@ import { } from './responses-reasoning-state.js'; import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result-prune.js'; import { toolResultOutput } from './tool-result-output.js'; +import { finitePositive } from './context-budget-helpers.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import type { AutomaticMemoryCompactionDecision, @@ -2731,6 +2732,13 @@ export class AiSdkBackend implements AgentBackend { } return undefined; })(); + // The anchor the NEXT turn estimates its first request from — see + // `LastRequestAnchor`. `input` below is the sum across this send's + // steps and anchors nothing. Either half missing (no usable usage + // sample, or no mid-turn seam to measure the payload) drops the + // whole pair and the next turn cold starts. + const anchorInputTokens = finitePositive(lastStepInputTokens); + const anchorPayloadChars = finitePositive(midTurnState?.lastRequestPayloadChars); // One shared usage payload for the durable message and the live // event: twin per-field literals drifted before (#4019), so a field // now has exactly one definition site. @@ -2759,6 +2767,14 @@ export class AiSdkBackend implements AgentBackend { ? { contextRemaining: contextRemainingForUsage } : {}), ...(providerRequestTraceId ? { providerRequestTraceId } : {}), + ...(anchorInputTokens !== undefined && anchorPayloadChars !== undefined + ? { + lastRequestAnchor: { + inputTokens: anchorInputTokens, + payloadChars: anchorPayloadChars, + }, + } + : {}), }; const tu: TokenUsageMessage = { type: 'token_usage', diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index e23114ab1b..d91ae3c9cf 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -36,6 +36,7 @@ import type { BackendSendInput, } from '@maka/core/backend-types'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; +import type { LastRequestAnchor } from '@maka/core/usage-record-schema'; import type { AiSdkCompactionCapabilities, @@ -838,12 +839,26 @@ export class AiSdkCompaction { const priorContentEvents = (input.runtimeContext ?? []) .filter((event) => event.turnId !== input.turnId) .filter(isHistoryCompactContentEvent); - return new MidTurnCapacityCompactState( + const state = new MidTurnCapacityCompactState( headAnchor, priorContentEvents, input.runtimeContextRunHeaders ?? [], resolveSelectedModelContextWindow(this.input.connection, this.input.modelId), ); + // Seed the turn's FIRST request with the last request the provider + // actually counted, so step 0 is estimated like every later step instead + // of guessing the whole payload at char/4. + const persisted = persistedRequestAnchor( + input.runtimeContext ?? [], + input.runtimeContextRunHeaders ?? [], + this.input.modelId, + this.targetConnectionId, + ); + if (persisted) { + state.lastRequestInputTokens = persisted.inputTokens; + state.lastRequestPayloadChars = persisted.payloadChars; + } + return state; } /** @@ -917,14 +932,19 @@ export class AiSdkCompaction { // request's chars would under-estimate the retry by the whole previous // step growth — so a missing baseline forces the whole-payload cold // start, exactly like a missing usage sample. - const lastStepInputTokens = options.completedSteps.at(-1)?.usage?.inputTokens; - state.lastRequestInputTokens = - state.lastRequestPayloadChars !== undefined && - lastStepInputTokens !== undefined && - Number.isFinite(lastStepInputTokens) && - lastStepInputTokens > 0 - ? lastStepInputTokens - : undefined; + // + // Before the first step finishes nothing in-send has overwritten the + // seeded pair, so leave it alone rather than clearing a coherent anchor. + if (options.completedSteps.length > 0) { + const lastStepInputTokens = options.completedSteps.at(-1)?.usage?.inputTokens; + state.lastRequestInputTokens = + state.lastRequestPayloadChars !== undefined && + lastStepInputTokens !== undefined && + Number.isFinite(lastStepInputTokens) && + lastStepInputTokens > 0 + ? lastStepInputTokens + : undefined; + } // A skipped trigger is never silent: every failure-driven skip records a // failedOpen decision. @@ -972,10 +992,7 @@ export class AiSdkCompaction { const estimate = forcedEstimate ?? estimateNextRequestTokens({ - ...(state.lastRequestInputTokens !== undefined - ? { priorUsageTokens: state.lastRequestInputTokens } - : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), + ...requestEstimateAnchor(state, payloadChars), charsPerToken, coldStartChars: payloadChars, }); @@ -1416,8 +1433,11 @@ export class AiSdkCompaction { // Reset the baseline: the capacity hook's usage anchor is only coherent // paired with the payload chars of the SAME request, and a missing // baseline forces the whole-payload cold-start estimate instead of a - // stale pairing against the dead attempt. + // stale pairing against the dead attempt. The cross-turn seed goes with + // it: falling back to an even older request's anchor pairs worse, not + // better. state.lastRequestPayloadChars = undefined; + state.lastRequestInputTokens = undefined; return { messages: outcome.replacementMessages }; } @@ -1481,10 +1501,7 @@ export class AiSdkCompaction { if (state.capacity !== undefined && options.stepNumber >= 1) { const estimateFinal = (): number => estimateNextRequestTokens({ - ...(state.lastRequestInputTokens !== undefined - ? { priorUsageTokens: state.lastRequestInputTokens } - : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), + ...requestEstimateAnchor(state, payloadChars), charsPerToken, coldStartChars: payloadChars, }); @@ -1645,6 +1662,9 @@ export class MidTurnCapacityCompactState { * Raw serialized chars of the final provider request. Overflow recovery * uses this as its shrink-reference baseline because it must compare the * actual rejected projection with a candidate replacement. + * + * Seeded before the turn's first request from the anchor a previous turn + * persisted, so it can describe a request from an earlier send. */ lastRequestPayloadChars: number | undefined; /** @@ -1655,6 +1675,10 @@ export class MidTurnCapacityCompactState { * them twice. Undefined when the last step's usage is missing or unusable * (no positive input count); estimates then fall back to the whole-payload * cold-start path — an unusable sample is unknown, never zero. + * + * Written and cleared as a pair with `lastRequestPayloadChars`, seeded from + * the previous turn's persisted anchor — see `LastRequestAnchor` for why the + * two only mean anything together. */ lastRequestInputTokens: number | undefined; /** Latest durable checkpoint (loaded or written) for roll-forward summaries. */ @@ -1741,6 +1765,60 @@ function midTurnRequestPayloadChars( ); } +/** + * The newest `LastRequestAnchor` persisted in the prior context. + * + * Reverse scan, and the FIRST anchor-bearing usage record decides — an older + * anchor describes a request further from the one about to go out, so a + * rejected newest anchor means cold start, never a fallback to an older one. + * The synthetic `token_usage` a manual `/compact` writes carries no anchor and + * is skipped for free. + * + * The anchor is a token count in the anchoring model's tokenizer, so it only + * transfers to a request going to the same model over the same connection. + */ +function persistedRequestAnchor( + events: readonly RuntimeEvent[], + runHeaders: readonly AgentRunHeader[], + modelId: string, + connectionId: string | undefined, +): LastRequestAnchor | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + const anchor = event?.actions?.tokenUsage?.lastRequestAnchor; + if (!anchor) continue; + const header = runHeaders.find((candidate) => candidate.runId === event?.runId); + if (!header || header.modelId !== modelId || header.llmConnectionId !== connectionId) { + return undefined; + } + return anchor; + } + return undefined; +} + +/** + * The estimate inputs for the request about to go out: the paired anchor and + * the signed char delta against the payload that anchor was reported for. + * + * A delta wider than the whole payload is the pairing's own alarm — it takes a + * baseline more than twice the current payload, which within a send cannot + * happen without a restructuring that already resets the baseline, and across a + * turn boundary means the prior tail was re-materialized down a different path + * than the request the anchor was reported for. A pairing that far off + * estimates worse than none, so drop it and let the cold start answer. + */ +function requestEstimateAnchor( + state: MidTurnCapacityCompactState, + payloadChars: number, +): { priorUsageTokens?: number; appendedChars: number } { + const anchor = state.lastRequestInputTokens; + const baseline = state.lastRequestPayloadChars; + if (anchor === undefined || baseline === undefined) return { appendedChars: 0 }; + const appendedChars = payloadChars - baseline; + if (Math.abs(appendedChars) > payloadChars) return { appendedChars: 0 }; + return { priorUsageTokens: anchor, appendedChars }; +} + /** * Outcome of folding the durable turn ledger into a replacement projection. * Shared by the proactive projection stage (which maps it to keepProjection / diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 7b12035cf3..ac02be04bb 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -545,6 +545,9 @@ function tokenUsageFromMessage( : {}), ...(message.promptSegments !== undefined ? { promptSegments: message.promptSegments } : {}), ...(message.contextBudget !== undefined ? { contextBudget: message.contextBudget } : {}), + ...(message.lastRequestAnchor !== undefined + ? { lastRequestAnchor: message.lastRequestAnchor } + : {}), }; } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index c4dda0edea..3243fe079d 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1112,6 +1112,9 @@ function projectTokenUsage( : {}), ...(usage.promptSegments !== undefined ? { promptSegments: usage.promptSegments } : {}), ...(usage.contextBudget !== undefined ? { contextBudget: usage.contextBudget } : {}), + ...(usage.lastRequestAnchor !== undefined + ? { lastRequestAnchor: usage.lastRequestAnchor } + : {}), ...(event.refs?.providerRequestTraceId !== undefined ? { providerRequestTraceId: event.refs.providerRequestTraceId } : {}), @@ -1567,6 +1570,7 @@ function semanticMessage(message: StoredMessage): unknown { promptSegments: message.promptSegments, contextBudget: message.contextBudget, providerRequestTraceId: message.providerRequestTraceId, + lastRequestAnchor: message.lastRequestAnchor, }; case 'turn_state': return { diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 3f8240dc92..9ab6e119ae 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -563,6 +563,9 @@ function mapBackendSessionEvent( : {}), ...(event.promptSegments !== undefined ? { promptSegments: event.promptSegments } : {}), ...(event.contextBudget !== undefined ? { contextBudget: event.contextBudget } : {}), + ...(event.lastRequestAnchor !== undefined + ? { lastRequestAnchor: event.lastRequestAnchor } + : {}), }, }, ...(event.providerRequestTraceId !== undefined From 21dd6323ea7482124a1897636cd97da3a9aa5922 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 03:51:47 +0800 Subject: [PATCH 6/9] feat(runtime): estimate the first request of a turn from the persisted anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn trigger and the final-request rescue both stood down on step 0, because the only sizing available there was chars/4 over the whole payload — too crude to start a summarizer on, and already the gate the pre-turn path spends. So the request most likely to be the largest one of the whole turn was the one nothing measured. With a previous turn's anchor seeded into the state, step 0 is no longer a guess: it is the same real-usage anchor plus signed char delta every later step is judged by. Open both gates exactly that far — an anchored step 0 is measured, an unanchored one still stands down, so a fresh session, a model switch and old sessions all behave as before. The fold itself reuses the pre_turn boundary the reactive step-0 recovery already picks: at step 0 the head anchor is pinned into the verbatim tail rather than covered, since folding the turn's only new event would save nothing. Expected behavior change: a long session in a language the chars/4 ruler under-counts (CJK especially) will now start compacting at the top of a turn where it previously waited for step 1. That is the estimate getting honest, not a regression — the pre-turn ruler that let those turns through measures neither the system prompt nor the tool schemas. --- .../mid-turn-capacity-backend.test.ts | 175 +++++++++++++++++- packages/runtime/src/ai-sdk-compaction.ts | 31 +++- 2 files changed, 197 insertions(+), 9 deletions(-) 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 39323626d0..2456698cfa 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ModelCallCommit } from '@maka/core/agent-run'; +import type { AgentRunHeader, ModelCallCommit } from '@maka/core/agent-run'; import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { setImmediate as flushMacrotask } from 'node:timers/promises'; @@ -71,6 +71,7 @@ interface MidTurnFixture { toolExecutions: string[]; summarizerCalls: number; priorEvents: RuntimeEvent[]; + priorRunHeaders: AgentRunHeader[]; anchor: RuntimeEvent; /** The fixture's durable RuntimeEvent ledger for the current turn/run. */ ledger: RuntimeEvent[]; @@ -151,6 +152,12 @@ interface MidTurnFixtureOptions { assistantTextInFirstStep?: boolean; /** Override the first step's reported usage; 'missing' = empty usage object. */ firstStepUsage?: { input: number; output: number } | 'missing'; + /** Override the final (text) step's reported usage. */ + finalStepUsage?: { input: number; output: number }; + /** Prior-turn RuntimeEvents appended after the shaped priors (e.g. a persisted usage anchor). */ + extraPriorEvents?: readonly RuntimeEvent[]; + /** Run headers for the prior turns, so a persisted anchor can be identity-gated. */ + priorRunHeaders?: readonly AgentRunHeader[]; /** System prompt size sent through the provider's separate system field. */ systemPromptChars?: number; /** Enable and capture automatic Memory extraction without allowing it to settle. */ @@ -216,7 +223,13 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { { type: 'text-start', id: 'text-1' }, { type: 'text-delta', id: 'text-1', delta: 'done' }, { type: 'text-end', id: 'text-1' }, - { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: usage(120, 10) }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: options.finalStepUsage + ? usage(options.finalStepUsage.input, options.finalStepUsage.output) + : usage(120, 10), + }, ]; const chunksForCall = (call: number): LanguageModelV4StreamPart[] => { if (options.bigToolGroup) { @@ -333,6 +346,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { `PRIOR_FACT answer ${'q'.repeat(priorChars)}`, ), ]; + priorEvents.push(...(options.extraPriorEvents ?? [])); const anchor: RuntimeEvent = { ...runtimeTextEvent('anchor-1', 'turn-1', 'user', ANCHOR_TEXT), ...(options.currentImage @@ -584,6 +598,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { return fixture.ledgerReads; }, priorEvents, + priorRunHeaders: [...(options.priorRunHeaders ?? [])], anchor, ledger, modelCalls, @@ -608,6 +623,7 @@ async function runFixtureTurn( text: ANCHOR_TEXT, context: [], runtimeContext: [...fixture.priorEvents], + runtimeContextRunHeaders: [...fixture.priorRunHeaders], })) { if (consumer === 'slow') { // Scheduling perturbation: hold the durable write back across several @@ -1527,6 +1543,115 @@ describe('the shipped runtime default drives the proactive long-turn journey (is assert.equal((anchor?.payloadChars ?? 0) > 0, true); }); + test('a turn seeded with the previous turn’s anchor compacts its FIRST request', async () => { + // End to end: turn one really runs, the backend writes the anchor, the + // fixture's own mapper puts it in the durable ledger, and turn two reads + // it back as prior context. Nothing here hand-builds the anchor. + const previous = buildFixture({ + priorChars: 2_000, + contextWindow: 1_000_000, + finalAtSecondCall: true, + // A dense (CJK-shaped) request: real input tokens well above chars/4. + finalStepUsage: { input: 30_000, output: 10 }, + }); + await runFixtureTurn(previous); + const persisted = priorTurnUsageEvents(previous); + assert.equal(persisted.length, 1); + assert.equal( + (persisted[0]?.actions?.tokenUsage?.lastRequestAnchor as { inputTokens: number } | undefined) + ?.inputTokens, + 30_000, + ); + + // High water 20k: the whole payload at chars/4 is nowhere near it, so + // without the anchor step 0 has nothing to act on. The anchor says the + // request really costs 30k, and the first request is compacted. + const seeded = buildFixture({ + priorChars: 2_000, + contextWindow: 40_000, + reserveTokens: 20_000, + finalAtSecondCall: true, + extraPriorEvents: persisted, + priorRunHeaders: [priorRunHeader()], + }); + await runFixtureTurn(seeded); + + assert.equal(seeded.recorded.length, 1); + // The first request folds on the pre_turn boundary: the head anchor stays + // verbatim in the successor tail instead of being covered. + assert.equal( + compactionDecisions(seeded).find((decision) => decision.decision === 'replaced')?.phase, + 'pre_turn', + ); + const firstPrompt = promptJson(seeded, 0); + assert.match(firstPrompt, /maka_history_compact_checkpoint/); + assert.match(firstPrompt, /MID_TURN_SUMMARY_SENTINEL/); + assert.equal(firstPrompt.includes('PRIOR_FACT'), false); + assert.equal(firstPrompt.includes(ANCHOR_TEXT), true); + const complete = seeded.events.find((event) => event.type === 'complete'); + assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); + }); + + test('the same turn without an anchor leaves its first request alone', async () => { + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 40_000, + reserveTokens: 20_000, + finalAtSecondCall: true, + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, 0); + assert.equal(promptJson(fixture, 0).includes('PRIOR_FACT'), true); + }); + + test('an anchor is discarded unless a run header proves it came from this model', async () => { + // Input tokens are a count in one model's tokenizer; nothing converts them. + // A header naming another model and no header at all fail the same way. + for (const priorRunHeaders of [[{ ...priorRunHeader(), modelId: 'some-other-model' }], []]) { + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 40_000, + reserveTokens: 20_000, + finalAtSecondCall: true, + extraPriorEvents: [priorUsageEvent({ inputTokens: 30_000, payloadChars: 4_000 })], + priorRunHeaders, + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, 0); + } + }); + + test('a synthetic /compact usage row does not shadow the real anchor', async () => { + // A manual /compact writes `input: 0, output: 0` without going through the + // provider send, so it carries no anchor. The reverse scan skips it and + // still finds the last real request. + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 40_000, + reserveTokens: 20_000, + finalAtSecondCall: true, + extraPriorEvents: [ + priorUsageEvent({ inputTokens: 30_000, payloadChars: 4_000 }), + { + ...runtimeTextEvent('prior-compact-usage', 'turn-0', 'model', ''), + id: 'prior-compact-usage', + runId: 'run-0', + role: 'system' as const, + author: 'system' as const, + content: undefined, + actions: { tokenUsage: { input: 0, output: 0 } }, + }, + ], + priorRunHeaders: [priorRunHeader()], + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, 1); + assert.match(promptJson(fixture, 0), /maka_history_compact_checkpoint/); + }); + test('an unrescuable turn under the shipped default still dispatches', async () => { // Same runtime-derived default (window 120 → reserve 30, high water 90): // no prior turns leaves no safe completed span, and the request genuinely @@ -1548,6 +1673,52 @@ describe('the shipped runtime default drives the proactive long-turn journey (is }); }); +/** + * The turn's durable token_usage RuntimeEvents, re-labelled as a previous turn + * so the next fixture turn sees them the way `prior-run-context` serves them + * back after a restart. + */ +function priorTurnUsageEvents(fixture: MidTurnFixture): RuntimeEvent[] { + return fixture.ledger + .filter((event) => event.actions?.tokenUsage !== undefined) + .map((event) => ({ ...event, turnId: 'turn-0', runId: 'run-0', invocationId: 'run-0' })); +} + +function priorUsageEvent(lastRequestAnchor: { + inputTokens: number; + payloadChars: number; +}): RuntimeEvent { + return { + ...runtimeTextEvent('prior-usage', 'turn-0', 'model', ''), + id: 'prior-usage', + runId: 'run-0', + invocationId: 'run-0', + role: 'system', + author: 'system', + content: undefined, + actions: { tokenUsage: { input: 30_100, output: 30, lastRequestAnchor } }, + }; +} + +function priorRunHeader(): AgentRunHeader { + return { + runId: 'run-0', + invocationId: 'run-0', + sessionId: 'session-1', + turnId: 'turn-0', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionId: 'test-connection-id', + llmConnectionSlug: 'anthropic-main', + modelId: 'mock-model-id', + cwd: '/tmp/maka', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + }; +} + function runtimeTextEvent( id: string, turnId: string, diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index d91ae3c9cf..c90967dbc9 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -909,10 +909,6 @@ export class AiSdkCompaction { ) ?? projectedMessages; const keepProjection = (): RequestProjection | undefined => projectedMessages ? { messages: projectedMessages } : undefined; - // Step 0 is shaped by the pre_turn path; the mid-turn trigger only runs - // between steps, once completed-step usage and events exist. - if (options.stepNumber < 1) return keepProjection(); - // Real usage for the last finished step, read synchronously from the // SDK's own step results (the same numbers the finish-step chunk // carries) — no coupling to how far the stream consumer has advanced. @@ -946,6 +942,9 @@ export class AiSdkCompaction { : undefined; } + // The turn's first request folds as a pre_turn boundary, like the + // reactive step-0 recovery; later steps fold mid_turn. + const phase = options.stepNumber === 0 ? 'pre_turn' : 'mid_turn'; // A skipped trigger is never silent: every failure-driven skip records a // failedOpen decision. const failOpen = (failOpenReason: string): RequestProjection | undefined => { @@ -954,7 +953,7 @@ export class AiSdkCompaction { stage: 'activeStep', sourceKind: 'runtimeEvents', decision: 'failedOpen', - phase: 'mid_turn', + phase, boundaryKind: 'historyCompact', reason: 'context_limit', failOpenReason, @@ -989,10 +988,23 @@ export class AiSdkCompaction { ); const forcedEstimate = state.forcedTriggerEstimate; state.forcedTriggerEstimate = undefined; + const anchored = requestEstimateAnchor(state, payloadChars); + // The turn's FIRST request only gets a trigger when a previous turn left + // a usable anchor. Without one the estimate is the whole payload at + // chars/4 — the same guess the pre_turn gate already spends, and far too + // crude to start a summarizer on. With one, step 0 is judged exactly like + // every later step. + if ( + options.stepNumber < 1 && + forcedEstimate === undefined && + anchored.priorUsageTokens === undefined + ) { + return keepProjection(); + } const estimate = forcedEstimate ?? estimateNextRequestTokens({ - ...requestEstimateAnchor(state, payloadChars), + ...anchored, charsPerToken, coldStartChars: payloadChars, }); @@ -1009,6 +1021,7 @@ export class AiSdkCompaction { // keep the raw projection on skip/fail, apply the fold on success. const outcome = await this.compactActiveRequestHistory({ turnId, + phase, origin, state, queue, @@ -1498,7 +1511,11 @@ export class AiSdkCompaction { charsPerToken, ); let payloadChars = finalPayloadChars(); - if (state.capacity !== undefined && options.stepNumber >= 1) { + // Same rule as the trigger: the turn's first request is measured only + // when a previous turn left a usable anchor to measure it against. + const anchoredAtStepZero = + requestEstimateAnchor(state, payloadChars).priorUsageTokens !== undefined; + if (state.capacity !== undefined && (options.stepNumber >= 1 || anchoredAtStepZero)) { const estimateFinal = (): number => estimateNextRequestTokens({ ...requestEstimateAnchor(state, payloadChars), From 1dd54632c7155fda2aeb8519228a07707694c6e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 04:56:16 +0800 Subject: [PATCH 7/9] refactor(runtime): make the anchored estimate the one turn-start trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two authorities answered the same question at turn start: a pre-turn gate weighing prior history events at chars/4 against a shaping threshold, and the step-0 anchored estimate weighing the whole outgoing payload against the real window. Demote the gate to what it actually is now — the fallback for the cases the anchored estimate cannot reach (no persisted anchor, no mid-turn seam, or a model that declares no window). The anchor's central invariant was also broken: the payload was measured from the base system prompt and the pre-dispatch tool set, while dispatch appends step-specific prompt fragments and clears the tool set entirely on a finalization step. A persisted provider input count could therefore be paired with a payload that describes a different request. One `resolveDispatch` seam on the request-projection context now resolves what the step really sends, and both the capacity trigger and the final-request rescue measure that. Removed along the way, all consumer-free or derivable: - `exceedsContextWindow`, left behind by the deleted local termination verdict - two dead imports in ai-sdk-backend - `EstimateNextRequestTokensInput.coldStartChars` and its branch: unanchored, the whole payload is the delta against a zero baseline, so one formula stands - the `midTurn.reserveTailEvents` policy knob no producer ever wrote - `MalformedHistoryCompactSummaryReason`'s derivation from the retired `ContextBudgetExhaustedDetail` enum - a duplicate run-header argument and one export that only served a test --- .../active-tool-result-prune.test.ts | 2 + .../src/__tests__/history-compaction.test.ts | 12 +- .../mid-turn-capacity-backend.test.ts | 267 ++++++++++++------ packages/runtime/src/ai-sdk-backend.ts | 59 ++-- packages/runtime/src/ai-sdk-compaction.ts | 60 ++-- packages/runtime/src/history-compact-error.ts | 23 +- packages/runtime/src/history-compaction.ts | 38 +-- packages/runtime/src/request-projection.ts | 14 + 8 files changed, 291 insertions(+), 184 deletions(-) diff --git a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts index 394fc49dc5..2acf4c70b5 100644 --- a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts +++ b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts @@ -82,6 +82,7 @@ describe('active current-turn tool-result pruning', () => { stepNumber: 1, model: {}, messages: originalMessages, + resolveDispatch: (active) => ({ systemPromptChars: 0, activeTools: [...(active ?? [])] }), }); assert.deepEqual(result?.activeTools, ['Read', TOOL_SEARCH_NAME]); @@ -327,6 +328,7 @@ describe('active current-turn tool-result pruning', () => { model: {}, messages: [{ role: 'user', content: 'load rive' }], activeTools: plan.activeTools, + resolveDispatch: (active) => ({ systemPromptChars: 0, activeTools: [...(active ?? [])] }), }); assert.ok(!plan.activeTools.includes('RiveWorkflow'), 'step 0 hides the group tool'); diff --git a/packages/runtime/src/__tests__/history-compaction.test.ts b/packages/runtime/src/__tests__/history-compaction.test.ts index d019a2a916..9f941bf193 100644 --- a/packages/runtime/src/__tests__/history-compaction.test.ts +++ b/packages/runtime/src/__tests__/history-compaction.test.ts @@ -22,7 +22,6 @@ import { describe, test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { estimateNextRequestTokens, - exceedsContextWindow, exceedsHighWater, applyRuntimeEventHistoryCompact, planHistoryCompaction, @@ -58,17 +57,14 @@ describe('context compaction trigger measurement', () => { }); test('falls back to whole-projection char/4 on cold start (no usage)', () => { - assert.equal( - estimateNextRequestTokens({ appendedChars: 40, charsPerToken: 4, coldStartChars: 800 }), - 200, - ); + // Unanchored, the caller passes the whole payload as the delta against a + // zero baseline, so the same formula yields the cold-start estimate. + assert.equal(estimateNextRequestTokens({ appendedChars: 800, charsPerToken: 4 }), 200); }); - test('high-water crosses at contextWindow minus reserve; hard cap at the window', () => { + test('high-water crosses at contextWindow minus reserve', () => { assert.equal(exceedsHighWater(100_000, 128_000, 16_384), false); assert.equal(exceedsHighWater(120_000, 128_000, 16_384), true); - assert.equal(exceedsContextWindow(120_000, 128_000), false); - assert.equal(exceedsContextWindow(130_000, 128_000), true); }); }); 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 2456698cfa..c528f6ae66 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -62,6 +62,7 @@ const RAW_SPAN_TWO = 'RAW_SPAN_TWO_'.repeat(160); const ROLLING_TAIL = 'ROLLING_TAIL_'.repeat(740); const HUGE_RESULT = 'HUGE_RESULT_'.repeat(670); const ANCHOR_TEXT = 'compact this very long turn but keep my exact words'; +const BIG_ACTIVE_TOOL_SCHEMA_CHARS = 12_000; interface MidTurnFixture { backend: AiSdkBackend; @@ -160,6 +161,16 @@ interface MidTurnFixtureOptions { priorRunHeaders?: readonly AgentRunHeader[]; /** System prompt size sent through the provider's separate system field. */ systemPromptChars?: number; + /** Lower the pre-turn history-shaping threshold so that gate can be exercised. */ + maxHistoryEstimatedTokens?: number; + /** An always-active tool whose schema dominates the request payload. */ + bigActiveTool?: boolean; + /** + * Run as a child agent with a two-step budget, so the turn's LAST request is + * the child-summary finalization step: it adds a prompt fragment and sends no + * tool schemas at all. + */ + childFinalization?: boolean; /** Enable and capture automatic Memory extraction without allowing it to settle. */ captureMemoryExtraction?: boolean; memoryGate?: @@ -423,7 +434,10 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { const backend = createTestAiSdkBackend({ sessionId: 'session-1', - header: header(), + header: options.childFinalization + ? { ...header(), collaborationMode: 'agent' as const } + : header(), + ...(options.childFinalization ? { maxSteps: 2 } : {}), appendMessage: async (message) => { messages.push(message); }, @@ -461,6 +475,16 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { return { body: RAW_SPAN_TWO }; }, }, + ...(options.bigActiveTool + ? [ + { + name: 'BigActive', + description: `BIG_ACTIVE_SCHEMA ${'A'.repeat(BIG_ACTIVE_TOOL_SCHEMA_CHARS)}`, + parameters: z.object({ q: z.string() }), + impl: async () => ({ ok: true }), + }, + ] + : []), ...(options.bigToolGroup ? [ { @@ -493,7 +517,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { ) : { name: 'mid-turn-test', - maxHistoryEstimatedTokens: 100_000, + maxHistoryEstimatedTokens: options.maxHistoryEstimatedTokens ?? 100_000, historyCompact: { enabled: true, midTurn: { enabled: true, reserveTokens }, @@ -1543,66 +1567,120 @@ describe('the shipped runtime default drives the proactive long-turn journey (is assert.equal((anchor?.payloadChars ?? 0) > 0, true); }); - test('a turn seeded with the previous turn’s anchor compacts its FIRST request', async () => { - // End to end: turn one really runs, the backend writes the anchor, the - // fixture's own mapper puts it in the durable ledger, and turn two reads - // it back as prior context. Nothing here hand-builds the anchor. - const previous = buildFixture({ - priorChars: 2_000, + /** + * Turn start has exactly ONE trigger. These rows differ only in what the + * prior context offers — a persisted anchor, an armed pre-turn gate, both, + * neither — and each names the one thing that may act on it. The gate weighs + * a subset of the payload with a cruder ruler, so it is a fallback, never a + * parallel authority. + */ + for (const row of [ + { + name: 'a persisted anchor compacts the turn’s FIRST request', + priorAnchor: 'previous_turn', + armGate: false, + foldedBy: 'anchored_estimate', + }, + { + name: 'neither armed leaves the first request alone', + priorAnchor: 'none', + armGate: false, + foldedBy: 'nothing', + }, + { + name: 'the anchored estimate acts while the armed gate stands down', + priorAnchor: 'previous_turn', + armGate: true, + foldedBy: 'anchored_estimate', + }, + { + name: 'without an anchor the armed gate shapes the oversized history', + priorAnchor: 'none', + armGate: true, + foldedBy: 'pre_turn_gate', + }, + { + // A manual /compact writes `input: 0, output: 0` without going through + // the provider send, so it carries no anchor: the reverse scan skips it + // and still finds the last real request. + name: 'a synthetic /compact usage row does not shadow the real anchor', + priorAnchor: 'behind_compact_row', + armGate: false, + foldedBy: 'anchored_estimate', + }, + ] as const) { + test(`turn start: ${row.name}`, async () => { + // High water 20k: the whole payload at chars/4 is nowhere near it, so + // without an anchor step 0 has nothing to act on. The anchor says the + // request really costs 30k. + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 40_000, + reserveTokens: 20_000, + finalAtSecondCall: true, + ...(row.armGate ? { maxHistoryEstimatedTokens: 400 } : {}), + extraPriorEvents: await priorAnchorEvents(row.priorAnchor), + ...(row.priorAnchor === 'none' ? {} : { priorRunHeaders: [priorRunHeader()] }), + }); + await runFixtureTurn(fixture); + + const decisions = compactionDecisions(fixture); + const anchoredFold = decisions.find( + (decision) => decision.stage === 'activeStep' && decision.decision === 'replaced', + ); + assert.equal(anchoredFold !== undefined, row.foldedBy === 'anchored_estimate'); + assert.equal( + decisions.some((decision) => decision.stage === 'priorReplay'), + row.foldedBy === 'pre_turn_gate', + ); + assert.equal(fixture.recorded.length, row.foldedBy === 'nothing' ? 0 : 1); + + const firstPrompt = promptJson(fixture, 0); + if (row.foldedBy === 'nothing') { + assert.equal(firstPrompt.includes('PRIOR_FACT'), true); + return; + } + assert.match(firstPrompt, /maka_history_compact_checkpoint/); + assert.equal(firstPrompt.includes('PRIOR_FACT'), false); + assert.equal(firstPrompt.includes(ANCHOR_TEXT), true); + if (row.foldedBy !== 'anchored_estimate') return; + // The first request folds on the pre_turn boundary: the head anchor stays + // verbatim in the successor tail instead of being covered. + assert.equal(anchoredFold?.phase, 'pre_turn'); + assert.match(firstPrompt, /MID_TURN_SUMMARY_SENTINEL/); + }); + } + + test('the anchor a finalization step writes excludes the tool schemas it cleared', async () => { + // The child-summary finalization step sends no tools at all. Measuring the + // pre-dispatch tool set would pair the provider's real input count with a + // payload 12k chars larger than the request it counted. + const finalization = buildFixture({ contextWindow: 1_000_000, finalAtSecondCall: true, - // A dense (CJK-shaped) request: real input tokens well above chars/4. - finalStepUsage: { input: 30_000, output: 10 }, + bigActiveTool: true, + childFinalization: true, }); - await runFixtureTurn(previous); - const persisted = priorTurnUsageEvents(previous); - assert.equal(persisted.length, 1); - assert.equal( - (persisted[0]?.actions?.tokenUsage?.lastRequestAnchor as { inputTokens: number } | undefined) - ?.inputTokens, - 30_000, - ); - - // High water 20k: the whole payload at chars/4 is nowhere near it, so - // without the anchor step 0 has nothing to act on. The anchor says the - // request really costs 30k, and the first request is compacted. - const seeded = buildFixture({ - priorChars: 2_000, - contextWindow: 40_000, - reserveTokens: 20_000, + await runFixtureTurn(finalization); + const control = buildFixture({ + contextWindow: 1_000_000, finalAtSecondCall: true, - extraPriorEvents: persisted, - priorRunHeaders: [priorRunHeader()], + bigActiveTool: true, }); - await runFixtureTurn(seeded); + await runFixtureTurn(control); - assert.equal(seeded.recorded.length, 1); - // The first request folds on the pre_turn boundary: the head anchor stays - // verbatim in the successor tail instead of being covered. assert.equal( - compactionDecisions(seeded).find((decision) => decision.decision === 'replaced')?.phase, - 'pre_turn', + promptJson(finalization, 1).includes('BIG_ACTIVE_SCHEMA'), + false, + 'the finalization request itself carries no tool schema', + ); + assert.equal(anchorOf(control) !== undefined, true); + assert.equal( + (anchorOf(control)?.payloadChars ?? 0) > BIG_ACTIVE_TOOL_SCHEMA_CHARS, + true, + 'an ordinary last request does carry the schema', ); - const firstPrompt = promptJson(seeded, 0); - assert.match(firstPrompt, /maka_history_compact_checkpoint/); - assert.match(firstPrompt, /MID_TURN_SUMMARY_SENTINEL/); - assert.equal(firstPrompt.includes('PRIOR_FACT'), false); - assert.equal(firstPrompt.includes(ANCHOR_TEXT), true); - const complete = seeded.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - }); - - test('the same turn without an anchor leaves its first request alone', async () => { - const fixture = buildFixture({ - priorChars: 2_000, - contextWindow: 40_000, - reserveTokens: 20_000, - finalAtSecondCall: true, - }); - await runFixtureTurn(fixture); - - assert.equal(fixture.recorded.length, 0); - assert.equal(promptJson(fixture, 0).includes('PRIOR_FACT'), true); + assert.equal((anchorOf(finalization)?.payloadChars ?? 0) < BIG_ACTIVE_TOOL_SCHEMA_CHARS, true); }); test('an anchor is discarded unless a run header proves it came from this model', async () => { @@ -1623,35 +1701,6 @@ describe('the shipped runtime default drives the proactive long-turn journey (is } }); - test('a synthetic /compact usage row does not shadow the real anchor', async () => { - // A manual /compact writes `input: 0, output: 0` without going through the - // provider send, so it carries no anchor. The reverse scan skips it and - // still finds the last real request. - const fixture = buildFixture({ - priorChars: 2_000, - contextWindow: 40_000, - reserveTokens: 20_000, - finalAtSecondCall: true, - extraPriorEvents: [ - priorUsageEvent({ inputTokens: 30_000, payloadChars: 4_000 }), - { - ...runtimeTextEvent('prior-compact-usage', 'turn-0', 'model', ''), - id: 'prior-compact-usage', - runId: 'run-0', - role: 'system' as const, - author: 'system' as const, - content: undefined, - actions: { tokenUsage: { input: 0, output: 0 } }, - }, - ], - priorRunHeaders: [priorRunHeader()], - }); - await runFixtureTurn(fixture); - - assert.equal(fixture.recorded.length, 1); - assert.match(promptJson(fixture, 0), /maka_history_compact_checkpoint/); - }); - test('an unrescuable turn under the shipped default still dispatches', async () => { // Same runtime-derived default (window 120 → reserve 30, high water 90): // no prior turns leaves no safe completed span, and the request genuinely @@ -1678,6 +1727,58 @@ describe('the shipped runtime default drives the proactive long-turn journey (is * so the next fixture turn sees them the way `prior-run-context` serves them * back after a restart. */ +/** The `lastRequestAnchor` the fixture's turn persisted on its usage message. */ +function anchorOf( + fixture: MidTurnFixture, +): { inputTokens: number; payloadChars: number } | undefined { + const usage = fixture.messages.find( + (message): message is { type: 'token_usage'; lastRequestAnchor?: unknown } => + (message as { type?: string }).type === 'token_usage', + ); + return usage?.lastRequestAnchor as { inputTokens: number; payloadChars: number } | undefined; +} + +/** + * The prior-turn events one turn-start row starts from. `previous_turn` runs a + * whole turn first and takes what the backend actually persisted, so nothing + * hand-builds the anchor it then reads back. + */ +async function priorAnchorEvents( + kind: 'none' | 'previous_turn' | 'behind_compact_row', +): Promise { + if (kind === 'none') return []; + if (kind === 'behind_compact_row') { + return [ + priorUsageEvent({ inputTokens: 30_000, payloadChars: 4_000 }), + { + ...runtimeTextEvent('prior-compact-usage', 'turn-0', 'model', ''), + id: 'prior-compact-usage', + runId: 'run-0', + role: 'system' as const, + author: 'system' as const, + content: undefined, + actions: { tokenUsage: { input: 0, output: 0 } }, + }, + ]; + } + const previous = buildFixture({ + priorChars: 2_000, + contextWindow: 1_000_000, + finalAtSecondCall: true, + // A dense (CJK-shaped) request: real input tokens well above chars/4. + finalStepUsage: { input: 30_000, output: 10 }, + }); + await runFixtureTurn(previous); + const persisted = priorTurnUsageEvents(previous); + assert.equal(persisted.length, 1); + assert.equal( + (persisted[0]?.actions?.tokenUsage?.lastRequestAnchor as { inputTokens: number } | undefined) + ?.inputTokens, + 30_000, + ); + return persisted; +} + function priorTurnUsageEvents(fixture: MidTurnFixture): RuntimeEvent[] { return fixture.ledger .filter((event) => event.actions?.tokenUsage !== undefined) diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 678f08cb12..fe67a5fffb 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -57,7 +57,6 @@ import type { AttachmentRef, DirectoryReference, QuoteRef, - ContextBudgetExhaustedDetail, } from '@maka/core/events'; import type { StoredMessage, @@ -167,6 +166,7 @@ import { persistedOpenAiResponsesStepMessages } from './openai-responses-continu import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { composeRequestProjection, + type DispatchRequestShape, type RequestProjection, type RequestProjectionContext, type RequestProjectionStage, @@ -183,6 +183,7 @@ import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import type { AutomaticMemoryCompactionDecision, AutomaticMemoryCompactionDispatch, + MidTurnCapacityCompactState, ProviderImageBudget, } from './ai-sdk-compaction.js'; import { @@ -279,7 +280,6 @@ import { projectHistoryCompactCheckpointReplay, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; -import { isMalformedHistoryCompactSummaryReason } from './history-compact-error.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; export { DEFAULT_PERMISSION_TIMEOUT_MS, @@ -1710,6 +1710,7 @@ export class AiSdkBackend implements AgentBackend { const priorReplayResult = await this.buildPriorMessages( scope, input, + midTurnState, this.automaticMemoryCompactionSupported() ? true : undefined, ); if (scope.aborted) { @@ -1935,14 +1936,11 @@ export class AiSdkBackend implements AgentBackend { patch, ); }; - const midTurnSystemPromptChars = systemPrompt?.length ?? 0; const midTurnCapacityHook = this.compaction.buildMidTurnCapacityCompactProjection( turnId, midTurnState, queue, providerTools, - () => currentRepairToolNames(), - midTurnSystemPromptChars, onMidTurnDiagnosticPatch, scope, this.automaticMemoryCompactionSupported() @@ -1984,9 +1982,7 @@ export class AiSdkBackend implements AgentBackend { )!, state: midTurnState, providerTools, - fallbackActiveTools: () => currentRepairToolNames(), charsPerToken: this.input.contextBudget?.charsPerToken ?? 4, - systemPromptChars: midTurnSystemPromptChars, }) : shapedProjection; @@ -2009,15 +2005,10 @@ export class AiSdkBackend implements AgentBackend { if (missingSteering.length > 0) requestMessages = [...requestMessages, ...missingSteering]; } - const shaped = requestProjection - ? await requestProjection({ - completedSteps: completedProviderSteps, - stepNumber: runtimeSteps, - model, - messages: requestMessages, - }) - : undefined; - const projectedMessages = shaped?.messages ?? requestMessages; + // Resolved BEFORE request projection so the capacity measurement and + // the request that goes out are the same request: a finalization step + // adds prompt fragments and sends no tool schemas, and an anchor + // paired with the un-finalized shape describes a different payload. const finalChildSummaryStep = this.input.header.collaborationMode === 'agent' && maxSteps !== undefined && @@ -2032,16 +2023,32 @@ export class AiSdkBackend implements AgentBackend { if (sandboxBoundaryFinalizationStep) { toolRuntime.forceSandboxBoundaryFinalization(); } - const activeToolsForRequest = - finalChildSummaryStep || sandboxBoundaryFinalizationStep - ? [] - : boundaryAwareToolNames(shaped?.activeTools ?? plan.currentRepairToolNames()); const requestSystemPrompt = joinPromptFragments([ systemPrompt, finalChildSummaryStep ? CHILD_STEP_BUDGET_FINALIZATION_PROMPT : undefined, toolRuntime.hasSandboxBoundaryDenial() ? SANDBOX_BOUNDARY_DENIED_FOR_TURN : undefined, sandboxBoundaryFinalizationStep ? SANDBOX_BOUNDARY_FINALIZATION_PROMPT : undefined, ]); + const resolveDispatch = ( + active: readonly string[] | undefined, + ): DispatchRequestShape => ({ + systemPromptChars: requestSystemPrompt?.length ?? 0, + activeTools: + finalChildSummaryStep || sandboxBoundaryFinalizationStep + ? [] + : boundaryAwareToolNames(active ?? plan.currentRepairToolNames()), + }); + const shaped = requestProjection + ? await requestProjection({ + completedSteps: completedProviderSteps, + stepNumber: runtimeSteps, + model, + messages: requestMessages, + resolveDispatch, + }) + : undefined; + const projectedMessages = shaped?.messages ?? requestMessages; + const activeToolsForRequest = resolveDispatch(shaped?.activeTools).activeTools; providerRequestTracker?.setStep(runtimeSteps); let attemptMessages = projectedMessages; let providerAttempt = 1; @@ -2404,7 +2411,7 @@ export class AiSdkBackend implements AgentBackend { currentMessages: attemptMessages, providerTools, activeTools: activeToolsForRequest, - systemPromptChars: midTurnSystemPromptChars, + systemPromptChars: requestSystemPrompt?.length ?? 0, queue, onDiagnosticPatch: onMidTurnDiagnosticPatch, origin: scope, @@ -2432,6 +2439,7 @@ export class AiSdkBackend implements AgentBackend { model, messages: recovered.messages, activeTools: activeToolsForRequest, + resolveDispatch, }) : undefined; attemptMessages = recoveredProjection?.messages ?? recovered.messages; @@ -3342,6 +3350,7 @@ export class AiSdkBackend implements AgentBackend { private async buildPriorMessages( scope: TurnScope, input: BackendSendInput, + midTurnState: MidTurnCapacityCompactState | undefined, automaticMemory?: true, ): Promise { const priorStored = input.context.filter((message) => message.turnId !== input.turnId); @@ -3392,7 +3401,15 @@ export class AiSdkBackend implements AgentBackend { } const maxHistoryTokens = contextBudget?.maxHistoryEstimatedTokens; + // FALLBACK, not a second authority: step 0's anchored estimate measures the + // whole outgoing payload against the real window, where this gate only + // weighs prior history events at chars/4. It stands in only where that + // estimate cannot reach — no anchor, no mid-turn seam, or no declared + // window — so an oversized history never goes out unshaped. const needsCompaction = + !( + midTurnState?.capacity !== undefined && midTurnState.lastRequestInputTokens !== undefined + ) && maxHistoryTokens !== undefined && estimateRuntimeEventsTokens(runtimeContext, contextBudget?.charsPerToken) > maxHistoryTokens; if ( diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index c90967dbc9..2d2a03fe65 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -850,7 +850,7 @@ export class AiSdkCompaction { // of guessing the whole payload at char/4. const persisted = persistedRequestAnchor( input.runtimeContext ?? [], - input.runtimeContextRunHeaders ?? [], + state.priorRunHeaders, this.input.modelId, this.targetConnectionId, ); @@ -880,8 +880,6 @@ export class AiSdkCompaction { state: MidTurnCapacityCompactState | undefined, queue: AsyncEventQueue, providerTools: readonly MakaTool[], - fallbackActiveTools: () => readonly string[], - systemPromptChars: number, onDiagnosticPatch: (patch: Partial) => void, origin: ProviderRequestOrigin, memoryCompactionDecision?: () => AutomaticMemoryCompactionDecision, @@ -978,12 +976,15 @@ export class AiSdkCompaction { // over-trigger; that is the recoverable direction, and the verdict owner // re-measures the post-shaping payload. const measuredMessages = projectedMessages ?? incomingMessages; - const activeToolsForStep = options.activeTools ?? fallbackActiveTools(); + // Price the request dispatch will actually build, not the pre-dispatch + // inputs: a finalization step adds prompt fragments and sends no tools. + const dispatch = options.resolveDispatch(options.activeTools); + const activeToolsForStep = dispatch.activeTools; const payloadChars = midTurnRequestPayloadChars( measuredMessages, providerTools, activeToolsForStep, - systemPromptChars, + dispatch.systemPromptChars, charsPerToken, ); const forcedEstimate = state.forcedTriggerEstimate; @@ -1001,13 +1002,7 @@ export class AiSdkCompaction { ) { return keepProjection(); } - const estimate = - forcedEstimate ?? - estimateNextRequestTokens({ - ...anchored, - charsPerToken, - coldStartChars: payloadChars, - }); + const estimate = forcedEstimate ?? estimateNextRequestTokens({ ...anchored, charsPerToken }); if ( forcedEstimate === undefined && (state.capacity === undefined || !exceedsHighWater(estimate, state.capacity, reserveTokens)) @@ -1029,7 +1024,7 @@ export class AiSdkCompaction { referencePayloadChars: payloadChars, providerTools, activeToolsForStep, - systemPromptChars, + systemPromptChars: dispatch.systemPromptChars, memoryCompactionDecision, onMemoryCompaction, abortSignal, @@ -1181,7 +1176,7 @@ export class AiSdkCompaction { phase: input.phase ?? 'mid_turn', orderedEvents, headAnchor: { runtimeEventId: state.headAnchor.id, turnId }, - reserveTailEvents: midTurn.reserveTailEvents ?? 1, + reserveTailEvents: 1, charsPerToken, now: this.now(), ...(compactPolicy.highWaterName !== undefined @@ -1480,19 +1475,9 @@ export class AiSdkCompaction { reentry: RequestProjectionStage; state: MidTurnCapacityCompactState; providerTools: readonly MakaTool[]; - fallbackActiveTools: () => readonly string[]; charsPerToken: number; - systemPromptChars: number; }): RequestProjectionStage { - const { - shaped, - reentry, - state, - providerTools, - fallbackActiveTools, - charsPerToken, - systemPromptChars, - } = input; + const { shaped, reentry, state, providerTools, charsPerToken } = input; return async (options) => { let result = await Promise.resolve(shaped(options)); const omissionProjection = projectHistoricalImageOmissions( @@ -1502,14 +1487,18 @@ export class AiSdkCompaction { if (omissionProjection) { result = { ...(result ?? {}), messages: omissionProjection }; } - const finalPayloadChars = (): number => - midTurnRequestPayloadChars( + const finalPayloadChars = (): number => { + // Measure the dispatched shape, so the payload recorded as the anchor's + // pair describes the same request the provider counts. + const dispatch = options.resolveDispatch(result?.activeTools ?? options.activeTools); + return midTurnRequestPayloadChars( result?.messages ?? options.messages, providerTools, - result?.activeTools ?? options.activeTools ?? fallbackActiveTools(), - systemPromptChars, + dispatch.activeTools, + dispatch.systemPromptChars, charsPerToken, ); + }; let payloadChars = finalPayloadChars(); // Same rule as the trigger: the turn's first request is measured only // when a previous turn left a usable anchor to measure it against. @@ -1520,7 +1509,6 @@ export class AiSdkCompaction { estimateNextRequestTokens({ ...requestEstimateAnchor(state, payloadChars), charsPerToken, - coldStartChars: payloadChars, }); const estimate = estimateFinal(); const capacityAttemptedThisStep = @@ -1817,12 +1805,8 @@ function persistedRequestAnchor( * The estimate inputs for the request about to go out: the paired anchor and * the signed char delta against the payload that anchor was reported for. * - * A delta wider than the whole payload is the pairing's own alarm — it takes a - * baseline more than twice the current payload, which within a send cannot - * happen without a restructuring that already resets the baseline, and across a - * turn boundary means the prior tail was re-materialized down a different path - * than the request the anchor was reported for. A pairing that far off - * estimates worse than none, so drop it and let the cold start answer. + * A delta wider than the whole payload means the pairing has already failed, so + * drop it and cold start: the whole payload against a zero anchor. */ function requestEstimateAnchor( state: MidTurnCapacityCompactState, @@ -1830,9 +1814,9 @@ function requestEstimateAnchor( ): { priorUsageTokens?: number; appendedChars: number } { const anchor = state.lastRequestInputTokens; const baseline = state.lastRequestPayloadChars; - if (anchor === undefined || baseline === undefined) return { appendedChars: 0 }; + if (anchor === undefined || baseline === undefined) return { appendedChars: payloadChars }; const appendedChars = payloadChars - baseline; - if (Math.abs(appendedChars) > payloadChars) return { appendedChars: 0 }; + if (Math.abs(appendedChars) > payloadChars) return { appendedChars: payloadChars }; return { priorUsageTokens: anchor, appendedChars }; } diff --git a/packages/runtime/src/history-compact-error.ts b/packages/runtime/src/history-compact-error.ts index fa0f6fe788..47b8350533 100644 --- a/packages/runtime/src/history-compact-error.ts +++ b/packages/runtime/src/history-compact-error.ts @@ -17,15 +17,18 @@ * under the License. */ -import { - isContextBudgetExhaustedDetail, - type ContextBudgetExhaustedDetail, -} from '@maka/core/events'; +/** + * Ways a summarizer's own output can be unusable. Owned here, in the + * history-compaction domain that produces and repairs them. + */ +const MALFORMED_HISTORY_COMPACT_SUMMARY_REASONS = [ + 'malformed_summary_missing_section', + 'malformed_summary_truncated', + 'malformed_summary_too_small_for_fold', +] as const; -export type MalformedHistoryCompactSummaryReason = Extract< - ContextBudgetExhaustedDetail, - `malformed_summary_${string}` ->; +export type MalformedHistoryCompactSummaryReason = + (typeof MALFORMED_HISTORY_COMPACT_SUMMARY_REASONS)[number]; export type HistoryCompactSummarizerFailureReason = | 'output_length' @@ -37,7 +40,9 @@ export type HistoryCompactSummarizerFailureReason = export function isMalformedHistoryCompactSummaryReason( reason: string, ): reason is MalformedHistoryCompactSummaryReason { - return isContextBudgetExhaustedDetail(reason) && reason.startsWith('malformed_summary_'); + return MALFORMED_HISTORY_COMPACT_SUMMARY_REASONS.includes( + reason as MalformedHistoryCompactSummaryReason, + ); } export class HistoryCompactSummarizerError extends Error { diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 93286b3e30..2c67346f55 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -56,7 +56,8 @@ export interface EstimateNextRequestTokensInput { * input+output, because `appendedChars` is a delta against that request's * payload and already carries the step's freshly generated output. * Undefined on cold start or when the sample is unusable (no positive - * input count), which falls back to a whole-payload char estimate. + * input count); the baseline is then zero and `appendedChars` carries the + * whole payload. */ priorUsageTokens?: number; /** @@ -68,30 +69,22 @@ export interface EstimateNextRequestTokensInput { appendedChars: number; /** Estimate conversion; defaults to 4 chars/token. */ charsPerToken?: number; - /** Whole-payload chars, used only when `priorUsageTokens` is undefined. */ - coldStartChars?: number; } /** - * Estimate the token size of the next provider request. Anchors on the last - * step's real usage plus a signed char/4 payload delta for content the provider - * has not yet counted (or no longer carries); cold-start (no usage) is a pure - * char/4 estimate of the whole payload. This mirrors how surveyed peers avoid - * pure character guessing. + * Estimate the token size of the next provider request: the last request's real + * usage plus a signed char/4 payload delta for content the provider has not yet + * counted (or no longer carries). Without a usable usage sample the caller + * passes the whole payload as the delta against a zero baseline, so this stays + * one formula. This mirrors how surveyed peers avoid pure character guessing. */ export function estimateNextRequestTokens(input: EstimateNextRequestTokensInput): number { const charsPerToken = Math.max(1, input.charsPerToken ?? 4); - if (input.priorUsageTokens !== undefined && Number.isFinite(input.priorUsageTokens)) { - return Math.max( - 0, - Math.max(0, Math.floor(input.priorUsageTokens)) + - estimateSignedChars(input.appendedChars, charsPerToken), - ); - } - return Math.max( - 0, - estimateSignedChars(input.coldStartChars ?? input.appendedChars, charsPerToken), - ); + const prior = + input.priorUsageTokens !== undefined && Number.isFinite(input.priorUsageTokens) + ? Math.max(0, Math.floor(input.priorUsageTokens)) + : 0; + return Math.max(0, prior + estimateSignedChars(input.appendedChars, charsPerToken)); } /** Proactive threshold: the next request would cross `contextWindow - reserve`. */ @@ -104,11 +97,6 @@ export function exceedsHighWater( return estimatedTokens > highWater; } -/** Hard cap: the estimate exceeds the raw context window even before the reserve. */ -export function exceedsContextWindow(estimatedTokens: number, contextWindow: number): boolean { - return estimatedTokens > contextWindow; -} - export interface SafePrefixOptions { /** Keep at least this many trailing events uncovered as the verbatim tail. */ reserveTailEvents?: number; @@ -423,7 +411,7 @@ export interface HistoryCompactionPolicy { enabled: boolean; checkpoint?: HistoryCompactCheckpoint; highWaterName?: string; - midTurn?: { enabled: true; reserveTokens?: number; reserveTailEvents?: number }; + midTurn?: { enabled: true; reserveTokens?: number }; } export interface HistoryCompactionReplayOptions { diff --git a/packages/runtime/src/request-projection.ts b/packages/runtime/src/request-projection.ts index f19ef99fcd..a9a76323a2 100644 --- a/packages/runtime/src/request-projection.ts +++ b/packages/runtime/src/request-projection.ts @@ -24,12 +24,26 @@ export interface CompletedProviderStep { usage?: NormalizedUsage; } +/** The system prompt and active tool subset one request actually dispatches. */ +export interface DispatchRequestShape { + systemPromptChars: number; + activeTools: string[]; +} + export interface RequestProjectionContext { completedSteps: readonly CompletedProviderStep[]; stepNumber: number; model: unknown; messages: ModelMessage[]; activeTools?: readonly string[]; + /** + * Resolve what this step will really send, from a stage's projected active + * tool set. Dispatch appends step-specific system prompt fragments and can + * clear the tool set entirely on a finalization step, so a stage that + * MEASURES the request must price this shape, not the pre-dispatch inputs — + * otherwise a payload measure gets paired with a different request's tokens. + */ + resolveDispatch: (activeTools: readonly string[] | undefined) => DispatchRequestShape; } export interface RequestProjection { From eacbcce3fe11c87a4ca0a0bd2f17de759d2e6606 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 05:15:53 +0800 Subject: [PATCH 8/9] refactor(core): retire context_budget_exhausted at the decode boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing has produced this outcome since the runtime stopped issuing local termination verdicts: whether a request fits is the provider's answer, and a rejection is recovered from by compacting and retrying. What remained was a read-only chain nine files long — a `CompleteEvent.stopReason` member no backend can emit, a six-value detail enum with no writer, its predicate, the mapper's stateDelta pass-through, the Host protocol allowlist and decoder, the canonical snapshot field, the session projector's `details`, and two desktop presentation branches with their locale copy. Old sessions still carry the name, so the durable ledger's own read boundary folds it to `context_overflow` — the outcome every downstream consumer already treated it as. That is the only place that now knows two names for it. The summarizer's malformed-summary taxonomy, which derived its type from the retired enum, was already moved into the history-compaction domain. The host fixture's provider stub now reports input tokens that grow with the request. Its flat 11 made the anchored turn-start estimate meaningless, which is exactly the number that test's compaction assertions depend on. --- .../session-error-presentation.test.ts | 17 ------ .../src/renderer/locales/conversation-copy.ts | 6 +-- .../src/renderer/model-connection-errors.ts | 7 --- .../renderer/session-error-presentation.ts | 2 - .../runtime-host-run-command.test.ts | 9 ---- packages/core/src/events.ts | 29 +---------- .../canonical-session-projection.test.ts | 31 ++++++----- .../execution-model-composition.test.ts | 52 +++++++++++++++---- .../src/__tests__/protocol.test.ts | 24 +-------- .../__tests__/root-turn-coordinator.test.ts | 10 ++-- .../src/__tests__/session-projector.test.ts | 8 ++- .../src/adapter/session-projector.ts | 3 -- packages/runtime-host/src/protocol/index.ts | 5 +- packages/runtime-host/src/protocol/turn.ts | 17 +----- .../agent-graph-execution-coordinator.ts | 5 +- .../src/server/canonical-turn-snapshot.ts | 19 +------ .../overflow-reactive-recovery.test.ts | 2 +- .../runtime-event-read-model.test.ts | 42 +++++++++++++++ .../session-event-runtime-mapper.test.ts | 19 ------- packages/runtime/src/ai-sdk-compaction.ts | 4 +- .../src/history-compact-summary-validation.ts | 7 +-- .../runtime/src/runtime-event-read-model.ts | 12 +++-- .../src/session-event-runtime-mapper.ts | 6 --- 23 files changed, 134 insertions(+), 202 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts index 19423fa36e..750d1d3cda 100644 --- a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -37,20 +37,3 @@ describe('provider capacity presentation', () => { }); }); -describe('context compaction failure presentation', () => { - it('shows actionable malformed-summary guidance', () => { - const message = sessionEventErrorMessage({ - type: 'error', - id: 'error-1', - turnId: 'turn-1', - ts: 1, - recoverable: false, - reason: 'context_budget_exhausted', - message: 'Turn failed: context_budget_exhausted', - details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' }, - }); - - assert.match(message, /上下文压缩/); - assert.match(message, /上下文窗口设置|切换模型|开启新任务/); - }); -}); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index a41bc754a7..01f292fd0d 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -342,8 +342,6 @@ export interface DesktopConversationCopy { turnError: { unknown: string; contextOverflow: string; - contextBudgetExhausted: string; - malformedSummary: string; timeout: string; auth: string; providerBilling: string; @@ -658,7 +656,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', contextBudgetExhausted: '上下文已达上限,这个任务无法继续。换模型或开启新任务。', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } }, + turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing earlier messages', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -894,7 +892,7 @@ const COPY = { reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' }, testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, - turnError: { unknown: 'Something went wrong, cause unknown. Send a message to retry.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', contextBudgetExhausted: 'The context limit was reached and this task cannot continue. Switch models or start a new task.', malformedSummary: 'Context compaction could not produce a valid summary. Check the model context-window setting, switch models, or start a new task.', timeout: 'The model request timed out. Send a message to retry.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity. Wait a few minutes, or switch models.', rateLimit: 'Requests were rate-limited. Wait a moment, then send a message to retry.', network: 'The network connection failed. Check the network, then send a message again.', provider: 'The model service returned an error. Retry later, or switch models.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.', partialOutput: 'This turn produced part of an answer. Worth reading before you send another message.' } }, + turnError: { unknown: 'Something went wrong, cause unknown. Send a message to retry.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out. Send a message to retry.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity. Wait a few minutes, or switch models.', rateLimit: 'Requests were rate-limited. Wait a moment, then send a message to retry.', network: 'The network connection failed. Check the network, then send a message again.', provider: 'The model service returned an error. Retry later, or switch models.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.', partialOutput: 'This turn produced part of an answer. Worth reading before you send another message.' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index fe6785ba72..fe3fdf5568 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -60,13 +60,6 @@ export function sessionEventErrorMessage( if (isNoRealConnectionEvent(event)) { return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale); } - const contextBudgetDetail = - event.details && !Array.isArray(event.details) - ? event.details.contextBudgetExhaustedDetail - : undefined; - if (typeof contextBudgetDetail === 'string' && contextBudgetDetail.startsWith('malformed_summary_')) { - return getDesktopConversationCopy(locale).turnError.malformedSummary; - } const reasonDescription = describeSessionErrorReason(event.reason, locale); if (reasonDescription) return reasonDescription; const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index 5e68e9791c..f3bccf1e7b 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -30,8 +30,6 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U switch (reason?.toLowerCase()) { case 'context_overflow': return copy.contextOverflow; - case 'context_budget_exhausted': - return copy.contextBudgetExhausted; case 'timeout': return copy.timeout; case 'model_after_tool_timeout': diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index f776973dbf..e275c2fea9 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -500,15 +500,6 @@ describe('Runtime Host maka run adapter', () => { assert.equal(durable.failure?.class, 'tool_step_cap_reached'); }); - test('classifies a standalone context-budget completion as failed', async () => { - const outcome = await observeFixtureOutcome({ - turnEvents: completionEvents('turn-1', 'context_budget_exhausted'), - }); - - assert.equal(outcome.status, 'failed'); - assert.equal(outcome.failure?.class, 'context_budget_exhausted'); - }); - test('uses the latest durable terminal state for a Graph Turn', async () => { const outcome = await observeFixtureOutcome({ graph: true, diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 5e2c3bb740..52af0d0727 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1247,14 +1247,7 @@ export interface CompleteEvent extends BaseEvent { | 'graph_yield' | 'permission_handoff' | 'step_limit' - | 'max_tokens' - | 'context_budget_exhausted'; - /** - * Detail for `stopReason: 'context_budget_exhausted'` — the runtime could not - * produce a provider-safe request even after mid-turn compaction. A first-class - * outcome, not a provider context-length error. - */ - contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail; + | 'max_tokens'; /** Durable result of an explicit context-compaction execution. */ contextCompactionOutcome?: ContextCompactionOutcome; } @@ -1264,32 +1257,14 @@ export type ContextCompactionOutcome = | { kind: 'unchanged'; reason: string } | { kind: 'failed'; reason: string }; -export const CONTEXT_BUDGET_EXHAUSTED_DETAILS = [ - 'no_safe_completed_span', - 'summarizer_failed', - 'malformed_summary_missing_section', - 'malformed_summary_truncated', - 'malformed_summary_too_small_for_fold', - 'head_anchor_exceeds_capacity', -] as const; - -export type ContextBudgetExhaustedDetail = (typeof CONTEXT_BUDGET_EXHAUSTED_DETAILS)[number]; - -export function isContextBudgetExhaustedDetail( - value: unknown, -): value is ContextBudgetExhaustedDetail { - return CONTEXT_BUDGET_EXHAUSTED_DETAILS.includes(value as ContextBudgetExhaustedDetail); -} - export type CompleteStopReason = CompleteEvent['stopReason']; /** Stable failure taxonomy for complete events that did not finish the turn. */ export function failureClassFromCompleteStopReason( reason: CompleteStopReason, -): 'runtime_error' | 'tool_step_cap_reached' | 'context_budget_exhausted' | undefined { +): 'runtime_error' | 'tool_step_cap_reached' | undefined { if (reason === 'error') return 'runtime_error'; if (reason === 'step_limit') return 'tool_step_cap_reached'; - if (reason === 'context_budget_exhausted') return 'context_budget_exhausted'; return undefined; } diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 7482e9c50b..06eff1cae3 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -335,12 +335,6 @@ test('preflights the worst-case failed Turn before accepting more queued content ); const worstCaseFailedTurn = worstCaseFailedTurnSnapshot(capacityCanonical.rootTurn!); assert.equal(worstCaseFailedTurn.status, 'failed'); - if (worstCaseFailedTurn.status === 'failed') { - assert.equal( - worstCaseFailedTurn.contextBudgetExhaustedDetail, - 'malformed_summary_too_small_for_fold', - ); - } assert.throws(() => createSessionContinuitySnapshot( { @@ -429,7 +423,7 @@ test('projects a failed Turn message from the canonical terminal event', async ( }); }); -test('projects context-budget exhaustion detail from the canonical terminal event', async () => { +test('a legacy context_budget_exhausted terminal event still projects, as a context overflow', async () => { await withStores(async (root, stores) => { const { sessionId, rootAdmissions } = await createRunningRoot(root, stores); const context = { @@ -450,18 +444,30 @@ test('projects context-budget exhaustion detail from the canonical terminal even newId: () => 'unused', now: () => 12, } as const; - const terminalEvent = mapSessionEventToRuntimeEvent( + // Exactly what a session written before this outcome was retired holds: the + // runtime can no longer emit it, so the durable shape is rebuilt here. + const mapped = mapSessionEventToRuntimeEvent( { type: 'complete', id: 'terminal-context-budget-1', turnId: 'turn-1', ts: 13, - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'malformed_summary_missing_section', + stopReason: 'error', }, context, createSessionEventMapMemory(), ); + const terminalEvent = { + ...mapped, + actions: { + ...mapped.actions, + stateDelta: { + stopReason: 'context_budget_exhausted', + failureClass: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'malformed_summary_missing_section', + }, + }, + }; await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent); await stores.agentRunStore.updateRun(sessionId, 'run-1', { status: 'failed', @@ -480,10 +486,7 @@ test('projects context-budget exhaustion detail from the canonical terminal even const canonical = await reader.read(sessionId); assert.equal(canonical?.rootTurn?.status, 'failed'); if (canonical?.rootTurn?.status === 'failed') { - assert.equal( - canonical.rootTurn.contextBudgetExhaustedDetail, - 'malformed_summary_missing_section', - ); + assert.equal(canonical.rootTurn.failureClass, 'context_overflow'); } }); }); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index f3ce620d98..782695d39b 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1716,6 +1716,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide const root = join(base, 'interactive'); const home = join(base, 'home'); const provider = await startProvider(); + // This turn's compaction trigger is anchored on the input tokens the provider + // reports, so the stub must report a number that grows with the request. + provider.configurePayloadProportionalUsage(); const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); assert.ok(owner); @@ -1974,7 +1977,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide ); assert.equal(usage.providerId, 'moonshot'); assert.equal(usage.modelId, MODEL_ID); - assert.equal(usage.inputTokens, 11); + // The stub reports input tokens proportional to the request, so this only + // asserts the reported number reached the meter, not a fixed constant. + assert.equal(usage.inputTokens > 11, true); assert.equal(usage.outputTokens, 5); assert.equal(usage.status, 'success'); @@ -2023,7 +2028,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide } assert.equal(summaryCaptureFound, true); - const requestsBeforeArtifactFailure = provider.requests.length; + const streamRequestsBeforeArtifactFailure = provider.requests.filter( + (request) => request.body.stream === true, + ).length; artifacts.close(); const failedTurnId = randomUUID(); const failedStart = await startTurn( @@ -2041,7 +2048,14 @@ test('production Host executes a canonical ai-sdk Session against a real provide connectionContext, ); assert.equal(failedTerminal.status, 'completed'); - assert.equal(provider.requests.length, requestsBeforeArtifactFailure + 1); + // A closed artifact store must not stop the turn from reaching the model. + // Counted on the streamed turn requests alone: whether this turn also + // spends an auxiliary compaction or memory call is the context budget's + // business, not this assertion's. + assert.equal( + provider.requests.filter((request) => request.body.stream === true).length, + streamRequestsBeforeArtifactFailure + 1, + ); assert.equal(drainRequests, 0); } finally { try { @@ -4351,14 +4365,22 @@ async function startProvider(): Promise<{ configureChildAgentFlow(): void; configureImplementationChildAgentFlow(): void; configureAgentGraphFlow(): void; + configurePayloadProportionalUsage(): void; close(): Promise; }> { const requests: ProviderRequest[] = []; let flow: ProviderFlow = { kind: 'default' }; + // A real provider's reported input tokens grow with the request. The default + // constant is fine for tests that only read the number back; a test whose + // subject is the context-budget estimate needs usage that tracks the payload, + // because that estimate is anchored on exactly this number. + let usageTracksPayload = false; const server = createServer((request, response) => { - void handleProviderRequest(request, response, requests, flow).catch((error) => { - response.destroy(error as Error); - }); + void handleProviderRequest(request, response, requests, flow, usageTracksPayload).catch( + (error) => { + response.destroy(error as Error); + }, + ); }); await listen(server); const address = server.address(); @@ -4396,6 +4418,9 @@ async function startProvider(): Promise<{ scenario: new AgentGraphProviderScenario(CHILD_AGENT_RESULT_TEXT), }; }, + configurePayloadProportionalUsage: () => { + usageTracksPayload = true; + }, close: () => closeServer(server), }; } @@ -4405,6 +4430,7 @@ async function handleProviderRequest( response: ServerResponse, requests: ProviderRequest[], flow: ProviderFlow, + usageTracksPayload = false, ): Promise { assert.equal(request.method, 'POST'); const body = JSON.parse(await readBody(request)) as Record; @@ -4644,7 +4670,11 @@ async function handleProviderRequest( }); return; } - respondProviderText(response, RESPONSE_TEXT); + respondProviderText( + response, + RESPONSE_TEXT, + usageTracksPayload ? Math.max(11, Math.ceil(JSON.stringify(body).length / 4)) : 11, + ); } function respondProviderResponsesText(response: ServerResponse, text: string): void { @@ -4708,7 +4738,7 @@ function respondProviderResponsesText(response: ServerResponse, text: string): v response.end(`${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\n`); } -function respondProviderText(response: ServerResponse, text: string): void { +function respondProviderText(response: ServerResponse, text: string, promptTokens = 11): void { response.writeHead(200, { 'content-type': 'text/event-stream' }); response.write( `data: ${JSON.stringify({ @@ -4732,7 +4762,11 @@ function respondProviderText(response: ServerResponse, text: string): void { created: 1, model: MODEL_ID, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], - usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 }, + usage: { + prompt_tokens: promptTokens, + completion_tokens: 5, + total_tokens: promptTokens + 5, + }, })}\n\n`, ); response.end('data: [DONE]\n\n'); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 1989f4b504..716d8921e0 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -21,7 +21,7 @@ import { RuntimeHostProtocolError } from '../protocol/errors.js'; import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; -import { CONTEXT_BUDGET_EXHAUSTED_DETAILS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events'; +import { TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events'; import { CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS } from '@maka/core/runtime-policy'; import { decodeClientCapabilityReplaceInput, @@ -2238,28 +2238,6 @@ describe('Runtime Host bootstrap protocol', () => { }; assert.deepEqual(decodeHostFrame(response), response); - for (const contextBudgetExhaustedDetail of CONTEXT_BUDGET_EXHAUSTED_DETAILS) { - const withContextDetail = { - ...response, - result: { - ...response.result, - failureClass: 'context_budget_exhausted', - contextBudgetExhaustedDetail, - }, - }; - assert.deepEqual(decodeHostFrame(withContextDetail), withContextDetail); - } - assert.throws( - () => - decodeHostFrame({ - ...response, - result: { - ...response.result, - contextBudgetExhaustedDetail: 'unknown_detail', - }, - }), - isInvalidFrame, - ); assert.throws( () => decodeHostFrame({ diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5abef48433..8ba15639e0 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2068,10 +2068,7 @@ test('Agent Graph supervisor wake preserves structured context-overflow outcomes }); const graphId = agentGraphIdForRootSession(fixture.sessionId); try { - for (const [index, text] of [ - 'provider context overflow', - 'local context budget exhausted', - ].entries()) { + for (const [index, text] of ['provider context overflow'].entries()) { const turnId = `turn-graph-context-${index}`; const outcome = await graphExecutions(fixture).run( fixture.sessionId, @@ -2093,7 +2090,7 @@ test('Agent Graph supervisor wake preserves structured context-overflow outcomes assert.deepEqual(outcome, { kind: 'context_overflow', turnId, - reason: index === 0 ? 'context_overflow' : 'context_budget_exhausted', + reason: 'context_overflow', }); } assert.equal(fixture.drainRequested(), false); @@ -5751,8 +5748,7 @@ class ContextFailureBackend implements AgentBackend { id: randomUUID(), turnId: input.turnId, ts: Date.now(), - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', + stopReason: 'error', }; } diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e0087c4efd..b6e34f1d90 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -333,8 +333,7 @@ test('projects structured context-budget failure detail to the Desktop event', ( runId: 'run-1', status: 'failed', terminalEventId: 'terminal-1', - failureClass: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'malformed_summary_missing_section', + failureClass: 'context_overflow', }, }), }).events; @@ -346,9 +345,8 @@ test('projects structured context-budget failure detail to the Desktop event', ( turnId: 'turn-1', ts: 10, recoverable: false, - reason: 'context_budget_exhausted', - message: 'Turn failed: context_budget_exhausted', - details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' }, + reason: 'context_overflow', + message: 'Turn failed: context_overflow', }, ]); }); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 12478ef636..7924fa38ad 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -483,9 +483,6 @@ export class RuntimeHostSessionProjector { recoverable: false, reason: root.failureClass, message: root.failureMessage ?? `Turn failed: ${root.failureClass}`, - ...(root.contextBudgetExhaustedDetail - ? { details: { contextBudgetExhaustedDetail: root.contextBudgetExhaustedDetail } } - : {}), }); } else { events.push({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index bc5f27e602..83883daec7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 93 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; +// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the +// retired outcome reads as context_overflow at the ledger boundary, and an older +// Host still sending the field fails a newer client's closed snapshot decode. // 93: Configuration credential transfer binds proxy destinations and // Connection credentials to exact Host-owned targets before secret access. // Proxy policy and credentials commit through one recoverable Host command; diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 9c4f718031..dafa428649 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -20,10 +20,8 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { decodeMessageContent as decodeCanonicalMessageContent, - isContextBudgetExhaustedDetail, DIRECTORY_REFERENCE_MAX_COUNT, isCanonicalAttachmentRef, - type ContextBudgetExhaustedDetail, type ContextCompactionOutcome, type MessageContent, type ProviderRetryReason, @@ -204,7 +202,6 @@ export type TurnSnapshot = terminalEventId: string; failureClass: string; failureMessage?: string; - contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail; }) | (TurnSnapshotBase & { status: 'cancelled'; @@ -689,7 +686,7 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'failed Turn snapshot', ['sessionId', 'turnId', 'runId', 'status', 'terminalEventId', 'failureClass'], - ['failureMessage', 'contextBudgetExhaustedDetail'], + ['failureMessage'], ); return { ...base, @@ -706,13 +703,6 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { ), } : {}), - ...(record.contextBudgetExhaustedDetail !== undefined - ? { - contextBudgetExhaustedDetail: requireContextBudgetExhaustedDetail( - record.contextBudgetExhaustedDetail, - ), - } - : {}), }; } if (status === 'cancelled') { @@ -746,11 +736,6 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { }; } -function requireContextBudgetExhaustedDetail(value: unknown): ContextBudgetExhaustedDetail { - if (isContextBudgetExhaustedDetail(value)) return value; - throw invalidProtocolFrame('Invalid context budget exhausted detail'); -} - export function decodeContextCompactionOutcome(value: unknown): ContextCompactionOutcome { const record = requireRecord(value, 'Context compaction outcome'); const kind = requireString(record.kind, 'kind', 32); diff --git a/packages/runtime-host/src/server/agent-graph-execution-coordinator.ts b/packages/runtime-host/src/server/agent-graph-execution-coordinator.ts index 48be08cb7c..a1449be86e 100644 --- a/packages/runtime-host/src/server/agent-graph-execution-coordinator.ts +++ b/packages/runtime-host/src/server/agent-graph-execution-coordinator.ts @@ -219,10 +219,7 @@ function classifyAgentGraphOutcome( if (snapshot.status === 'completed') return { kind: 'completed', turnId: snapshot.turnId }; if (snapshot.status === 'cancelled') return { kind: 'aborted', turnId: snapshot.turnId }; if (snapshot.status === 'failed') { - if ( - snapshot.failureClass === 'context_overflow' || - snapshot.failureClass === 'context_budget_exhausted' - ) { + if (snapshot.failureClass === 'context_overflow') { return { kind: 'context_overflow', turnId: snapshot.turnId, diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index de00dfbace..bdf7a3ae17 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -18,11 +18,7 @@ */ import type { AgentRunHeader } from '@maka/core/agent-run'; -import { - isContextBudgetExhaustedDetail, - type ContextBudgetExhaustedDetail, - type ContextCompactionOutcome, -} from '@maka/core/events'; +import { type ContextCompactionOutcome } from '@maka/core/events'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import { classifyTerminalRuntimeLedger } from '@maka/runtime/terminal-run-commit'; @@ -82,9 +78,6 @@ export async function readCanonicalTurnSnapshot( '…', ) : undefined; - const contextBudgetExhaustedDetail = readContextBudgetExhaustedDetail( - fact.terminalEvent.actions?.stateDelta?.contextBudgetExhaustedDetail, - ); return { sessionId, turnId, @@ -93,7 +86,6 @@ export async function readCanonicalTurnSnapshot( terminalEventId: fact.terminalEvent.id, failureClass: fact.failureClass, ...(failureMessage ? { failureMessage } : {}), - ...(contextBudgetExhaustedDetail ? { contextBudgetExhaustedDetail } : {}), }; } if (!fact.abortSource) throw new Error('Cancelled terminal fact has no abort source'); @@ -118,12 +110,6 @@ export async function readCanonicalTurnSnapshot( return { sessionId, turnId, runId, status: run.status }; } -function readContextBudgetExhaustedDetail( - value: unknown, -): ContextBudgetExhaustedDetail | undefined { - return isContextBudgetExhaustedDetail(value) ? value : undefined; -} - function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { if (!value || typeof value !== 'object') return undefined; const outcome = value as Record; @@ -147,9 +133,6 @@ export function worstCaseFailedTurnSnapshot(identity: CanonicalTurnIdentity): Tu terminalEventId: 'x'.repeat(128), failureClass: '\0'.repeat(128), failureMessage: '\0'.repeat(TURN_FAILURE_MESSAGE_MAX_BYTES), - // Keep capacity preflight conservative for every protocol-valid failure - // detail, including the longest malformed-summary diagnostic. - contextBudgetExhaustedDetail: 'malformed_summary_too_small_for_fold', }; } diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 573f3d9137..b5066a0b04 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -1691,7 +1691,7 @@ describe('reactive overflow recovery in the streaming backend', () => { // First-request overflow with no prior turns: the pool is just the current // user message, so there is no safe completed span to fold. Recovery is not // possible, so the provider error is surfaced honestly (not a fake success, - // and not a synthesized context_budget_exhausted — the provider rejected). + // and not a locally synthesized verdict — the provider rejected). const fixture = buildReactiveFixture({ script: ['overflow'], withoutPriorTurns: true }); await runTurn(fixture); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 081ee6edc3..73eb22437a 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1561,6 +1561,48 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.deepStrictEqual(out.diagnostics, []); }); + test('a session written with the retired context_budget_exhausted reads back as context_overflow', () => { + // The runtime no longer decides locally that a request cannot be made to + // fit, so that outcome is gone from the live contract. Sessions persisted + // before still carry it, and must still decode — as the one name that + // survives. + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-budget-exhausted', + ts: ts + 9, + status: 'failed', + actions: { + endInvocation: true, + stateDelta: { + stopReason: 'context_budget_exhausted', + failureClass: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', + }, + }, + }), + ], + { + runHeaders: [{ ...header, status: 'failed', failureClass: 'context_budget_exhausted' }], + }, + ); + + assert.deepStrictEqual( + out.messages.find((message) => message.type === 'turn_state'), + { + type: 'turn_state', + id: 'evt-budget-exhausted', + turnId, + ts: ts + 9, + status: 'failed', + parentTurnId: 'parent-turn', + errorClass: 'context_overflow', + partialOutputRetained: false, + }, + ); + assert.deepStrictEqual(out.diagnostics, []); + }); + test('tool step cap terminal fact projects a persistent system notice', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index d8e4c985fe..99d8ebd938 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -166,25 +166,6 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { }); }); - test('context_budget_exhausted keeps its detail in the durable terminal state', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ - type: 'complete', - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }), - ctx, - createSessionEventMapMemory(), - ); - - assert.equal(mapped.status, 'failed'); - assert.deepEqual(mapped.actions?.stateDelta, { - stopReason: 'context_budget_exhausted', - failureClass: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }); - }); - test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { const mem = createSessionEventMapMemory(); const a = mapSessionEventToRuntimeEvent( diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 2d2a03fe65..3138c9b6b9 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -1331,8 +1331,8 @@ export class AiSdkCompaction { * latch (pi's `_overflowRecoveryAttempted`). Returns the compacted messages * to resend, or undefined when recovery is impossible or already spent, in * which case the caller surfaces the real provider error rather than a - * fabricated success or a synthesized `context_budget_exhausted` (the - * provider — not the runtime — rejected the request). Non-context-length + * fabricated success or a locally synthesized verdict (the provider — not the + * runtime — rejected the request). Non-context-length * errors and turns without the mid-turn seam never reach compaction, so the * default (no seam) behavior is already better than the old fake end_turn. */ diff --git a/packages/runtime/src/history-compact-summary-validation.ts b/packages/runtime/src/history-compact-summary-validation.ts index eec8172a5a..99a67442f9 100644 --- a/packages/runtime/src/history-compact-summary-validation.ts +++ b/packages/runtime/src/history-compact-summary-validation.ts @@ -18,7 +18,7 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { ContextBudgetExhaustedDetail } from '@maka/core/events'; +import type { MalformedHistoryCompactSummaryReason } from './history-compact-error.js'; import { estimateTokens } from './context-budget-helpers.js'; import { estimateRuntimeEventsTokens } from './model-history.js'; @@ -36,10 +36,7 @@ import { estimateRuntimeEventsTokens } from './model-history.js'; export const SECTIONED_SUMMARY_FORMAT = 'sections_v1' as const; export type SectionedSummaryFormat = typeof SECTIONED_SUMMARY_FORMAT; -export type CheckpointSummaryDefect = Extract< - ContextBudgetExhaustedDetail, - `malformed_summary_${string}` ->; +export type CheckpointSummaryDefect = MalformedHistoryCompactSummaryReason; // The exact format the prompt mandates. REQUIRED_SUMMARY_SECTIONS is the // ordered subsequence of its headings a completion must carry to be allowed diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 3243fe079d..62ab789737 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1275,15 +1275,21 @@ function failureClassFromRuntimeEvent( event: RuntimeEvent, header: AgentRunHeader, ): string | undefined { - return ( + const failureClass = stringStateDelta(event, 'failureClass') ?? stringStateDelta(event, 'errorClass') ?? stringStateDelta(event, 'reason') ?? stringStateDelta(event, 'code') ?? (event.content?.kind === 'error' ? nonEmptyString(event.content.reason) : undefined) ?? (event.content?.kind === 'error' ? nonEmptyString(event.content.code) : undefined) ?? - header.failureClass - ); + header.failureClass; + // Retired outcome. The runtime no longer decides locally that a request + // cannot be shaped to fit — the provider rejects it and recovery compacts and + // retries — so a turn that ends over the window is a context overflow like any + // other. Sessions written before that still carry the old name; fold it here, + // at the one place the durable ledger is read, so nothing downstream has to + // know two names for one outcome. + return failureClass === 'context_budget_exhausted' ? 'context_overflow' : failureClass; } function stringRecordValue(value: unknown, key: string): string | undefined { diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 9ab6e119ae..5c53a3a595 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -645,12 +645,6 @@ function completeRuntimeEvent( stateDelta.failureClass = memory.failureClass ?? failureClassFromCompleteStopReason(stopReason) ?? 'runtime_error'; } - // The context_budget_exhausted outcome carries which invariant made the turn - // unrecoverable; the durable terminal state must not collapse it to a bare - // failure class. - if (event.contextBudgetExhaustedDetail !== undefined) { - stateDelta.contextBudgetExhaustedDetail = event.contextBudgetExhaustedDetail; - } if (event.contextCompactionOutcome !== undefined) { stateDelta.contextCompactionOutcome = event.contextCompactionOutcome; } From 5b339b2ca53d79d425cab63292a6c941de3f6704 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 15:53:02 +0800 Subject: [PATCH 9/9] fix(runtime): archive a media-bearing tool result regardless of its text size Stale-result collection gated every candidate on one comparison: does the priced result exceed maxResultEstimatedTokens? With MATERIALIZED_IMAGE_TOKENS at 2,000 and the default gate at 2,048, whether a single screenshot could be archived came down to whether the reference text around it happened to weigh more than about 48 tokens. The gate exists to spare small text results, so it now decides only those: a result carrying media is always a candidate, because archiving it drops whole images from the request whatever its text weighs. The same comparison in active-tool-result-prune is left alone. That path never sees a type:'content' image result to begin with, which is a separate gap. Also brings two documents back to the behavior on this branch. The compaction draft still described a fabricated 32,000+16,384 capacity and termination via context_budget_exhausted; capacity is now the declared window or nothing, an estimate only asks for compaction, and a request the provider rejects is compacted, retried once, and then reported as context_overflow. And the changelog now carries the downgrade note the token_usage anchor earns. Refs #4458, #4283 --- CHANGELOG.md | 1 + ...-compaction-events-log-projection-draft.md | 6 +-- ...ction-events-log-projection-draft.zh-CN.md | 6 +-- ...model-projection-transition-ledger.test.ts | 46 +++++++++++++++++++ .../src/tool-result-archive-transition.ts | 9 +++- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acffc8fe45..b82a45bf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch, and SessionEvent-to-RuntimeEvent conversion remains a pure mapper. - Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance. +- Let the provider decide whether a request fits, and anchored the estimate that decides when to compact on the last request the provider actually counted. `token_usage` records now persist that anchor under a new `lastRequestAnchor` key. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the unknown key fails the record and, with it, the Session that contains it. Downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Retired with the local verdict: nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 94. - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out. ## 0.1.11 - 2026-08-18 diff --git a/docs/architecture/llm-compaction-events-log-projection-draft.md b/docs/architecture/llm-compaction-events-log-projection-draft.md index 406941ed7f..b1aa79d215 100644 --- a/docs/architecture/llm-compaction-events-log-projection-draft.md +++ b/docs/architecture/llm-compaction-events-log-projection-draft.md @@ -180,7 +180,7 @@ Third, the checkpoint is not itself a canonical RuntimeEvent. Coverage and tail ## Triggering ends before compaction begins -Runtime derives one capacity from the selected model's metadata. For a known context window it reserves one quarter of the window, capped at 16,384 tokens; most providers without a known window use a 32,000-token history budget plus the classic 16,384-token reserve. +Capacity is the selected model's declared context window or nothing at all; Runtime never manufactures one. For a declared window it reserves one quarter of the window, capped at 16,384 tokens, and shapes history against the rest. Where no window is declared there is no capacity to weigh a request against, and the pre-turn gate falls back to the policy's own history-shaping budget — 32,000 tokens for most providers, and none where the provider publishes neither. Either way an estimate only asks for compaction; it never ends a request. Trigger owners use that capacity but do not participate in compaction: @@ -393,7 +393,7 @@ Compaction crosses token estimation, an LLM call, schema construction, durable a | Failure point | Current behavior | What must not happen | |---|---|---| | Below high water | Keep the existing projection or apply ordinary budget selection | Create an unsourced summary as a speculative optimization | -| LLM returns an empty summary | Record no new checkpoint. Automatic pre-turn compaction keeps the original source-derived projection and, if it remains over budget, terminates with `context_budget_exhausted` without writing a failure note; manual compaction records one visible `context_compaction_failed_open` note | Treat an empty projection as covered history | +| LLM returns an empty summary | Record no new checkpoint. Automatic pre-turn compaction keeps the original source-derived projection and dispatches it without writing a failure note, leaving the provider to say whether it fits; manual compaction records one visible `context_compaction_failed_open` note | Treat an empty projection as covered history | | Text summary is malformed | Spend one stricter repair attempt, then fail open with a granular reason; do not redispatch an unchanged failed fingerprint | Persist incomplete structure or loop on the same doomed compaction input | | Codex returns no unique valid compact item | Try one portable text-summary checkpoint, then fail open if that also fails | Persist partial or ambiguous provider state | | Native compaction input cannot fit after bounded Tool Result omission | Do not dispatch the native request; try one bounded text-summary checkpoint | Ask the provider to compact an already over-capacity request | @@ -404,7 +404,7 @@ Compaction crosses token estimation, an LLM call, schema construction, durable a | Bounded projection is damaged | Recover from canonical AgentRun ledgers and repair the projection | Treat the cache as the only source of truth | | User stops manual compaction | Abort the summarizer/write path without poisoning the next Turn | Persist a late result or reuse aborted state | -Fail-open here does not mean “always send the complete raw history.” Once history exceeds the model budget, the full raw prefix may itself be impossible to send. An automatic pre-turn initial V2 summary failure leaves the original source-derived projection untouched; if that projection still exceeds the budget, the backend terminates with `context_budget_exhausted` before the failure-note path. Manual compaction records one visible `context_compaction_failed_open` note for the same failed outcome. A rolling failure may reuse the old checkpoint, but it never expands that checkpoint's coverage claim. +Fail-open here does not mean “always send the complete raw history.” Once history exceeds the model budget, the full raw prefix may itself be impossible to send. An automatic pre-turn initial V2 summary failure leaves the original source-derived projection untouched, and whether that projection still fits is the provider's answer: a rejected request is compacted and retried once, and a second rejection surfaces as a `context_overflow` provider error. Manual compaction records one visible `context_compaction_failed_open` note for the same failed outcome. A rolling failure may reuse the old checkpoint, but it never expands that checkpoint's coverage claim. The correct interpretation is: diff --git a/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md b/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md index e70bf18a39..5239350a6d 100644 --- a/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md +++ b/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md @@ -180,7 +180,7 @@ V2 中模型主要看到 `summary`;V3 中 provider 看到自己的 opaque comp ## Trigger 在 compaction 开始前结束 -Runtime 从所选模型的 metadata 推导唯一 capacity。已知 context window 时,reserve 取 window 的四分之一且上限为 16,384 tokens;无法得到 window 时,多数 provider 使用 32,000-token history budget 加经典的 16,384-token reserve。 +Capacity 要么是所选模型声明的 context window,要么根本不存在,Runtime 不会自己造一个。有声明的 window 时,reserve 取 window 的四分之一且上限为 16,384 tokens,其余部分用来塑形历史;没有声明 window 时就没有可用来衡量请求的 capacity,pre-turn gate 退回到 policy 自己的 history-shaping budget——多数 provider 是 32,000 tokens,两者都不公布的 provider 则没有预算。无论哪种情况,估算都只用来请求 compaction,不会结束请求。 Trigger owner 使用这个 capacity,但不参与 compaction: @@ -393,7 +393,7 @@ Compaction 跨越 token estimation、LLM call、schema construction、durable ap | 失败位置 | 当前行为 | 不允许发生的事 | |---|---|---| | 未超过 high water | 保持原投影或普通预算裁剪 | 为了“提前优化”制造无来源摘要 | -| LLM 返回空 summary | 不记录新 checkpoint。自动 pre-turn compaction 保留原有的 source-derived projection;如果它仍然超出预算,则以 `context_budget_exhausted` 结束且不写入失败 note;手动 compaction 则记录一次可见的 `context_compaction_failed_open` note | 把空 projection 当作 covered history | +| LLM 返回空 summary | 不记录新 checkpoint。自动 pre-turn compaction 保留原有的 source-derived projection 并照常发出,不写入失败 note,放不放得下由 provider 回答;手动 compaction 则记录一次可见的 `context_compaction_failed_open` note | 把空 projection 当作 covered history | | Text summary 格式不合法 | 只进行一次更严格的 repair,之后以细分 reason fail open;同一失败 fingerprint 不再 dispatch | 持久化不完整结构,或在相同 doomed input 上循环 | | Codex 没有返回唯一且合法的 compact item | 尝试一次可移植文本摘要 checkpoint;若仍失败再 fail open | 持久化残缺或有歧义的 provider state | | Native compaction input 在有界省略 Tool Result 后仍无法容纳 | 不发送 native request,尝试一次有界文本摘要 checkpoint | 要求 provider 压缩一条已经超出容量的请求 | @@ -404,7 +404,7 @@ Compaction 跨越 token estimation、LLM call、schema construction、durable ap | Bounded projection 损坏 | 从 canonical AgentRun ledger 恢复并修复 projection | 把缓存当成唯一事实源 | | 用户停止 manual compaction | 中止 summarizer/write 链路,不污染下一 Turn | 让迟到结果写入或复用 abort state | -这里的 fail-open 不是“无论如何发送完整历史”。当历史已经超过模型预算时,完整 raw prefix 本身可能不可发送。自动 pre-turn 的 V2 初次 summary 失败会原样保留 source-derived projection;如果该 projection 仍然超出预算,backend 会在失败 note 路径之前以 `context_budget_exhausted` 结束。手动 compaction 对同一失败结果写入一次可见的 `context_compaction_failed_open` note。Rolling failure 可以复用旧 checkpoint,但绝不会扩大它的 coverage claim。 +这里的 fail-open 不是“无论如何发送完整历史”。当历史已经超过模型预算时,完整 raw prefix 本身可能不可发送。自动 pre-turn 的 V2 初次 summary 失败会原样保留 source-derived projection;该 projection 放不放得下由 provider 决定:请求被拒后压缩并重试一次,再次被拒则以 `context_overflow` 报 provider 错误。手动 compaction 对同一失败结果写入一次可见的 `context_compaction_failed_open` note。Rolling failure 可以复用旧 checkpoint,但绝不会扩大它的 coverage claim。 正确理解是: diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts index c7fb260528..9af813c7ed 100644 --- a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -255,6 +255,52 @@ describe('effective model projection reduction', () => { assert.equal(rawCandidates.length, 1); assert.deepEqual(effectiveCandidates, []); }); + + test('collects a media-bearing result whose reference text is tiny', () => { + const event = toolResultEvent('rt-1', 'turn-1', 'ok', { + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Screenshot', + result: 'ok', + modelProjection: { + version: 1, + kind: 'content', + parts: [ + { kind: 'text', text: 'ok' }, + { + kind: 'artifact', + mediaType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + ], + }, + }, + } as Partial); + + const candidates = collectStaleToolResultArchiveCandidates( + [event, toolResultEvent('rt-2', 'turn-2', { body: 'tail' })], + { enabled: true, maxResultEstimatedTokens: 2048, minRecentTurnsFull: 1 }, + 4, + ); + + assert.deepEqual( + candidates.map((candidate) => candidate.runtimeEventId), + ['rt-1'], + ); + }); + + test('leaves a text result under the size gate alone', () => { + const event = toolResultEvent('rt-1', 'turn-1', { body: 'x'.repeat(4_000) }); + + const candidates = collectStaleToolResultArchiveCandidates( + [event, toolResultEvent('rt-2', 'turn-2', { body: 'tail' })], + { enabled: true, maxResultEstimatedTokens: 2048, minRecentTurnsFull: 1 }, + 4, + ); + + assert.deepEqual(candidates, []); + }); }); describe('durable transition writer', () => { diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index ad5b72106a..50ceb7a06d 100644 --- a/packages/runtime/src/tool-result-archive-transition.ts +++ b/packages/runtime/src/tool-result-archive-transition.ts @@ -293,12 +293,17 @@ export function collectStaleToolResultArchiveCandidates( if (!sourceProjection) continue; const serializedResult = serializedToolResultProjection(sourceProjection); const originalBytes = utf8ByteLength(serializedResult); + const media = projectionArtifactMedia(sourceProjection); // An artifact serializes to a short reference and materializes to real // image bytes, so the string alone would price a screenshot at nothing. const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken) + - projectionArtifactMedia(sourceProjection).length * MATERIALIZED_IMAGE_TOKENS; - if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; + media.length * MATERIALIZED_IMAGE_TOKENS; + // A result that carries media is always a candidate: archiving it drops + // whole images from the request, which is worth doing whatever the + // reference text around them happens to weigh. The size gate is there to + // spare small text results, so it only decides those. + if (media.length === 0 && originalEstimatedTokens <= maxResultEstimatedTokens) continue; candidates.push({ runtimeEventId: event.id, turnId: event.turnId,