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/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/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/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/__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/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/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/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-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__/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__/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__/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__/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__/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 ce4bc494c6..c528f6ae66 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'; @@ -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; @@ -71,6 +72,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,8 +153,24 @@ 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; + /** 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?: @@ -216,7 +234,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 +357,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 @@ -409,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); }, @@ -447,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 ? [ { @@ -479,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 }, @@ -584,6 +622,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { return fixture.ledgerReads; }, priorEvents, + priorRunHeaders: [...(options.priorRunHeaders ?? [])], anchor, ledger, modelCalls, @@ -608,6 +647,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 @@ -817,9 +857,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 +870,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 +883,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 +900,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 +965,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 +1011,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 +1052,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 +1062,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 +1144,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 +1153,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 @@ -1476,6 +1315,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, @@ -1498,7 +1359,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, @@ -1509,12 +1370,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 () => { @@ -1557,7 +1415,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, @@ -1565,12 +1428,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 () => { @@ -1686,11 +1546,165 @@ 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('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); + }); + + /** + * 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, + bigActiveTool: true, + childFinalization: true, + }); + await runFixtureTurn(finalization); + const control = buildFixture({ + contextWindow: 1_000_000, + finalAtSecondCall: true, + bigActiveTool: true, + }); + await runFixtureTurn(control); + + assert.equal( + 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', + ); + 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 () => { + // 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('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, @@ -1699,10 +1713,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, @@ -1710,6 +1722,104 @@ 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. + */ +/** 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) + .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/__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/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index f1c2ebc488..b5066a0b04 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 @@ -1657,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); @@ -1750,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, @@ -1791,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 a58861f151..a68fa28348 100644 --- a/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts +++ b/packages/runtime/src/__tests__/provider-image-overflow-recovery.test.ts @@ -28,6 +28,17 @@ import { omitHistoricalImageToolResults, } 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 +60,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 +108,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 +129,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 +140,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 +170,18 @@ describe('provider image overflow recovery projection', () => { assert.match(prompt(result.messages), /read-image:owner-1/); }); + + 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.size, 1); + assert.equal(result.omittedParts, 1); + assert.match(prompt(result.messages), /screenshots\/screenshot\.png/); + }); }); 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..73eb22437a 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; @@ -1560,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( [ @@ -1997,6 +2040,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/__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-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 69f83d569f..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, @@ -178,10 +178,12 @@ 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, AutomaticMemoryCompactionDispatch, + MidTurnCapacityCompactState, ProviderImageBudget, } from './ai-sdk-compaction.js'; import { @@ -278,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, @@ -1030,21 +1031,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'; @@ -1715,6 +1710,7 @@ export class AiSdkBackend implements AgentBackend { const priorReplayResult = await this.buildPriorMessages( scope, input, + midTurnState, this.automaticMemoryCompactionSupported() ? true : undefined, ); if (scope.aborted) { @@ -1736,20 +1732,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); @@ -1954,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() @@ -1994,7 +1973,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, @@ -2003,11 +1982,7 @@ export class AiSdkBackend implements AgentBackend { )!, state: midTurnState, providerTools, - fallbackActiveTools: () => currentRepairToolNames(), charsPerToken: this.input.contextBudget?.charsPerToken ?? 4, - systemPromptChars: midTurnSystemPromptChars, - onDiagnosticPatch: onMidTurnDiagnosticPatch, - abortController: turnAbortController, }) : shapedProjection; @@ -2030,20 +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; - if (midTurnState?.exhaustedDetail) { - throw new Error( - `context budget exhausted before provider dispatch: ${midTurnState.exhaustedDetail}`, - ); - } - 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 && @@ -2058,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; @@ -2402,7 +2383,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) @@ -2430,7 +2411,7 @@ export class AiSdkBackend implements AgentBackend { currentMessages: attemptMessages, providerTools, activeTools: activeToolsForRequest, - systemPromptChars: midTurnSystemPromptChars, + systemPromptChars: requestSystemPrompt?.length ?? 0, queue, onDiagnosticPatch: onMidTurnDiagnosticPatch, origin: scope, @@ -2458,6 +2439,7 @@ export class AiSdkBackend implements AgentBackend { model, messages: recovered.messages, activeTools: activeToolsForRequest, + resolveDispatch, }) : undefined; attemptMessages = recoveredProjection?.messages ?? recovered.messages; @@ -2544,16 +2526,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; @@ -2768,6 +2740,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. @@ -2796,6 +2775,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', @@ -2884,20 +2871,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(), @@ -3376,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); @@ -3426,10 +3401,17 @@ 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; - let compactionFailure: ContextBudgetExhaustedDetail | undefined; if ( needsCompaction && contextBudget?.historyCompact?.enabled === true && @@ -3486,14 +3468,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 +3475,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 4f009b1e08..1bdd348587 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -47,9 +47,10 @@ 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. */ 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 7747e2e273..3138c9b6b9 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, @@ -95,7 +96,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 { @@ -112,13 +113,12 @@ 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, type HistoricalImageToolResult, + isInlineImageFilePart, omitHistoricalImageToolResults, } from './provider-image-overflow-recovery.js'; @@ -803,7 +803,9 @@ 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: reactive recovery runs off a real provider rejection and needs no + * window at all. */ public buildMidTurnCapacityCompactState( input: BackendSendInput, @@ -834,21 +836,29 @@ 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); - return new MidTurnCapacityCompactState( + const state = new MidTurnCapacityCompactState( headAnchor, priorContentEvents, input.runtimeContextRunHeaders ?? [], - capacity, + 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 ?? [], + state.priorRunHeaders, + this.input.modelId, + this.targetConnectionId, ); + if (persisted) { + state.lastRequestInputTokens = persisted.inputTokens; + state.lastRequestPayloadChars = persisted.payloadChars; + } + return state; } /** @@ -860,21 +870,16 @@ 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, state: MidTurnCapacityCompactState | undefined, queue: AsyncEventQueue, providerTools: readonly MakaTool[], - fallbackActiveTools: () => readonly string[], - systemPromptChars: number, onDiagnosticPatch: (patch: Partial) => void, origin: ProviderRequestOrigin, memoryCompactionDecision?: () => AutomaticMemoryCompactionDecision, @@ -902,10 +907,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 || state.exhaustedDetail) 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. @@ -925,15 +926,23 @@ 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; + } + // 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 => { @@ -942,7 +951,7 @@ export class AiSdkCompaction { stage: 'activeStep', sourceKind: 'runtimeEvents', decision: 'failedOpen', - phase: 'mid_turn', + phase, boundaryKind: 'historyCompact', reason: 'context_limit', failOpenReason, @@ -951,15 +960,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); }; @@ -972,28 +976,36 @@ 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; state.forcedTriggerEstimate = undefined; - const estimate = - forcedEstimate ?? - estimateNextRequestTokens({ - ...(state.lastRequestInputTokens !== undefined - ? { priorUsageTokens: state.lastRequestInputTokens } - : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), - charsPerToken, - coldStartChars: payloadChars, - }); + 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({ ...anchored, charsPerToken }); if ( forcedEstimate === undefined && - !exceedsHighWater(estimate, state.capacity.tokens, reserveTokens) + (state.capacity === undefined || !exceedsHighWater(estimate, state.capacity, reserveTokens)) ) { return keepProjection(); } @@ -1004,6 +1016,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, @@ -1011,13 +1024,13 @@ export class AiSdkCompaction { referencePayloadChars: payloadChars, providerTools, activeToolsForStep, - systemPromptChars, + systemPromptChars: dispatch.systemPromptChars, memoryCompactionDecision, onMemoryCompaction, abortSignal, }); if (outcome.decision === 'fail') { - return shapeFailure(outcome.detail, outcome.diagnosticReason); + return shapeFailure(outcome.diagnosticReason); } acceptedProjection = { sourceSignatures: incomingMessages.map(modelMessageSignature), @@ -1075,7 +1088,6 @@ export class AiSdkCompaction { if (state.malformedSummaryFailure) { return { decision: 'fail', - detail: state.malformedSummaryFailure, diagnosticReason: state.malformedSummaryFailure, }; } @@ -1124,14 +1136,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', }; } @@ -1145,7 +1155,6 @@ export class AiSdkCompaction { } catch { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'ledger_read_failed', }; } @@ -1157,7 +1166,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', }; } @@ -1168,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 @@ -1197,7 +1205,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 } : {}), @@ -1213,9 +1223,6 @@ export class AiSdkCompaction { } return { decision: 'fail', - detail: isMalformedHistoryCompactSummaryReason(diagnosticReason) - ? diagnosticReason - : plan.reason, diagnosticReason, }; } @@ -1236,7 +1243,6 @@ export class AiSdkCompaction { ) { return { decision: 'fail', - detail: 'no_safe_completed_span', diagnosticReason: 'replacement_unmaterializable', }; } @@ -1262,11 +1268,11 @@ export class AiSdkCompaction { providerTools, activeToolsForStep, systemPromptChars, + charsPerToken, ); if (replacedPayloadChars >= input.referencePayloadChars) { return { decision: 'fail', - detail: 'summarizer_failed', diagnosticReason: 'replacement_not_smaller', }; } @@ -1282,7 +1288,6 @@ export class AiSdkCompaction { if (!replayFit.fits) { return { decision: 'fail', - detail: 'head_anchor_exceeds_capacity', diagnosticReason: `replay_rejected_${replayFit.reason}`, }; } @@ -1295,7 +1300,6 @@ export class AiSdkCompaction { } catch { return { decision: 'fail', - detail: 'summarizer_failed', diagnosticReason: 'write_failed', }; } @@ -1327,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. */ @@ -1382,6 +1386,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({ @@ -1436,8 +1441,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 }; } @@ -1446,8 +1454,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 @@ -1457,39 +1465,19 @@ 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; providerTools: readonly MakaTool[]; - fallbackActiveTools: () => readonly string[]; charsPerToken: number; - systemPromptChars: number; - onDiagnosticPatch: (patch: Partial) => void; - abortController?: AbortController | null; }): RequestProjectionStage { - const { - shaped, - reentry, - state, - providerTools, - fallbackActiveTools, - charsPerToken, - systemPromptChars, - onDiagnosticPatch, - abortController, - } = input; + const { shaped, reentry, state, providerTools, charsPerToken } = input; return async (options) => { let result = await Promise.resolve(shaped(options)); const omissionProjection = projectHistoricalImageOmissions( @@ -1499,43 +1487,39 @@ 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(); - if ( - (options.stepNumber >= 1 || state.capacity.source === 'policy_fallback') && - !state.exhaustedDetail - ) { + // 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({ - ...(state.lastRequestInputTokens !== undefined - ? { priorUsageTokens: state.lastRequestInputTokens } - : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), + ...requestEstimateAnchor(state, payloadChars), 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.tokens && - !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({ @@ -1553,34 +1537,6 @@ export class AiSdkCompaction { }; } payloadChars = finalPayloadChars(); - estimate = estimateFinal(); - } - if (estimate > state.capacity.tokens) { - 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; @@ -1711,6 +1667,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; /** @@ -1721,14 +1680,16 @@ 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. */ 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 @@ -1751,18 +1712,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; @@ -1770,36 +1724,102 @@ 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, ) {} } /** * 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, + * 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 (!isInlineImageFilePart(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) ); } +/** + * 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 means the pairing has already failed, so + * drop it and cold start: the whole payload against a zero anchor. + */ +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: payloadChars }; + const appendedChars = payloadChars - baseline; + if (Math.abs(appendedChars) > payloadChars) return { appendedChars: payloadChars }; + 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 / @@ -1810,7 +1830,6 @@ function midTurnRequestPayloadChars( 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 ef94524602..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; @@ -126,24 +125,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' }; -} diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index dd52899777..376d374e65 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -173,6 +173,48 @@ export function durableProjectionToToolResultOutput( } } +/** One image a Tool Result puts in the request. */ +export interface MaterializedToolResultMedia { + /** How the model is told to name it once it is gone. */ + label: string; +} + +/** The images the artifact parts of one durable projection put in the request. */ +export function projectionArtifactMedia( + projection: DurableToolResultProjection, +): MaterializedToolResultMedia[] { + if (projection.kind !== 'content') return []; + return projection.parts.flatMap((part) => + part.kind === 'artifact' + ? [ + { + 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 [ + { + label: image.ref.kind === 'session_context' ? image.ref.refId : image.ref.relativePath, + }, + ]; +} + /** * 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 +246,7 @@ export function rewriteDurableToolResultProjectionArtifactRefs( }; } -type EffectiveToolResultProjection = +export type EffectiveToolResultProjection = | { kind: 'projection'; projection: DurableToolResultProjection; 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-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/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 1ee5fc1fb0..2c67346f55 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 { @@ -58,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; /** @@ -70,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`. */ @@ -106,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; @@ -139,8 +125,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 +265,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, @@ -429,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/model-history.ts b/packages/runtime/src/model-history.ts index 94c76425b7..840e7392f0 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -73,7 +73,9 @@ import type { import { decodeEffectiveToolResultProjection, durableProjectionToToolResultOutput, + 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'; @@ -126,9 +128,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 +138,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 +159,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: effectiveToolResultMedia(effective, sessionId).length * MATERIALIZED_IMAGE_TOKENS, + }; + 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 +203,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..480c20ada0 100644 --- a/packages/runtime/src/provider-image-overflow-recovery.ts +++ b/packages/runtime/src/provider-image-overflow-recovery.ts @@ -19,6 +19,10 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ModelMessage } from './model-protocol.js'; +import { + decodeEffectiveToolResultProjection, + effectiveToolResultMedia, +} from './durable-tool-result-projection.js'; export interface HistoricalImageToolResult { toolName: string; @@ -37,32 +41,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,23 +53,18 @@ 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 }); } return collected; } -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/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 { 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..62ab789737 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 } : {}), @@ -1272,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 { @@ -1567,6 +1576,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..5c53a3a595 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 @@ -642,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; } diff --git a/packages/runtime/src/tool-result-archive-transition.ts b/packages/runtime/src/tool-result-archive-transition.ts index f96588a4e8..50ceb7a06d 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 { @@ -56,7 +57,10 @@ import { turnKey, utf8ByteLength, } from './context-budget-helpers.js'; -import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import { + durableProjectionToToolResultOutput, + projectionArtifactMedia, +} from './durable-tool-result-projection.js'; import { baseToolResultProjection, nextInChain } from './model-projection-transition-ledger.js'; import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, @@ -289,8 +293,17 @@ export function collectStaleToolResultArchiveCandidates( if (!sourceProjection) continue; const serializedResult = serializedToolResultProjection(sourceProjection); const originalBytes = utf8ByteLength(serializedResult); - const originalEstimatedTokens = estimateTokens(serializedResult.length, charsPerToken); - if (originalEstimatedTokens <= maxResultEstimatedTokens) continue; + 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) + + 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,