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 1f89682187..319cf498ae 100644 --- a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; +import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js'; import { deriveFailedTurnRecovery, describeTurnErrorClass } from '../../renderer/session-status-presentation.js'; describe('provider capacity presentation', () => { @@ -41,3 +42,21 @@ describe('provider capacity presentation', () => { assert.doesNotMatch(recovery.label, /直接重试/); }); }); + +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/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index 0107b6ebc8..bc303df436 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -50,6 +50,19 @@ describe('failed turn recovery presentation', () => { ); }); + it('offers configuration or task recovery after runtime context exhaustion', () => { + assert.deepEqual( + deriveFailedTurnRecovery( + { ...outputFreeFailure, errorClass: 'context_budget_exhausted' }, + 'zh', + ), + { + action: 'check_connection', + label: '检查模型的上下文窗口设置、切换模型,或开启新任务', + }, + ); + }); + it('keeps the generic retry fallback for an unknown output-free failure', () => { assert.deepEqual( deriveFailedTurnRecovery({ ...outputFreeFailure, errorClass: 'unknown_failure' }, 'en'), diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 14fe0721d6..52496bd793 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -327,6 +327,8 @@ export interface DesktopConversationCopy { turnError: { unknown: string; contextOverflow: string; + contextBudgetExhausted: string; + malformedSummary: string; timeout: string; auth: string; providerBilling: string; @@ -339,7 +341,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'contextBudgetExhausted' | 'sandboxBoundaryClosed', string>; }; } @@ -636,7 +638,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, 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', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -867,7 +869,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: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', 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', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, + turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', contextBudgetExhausted: 'The context limit was reached and this task cannot continue', malformedSummary: 'Context compaction could not produce a valid summary. Check the model context-window setting, switch models, or start a new task.', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', 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', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', contextBudgetExhausted: 'Check the model context-window setting, switch models, or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index cb9a34de3f..ca0c7550d1 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -60,6 +60,13 @@ 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 3dde9920c1..0d46d48b23 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -30,6 +30,8 @@ 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 'auth': diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 74deaa7cb8..bc19cb9ab3 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -180,5 +180,8 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: if (lower === 'context_overflow') { return { action: 'continue', label: copy.contextOverflow }; } + if (lower === 'context_budget_exhausted') { + return { action: 'check_connection', label: copy.contextBudgetExhausted }; + } return { action: 'retry', label: copy.retry }; } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index f73952184f..20c9e88513 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1205,6 +1205,9 @@ export type ContextCompactionOutcome = export type ContextBudgetExhaustedDetail = | 'no_safe_completed_span' | 'summarizer_failed' + | 'malformed_summary_missing_section' + | 'malformed_summary_truncated' + | 'malformed_summary_too_small_for_fold' | 'head_anchor_exceeds_capacity'; export type CompleteStopReason = CompleteEvent['stopReason']; 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 346da5eb3e..fdc3316182 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -421,6 +421,65 @@ test('projects a failed Turn message from the canonical terminal event', async ( }); }); +test('projects context-budget exhaustion detail from the canonical terminal event', async () => { + await withStores(async (root, stores) => { + const { sessionId, rootAdmissions } = await createRunningRoot(root, stores); + const context = { + sessionId, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + source: 'test', + startedAt: 10, + request: { + sessionId, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + text: 'hello', + source: 'test', + }, + newId: () => 'unused', + now: () => 12, + } as const; + const terminalEvent = mapSessionEventToRuntimeEvent( + { + type: 'complete', + id: 'terminal-context-budget-1', + turnId: 'turn-1', + ts: 13, + stopReason: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'malformed_summary_missing_section', + }, + context, + createSessionEventMapMemory(), + ); + await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent); + await stores.agentRunStore.updateRun(sessionId, 'run-1', { + status: 'failed', + updatedAt: 13, + completedAt: 13, + failureClass: 'context_budget_exhausted', + }); + + const reader = new CanonicalSessionProjectionReader({ + stores, + rootAdmissions, + messages: { + projection: () => ({ hostEpoch: 'epoch-1', queueRevision: 0, steering: [], followup: [] }), + }, + }); + 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', + ); + } + }); +}); + test('propagates canonical Store read failures during candidate preflight', async () => { await withStores(async (root, stores) => { const session = await stores.sessionStore.create(sessionInput(root)); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..95cd817ae1 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -189,6 +189,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47); }); + test('publishes a new compatibility epoch for context-budget failure detail', () => { + // Epoch 50 is already used by WorkHub coordination summaries on main. + // The context-budget detail therefore needs its own strictly newer + // handshake boundary so peers cannot accept the wrong closed shape. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + test('adds credential rotation without changing existing credential inputs', () => { const issueInput = { principalKind: 'remote_owner', @@ -1607,6 +1614,15 @@ describe('Runtime Host bootstrap protocol', () => { }; assert.deepEqual(decodeHostFrame(response), response); + const withContextDetail = { + ...response, + result: { + ...response.result, + failureClass: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'malformed_summary_missing_section' as const, + }, + }; + assert.deepEqual(decodeHostFrame(withContextDetail), withContextDetail); assert.throws( () => decodeHostFrame({ diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index b0a5ac001b..9d349c047d 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -269,6 +269,46 @@ test('reseeds the latest provider retry when the active Turn still carries one', assert.equal(seeded[0] && 'phase' in seeded[0] ? seeded[0].phase : undefined, 'scheduled'); }); +test('projects structured context-budget failure detail to the Desktop event', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'malformed_summary_missing_section', + }, + }), + }).events; + + assert.deepEqual(events, [ + { + type: 'error', + id: 'terminal-1', + turnId: 'turn-1', + ts: 10, + recoverable: false, + reason: 'context_budget_exhausted', + message: 'Turn failed: context_budget_exhausted', + details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' }, + }, + ]); +}); + test('emits a live provider retry when the snapshot overlay appears, then drops it after content', () => { const projector = new RuntimeHostSessionProjector( snapshot(), diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 08b14a9604..f49adb4b83 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -456,6 +456,9 @@ 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 30f01b2cb7..0cc435a173 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,9 @@ 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 = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: Failed Turn snapshots preserve the structured context-budget exhaustion +// detail. Epoch-50 peers reject the optional field on the closed snapshot shape. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..d2c680c760 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -21,6 +21,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { decodeMessageContent as decodeCanonicalMessageContent, isCanonicalAttachmentRef, + type ContextBudgetExhaustedDetail, type ContextCompactionOutcome, type MessageContent, type ProviderRetryReason, @@ -193,6 +194,7 @@ export type TurnSnapshot = terminalEventId: string; failureClass: string; failureMessage?: string; + contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail; }) | (TurnSnapshotBase & { status: 'cancelled'; @@ -660,7 +662,7 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'failed Turn snapshot', ['sessionId', 'turnId', 'runId', 'status', 'terminalEventId', 'failureClass'], - ['failureMessage'], + ['failureMessage', 'contextBudgetExhaustedDetail'], ); return { ...base, @@ -677,6 +679,13 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { ), } : {}), + ...(record.contextBudgetExhaustedDetail !== undefined + ? { + contextBudgetExhaustedDetail: requireContextBudgetExhaustedDetail( + record.contextBudgetExhaustedDetail, + ), + } + : {}), }; } if (status === 'cancelled') { @@ -710,6 +719,20 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { }; } +function requireContextBudgetExhaustedDetail(value: unknown): ContextBudgetExhaustedDetail { + if ( + value === 'no_safe_completed_span' || + value === 'summarizer_failed' || + value === 'malformed_summary_missing_section' || + value === 'malformed_summary_truncated' || + value === 'malformed_summary_too_small_for_fold' || + value === 'head_anchor_exceeds_capacity' + ) { + 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/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 24cf1f3e7a..5faff9f202 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -18,7 +18,7 @@ */ import type { AgentRunHeader } from '@maka/core/agent-run'; -import type { ContextCompactionOutcome } from '@maka/core/events'; +import type { ContextBudgetExhaustedDetail, 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'; @@ -78,6 +78,9 @@ export async function readCanonicalTurnSnapshot( '…', ) : undefined; + const contextBudgetExhaustedDetail = readContextBudgetExhaustedDetail( + fact.terminalEvent.actions?.stateDelta?.contextBudgetExhaustedDetail, + ); return { sessionId, turnId, @@ -86,6 +89,7 @@ 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'); @@ -110,6 +114,22 @@ export async function readCanonicalTurnSnapshot( return { sessionId, turnId, runId, status: run.status }; } +function readContextBudgetExhaustedDetail( + value: unknown, +): ContextBudgetExhaustedDetail | undefined { + if ( + value === 'no_safe_completed_span' || + value === 'summarizer_failed' || + value === 'malformed_summary_missing_section' || + value === 'malformed_summary_truncated' || + value === 'malformed_summary_too_small_for_fold' || + value === 'head_anchor_exceeds_capacity' + ) { + return value; + } + return undefined; +} + function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { if (!value || typeof value !== 'object') return undefined; const outcome = value as Record; diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 0463b92005..bf45b721d3 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -99,6 +99,7 @@ import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; import { createTestAiSdkBackend, + readExternalExecutionBoundary, testToolResultArchive, } from './execution-boundary-test-helpers.js'; import type { MemoryExtractionSourceSnapshot } from '../memory-extraction.js'; @@ -5336,6 +5337,274 @@ describe('AiSdkBackend model history', () => { }); }); + test('does not redispatch an unchanged malformed compaction input', async () => { + let calls = 0; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { + name: 'malformed-summary-circuit-test', + maxHistoryEstimatedTokens: 10_000, + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => { + calls += 1; + throw new HistoryCompactSummarizerError('malformed_summary_missing_section'); + }, + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + const history = [ + runtimeTextEvent({ + id: 'circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + + const first = await backend.compactHistory({ + turnId: 'turn-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + const repeated = await backend.compactHistory({ + turnId: 'turn-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(calls, 1); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + + await backend.compactHistory({ + turnId: 'turn-compact-3', + runId: 'run-3', + runtimeContext: [ + ...history, + runtimeTextEvent({ + id: 'circuit-changed', + turnId: 'changed', + role: 'user', + author: 'user', + text: 'new source history', + }), + ], + }); + assert.equal(calls, 2, 'changed source fingerprint is eligible again'); + }); + + test('does not redispatch when malformed-summary repair fails with another reason', async () => { + let providerCalls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async () => { + providerCalls += 1; + return providerCalls % 2 === 1 + ? { text: 'free-form incomplete summary', finishReason: 'stop' } + : { text: '## Goal\npartial summary', finishReason: 'length' }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { + name: 'malformed-summary-repair-circuit-test', + maxHistoryEstimatedTokens: 10_000, + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: (input) => summarize(input), + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + const history = [ + runtimeTextEvent({ + id: 'repair-circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'repair-circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + + const first = await backend.compactHistory({ + turnId: 'turn-repair-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + const repeated = await backend.compactHistory({ + turnId: 'turn-repair-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(providerCalls, 2); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + }); + + test('invalidates the malformed compaction circuit when configuration changes', async (t) => { + type FingerprintCase = { + name: string; + expectedCalls: number; + prepare?: (input: AiSdkBackendInput, backend: AiSdkBackend) => void; + change?: (input: AiSdkBackendInput, backend: AiSdkBackend) => void; + }; + const setRequestShapeHash = (backend: AiSdkBackend, requestShapeHash: string): void => { + const internals = backend as unknown as { + priorRequestShape: { requestShapeHash: string } | undefined; + }; + internals.priorRequestShape = { requestShapeHash }; + }; + const cases = [ + { + name: 'unchanged input stays blocked', + expectedCalls: 1, + }, + { + name: 'model change retries', + expectedCalls: 2, + change: (input) => { + input.modelId = 'changed-model-id'; + }, + }, + { + name: 'connection change retries', + expectedCalls: 2, + change: (input) => { + input.connection = { ...input.connection, slug: 'anthropic-secondary' }; + }, + }, + { + name: 'context-window budget change retries', + expectedCalls: 2, + change: (input) => { + input.contextBudget = { + ...input.contextBudget, + maxHistoryEstimatedTokens: 12_000, + }; + }, + }, + { + name: 'request shape change retries', + expectedCalls: 2, + prepare: (_input, backend) => setRequestShapeHash(backend, 'request-shape-before'), + change: (_input, backend) => setRequestShapeHash(backend, 'request-shape-after'), + }, + ] satisfies readonly FingerprintCase[]; + + for (const fingerprintCase of cases) { + await t.test(fingerprintCase.name, async () => { + let calls = 0; + const backendInput: AiSdkBackendInput = { + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + readExecutionBoundary: readExternalExecutionBoundary, + contextBudget: { + name: 'malformed-summary-config-circuit-test', + maxHistoryEstimatedTokens: 10_000, + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => { + calls += 1; + throw new HistoryCompactSummarizerError('malformed_summary_missing_section'); + }, + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }; + const backend = new AiSdkBackend(backendInput); + const history = [ + runtimeTextEvent({ + id: 'config-circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'config-circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + fingerprintCase.prepare?.(backendInput, backend); + + const first = await backend.compactHistory({ + turnId: 'turn-config-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + fingerprintCase.change?.(backendInput, backend); + const repeated = await backend.compactHistory({ + turnId: 'turn-config-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(calls, fingerprintCase.expectedCalls); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + }); + } + }); + test('manual compactHistory is a no-op when context budget is disabled', async () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts index cd10abfd43..7a61a9e895 100644 --- a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -166,6 +166,67 @@ describe('buildLlmHistorySummarizer', () => { assert.equal(attempt.costUsd, undefined); }); + test('attributes a malformed completion and its repair to separate logical steps', async () => { + const recorded: ModelCallAttempt[] = []; + let providerCalls = 0; + let id = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => + new MockLanguageModelV4({ + doGenerate: async () => { + providerCalls += 1; + return { + content: [ + { + type: 'text' as const, + text: providerCalls === 1 ? 'free-form incomplete summary' : VALID_SUMMARY, + }, + ], + finishReason: { unified: 'stop' as const, raw: 'stop' }, + usage: { + inputTokens: { total: 7, noCache: 7, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + }, + warnings: [], + }; + }, + }), + }); + + await summarize({ + ...inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + providerRequestTracker: new ProviderRequestTracker({ + traceId: 'trace-id', + turnId: 'turn-1', + now: () => 100 + id, + newId: () => `request-${++id}`, + persistCapture: async () => ({ artifactId: `artifact-${id}` }), + recordAttempt: () => {}, + accounting: { + sessionId: 'sess-1', + resolveRunId: () => 'run-1', + connectionSlug: 'connection', + providerId: 'provider', + callKind: 'history_compact', + record: ({ attempt }: ModelCallCommit) => { + recorded.push(attempt); + }, + }, + }), + }); + + assert.equal(providerCalls, 2); + assert.deepEqual( + recorded.map((attempt) => attempt.step), + [0, 1], + ); + assert.deepEqual( + recorded.map((attempt) => attempt.attempt), + [0, 0], + ); + assert.notEqual(recorded[0]?.logicalCallId, recorded[1]?.logicalCallId); + }); + test('produces schema-valid tool-result messages (toolName + wrapped output) and does not fall back', async () => { const seen: Array<{ messages: unknown[] }> = []; const generateText: AiSdkGenerateTextLike = async (opts) => { @@ -644,6 +705,99 @@ describe('buildLlmHistorySummarizer', () => { ); }); + test('repairs one malformed completion with a single stricter retry', async () => { + const instructions: string[] = []; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async (options) => { + instructions.push(options.instructions); + return { + text: instructions.length === 1 ? 'free-form incomplete summary' : VALID_SUMMARY, + finishReason: 'stop', + }; + }, + }); + + const result = await summarize( + inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + ); + + assert.equal(result, VALID_SUMMARY); + assert.equal(instructions.length, 2); + assert.match(instructions[1] ?? '', /malformed_summary_missing_section/); + }); + + test('bounds a persistently malformed completion at two provider calls', async () => { + let calls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async () => { + calls += 1; + return { text: 'free-form incomplete summary', finishReason: 'stop' }; + }, + }); + + await assert.rejects( + summarize( + inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + ), + (error) => + error instanceof HistoryCompactSummarizerError && + error.reason === 'malformed_summary_missing_section', + ); + assert.equal(calls, 2); + }); + + test('preserves the initial malformed defect when the repair request fails', async () => { + let calls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async () => { + calls += 1; + if (calls === 1) return { text: 'free-form incomplete summary', finishReason: 'stop' }; + throw new Error('model down during repair'); + }, + }); + + await assert.rejects( + summarize( + inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + ), + (error) => + error instanceof HistoryCompactSummarizerError && + error.reason === 'malformed_summary_missing_section' && + error.cause instanceof HistoryCompactSummarizerError && + error.cause.reason === 'provider_error' && + error.cause.cause instanceof Error && + error.cause.cause.message === 'model down during repair', + ); + assert.equal(calls, 2); + }); + + test('preserves the initial malformed defect when the repair is empty', async () => { + let calls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async () => { + calls += 1; + return { + text: calls === 1 ? 'free-form incomplete summary' : '', + finishReason: 'stop', + }; + }, + }); + + await assert.rejects( + summarize( + inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + ), + (error) => + error instanceof HistoryCompactSummarizerError && + error.reason === 'malformed_summary_missing_section', + ); + assert.equal(calls, 2); + }); + test('a deeper heading level cannot stand in for a mandated section', async () => { const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', 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 333ac7a751..637018fae2 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -968,6 +968,25 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.equal(failedOpen?.failOpenReason, 'malformed_summary_missing_section'); }); + test('bounds malformed-summary attempts across later steps in the same turn', async () => { + const fixture = buildFixture({ + rollingOverflow: true, + summarize: () => { + throw new HistoryCompactSummarizerError('malformed_summary_missing_section'); + }, + }); + + await runFixtureTurn(fixture, consumer); + + 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 diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 6f1a31eb36..a1ebf81aea 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -282,6 +282,7 @@ 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, @@ -3625,7 +3626,9 @@ export class AiSdkBackend implements AgentBackend { compactionFailure = compactResult.outcome.reason === 'no_safe_completed_span' ? 'no_safe_completed_span' - : 'summarizer_failed'; + : isMalformedHistoryCompactSummaryReason(compactResult.outcome.reason) + ? compactResult.outcome.reason + : 'summarizer_failed'; } contextBudgetDiagnostic = mergeContextBudgetDiagnostic( contextBudgetDiagnostic ?? diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 21b4b6e2ff..9f0566df7b 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -36,7 +36,11 @@ import type { } from '@maka/core/backend-types'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; -import type { AiSdkCompactionCapabilities } from './ai-sdk-compaction-contract.js'; +import type { + AiSdkCompactionCapabilities, + HistoryCompactSummarizer, + HistoryCompactSummaryInput, +} from './ai-sdk-compaction-contract.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, @@ -58,7 +62,14 @@ import { projectHistoryCompactCheckpointReplay, type HistoryCompactCheckpoint, type HistoryCompactMemoryExtractionBoundary, + type HistoryCompactProviderState, } from './history-compact-checkpoint.js'; +import { + HistoryCompactSummarizerError, + isMalformedHistoryCompactSummaryReason, + type MalformedHistoryCompactSummaryReason, +} from './history-compact-error.js'; +import { findCheckpointSummaryDefect } from './history-compact-summary-validation.js'; import { createHash } from 'node:crypto'; import type { ModelMessage } from './model-protocol.js'; @@ -197,6 +208,15 @@ export class AiSdkCompaction { turnTailPrompt?: string, ) => ModelMessage['content']; private historyCompactAbortController: AbortController | null = null; + /** + * Session-scoped circuit for exact malformed compaction inputs. A retry or + * regeneration on the same backend must not dispatch the same doomed call; + * changed source/configuration fingerprints remain eligible. + */ + private readonly malformedSummaryFailures = new Map< + string, + MalformedHistoryCompactSummaryReason + >(); constructor(deps: AiSdkCompactionDeps) { this.input = deps.input; @@ -316,22 +336,20 @@ export class AiSdkCompaction { ...(automaticMemoryBoundary ? { memoryExtractionBoundary: automaticMemoryBoundary } : {}), ...(previousCheckpoint ? { previousCheckpoint } : {}), summarize: async ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, previousCheckpoint }) => - await Promise.resolve( - summarizer({ - sessionId: this.sessionId, - turnId: input.turnId, - source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, - newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], - ...(previousCheckpoint ? { previousCheckpoint } : {}), - inputBudget: { - maxEstimatedTokens: policy.maxHistoryEstimatedTokens ?? estimatedTokensBefore, - charsPerToken, - }, - ...(requestShapeHashBefore ? { requestShapeHashBefore } : {}), - abortSignal: historyCompactAbortController.signal, - ...(tracker ? { providerRequestTracker: tracker } : {}), - }), - ), + await this.summarizeWithFailureCircuit(summarizer, { + sessionId: this.sessionId, + turnId: input.turnId, + source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, + newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], + ...(previousCheckpoint ? { previousCheckpoint } : {}), + inputBudget: { + maxEstimatedTokens: policy.maxHistoryEstimatedTokens ?? estimatedTokensBefore, + charsPerToken, + }, + ...(requestShapeHashBefore ? { requestShapeHashBefore } : {}), + abortSignal: historyCompactAbortController.signal, + ...(tracker ? { providerRequestTracker: tracker } : {}), + }), }); if (historyCompactAbortController.signal.aborted) { return { outcome: { kind: 'failed', reason: 'aborted' } }; @@ -432,6 +450,55 @@ export class AiSdkCompaction { return Boolean(this.input.summarizeHistoryCompact && this.input.recordHistoryCompactCheckpoint); } + private async summarizeWithFailureCircuit( + summarizer: HistoryCompactSummarizer, + input: HistoryCompactSummaryInput, + ): Promise { + const fingerprint = sha256( + stableStringifyForSignature({ + version: 1, + connection: this.input.connection, + modelId: this.input.modelId, + historyCompactRoute: this.input.historyCompactRoute, + contextBudget: this.input.contextBudget, + inputBudget: input.inputBudget, + requestShapeHashBefore: input.requestShapeHashBefore, + previousCheckpointId: input.previousCheckpoint?.checkpointId, + foldedRuntimeEvents: input.source.foldedRuntimeEvents, + }), + ); + const priorFailure = this.malformedSummaryFailures.get(fingerprint); + if (priorFailure) throw new HistoryCompactSummarizerError(priorFailure); + + try { + const summary = await Promise.resolve(summarizer(input)); + if (typeof summary === 'string') { + const defect = findCheckpointSummaryDefect(summary, { + coveredRuntimeEvents: input.source.foldedRuntimeEvents, + ...(input.inputBudget?.charsPerToken !== undefined + ? { charsPerToken: input.inputBudget.charsPerToken } + : {}), + }); + if (defect) throw new HistoryCompactSummarizerError(defect); + } + return summary; + } catch (error) { + if ( + error instanceof HistoryCompactSummarizerError && + isMalformedHistoryCompactSummaryReason(error.reason) + ) { + this.malformedSummaryFailures.delete(fingerprint); + this.malformedSummaryFailures.set(fingerprint, error.reason); + while (this.malformedSummaryFailures.size > 16) { + const oldest = this.malformedSummaryFailures.keys().next().value; + if (oldest === undefined) break; + this.malformedSummaryFailures.delete(oldest); + } + } + throw error; + } + } + public async prepareContextBudgetPolicy(runtimeContext: readonly RuntimeEvent[]): Promise<{ policy: ContextBudgetPolicy | undefined; diagnosticPatch?: Partial; @@ -830,6 +897,13 @@ export class AiSdkCompaction { turnTailPrompt, abortSignal, } = input; + if (state.malformedSummaryFailure) { + return { + decision: 'fail', + detail: state.malformedSummaryFailure, + diagnosticReason: state.malformedSummaryFailure, + }; + } const summarizer = this.input.summarizeHistoryCompact!; const midTurnTracker = this.createProviderRequestTracker({ turnId, @@ -937,29 +1011,33 @@ export class AiSdkCompaction { } : {}), summarize: async ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, previousCheckpoint }) => { - return await Promise.resolve( - summarizer({ - sessionId: this.sessionId, - turnId, - source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, - ...(previousCheckpoint ? { previousCheckpoint } : {}), - newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], - inputBudget: { - maxEstimatedTokens: Math.max(1, state.capacity.tokens - reserveTokens), - charsPerToken, - }, - ...(abortSignal ? { abortSignal } : {}), - ...(midTurnTracker ? { providerRequestTracker: midTurnTracker } : {}), - }), - ); + return await this.summarizeWithFailureCircuit(summarizer, { + sessionId: this.sessionId, + turnId, + source: { foldedRuntimeEvents: [...coveredRuntimeEvents] }, + ...(previousCheckpoint ? { previousCheckpoint } : {}), + newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], + inputBudget: { + maxEstimatedTokens: Math.max(1, state.capacity.tokens - reserveTokens), + charsPerToken, + }, + ...(abortSignal ? { abortSignal } : {}), + ...(midTurnTracker ? { providerRequestTracker: midTurnTracker } : {}), + }); }, }); if (plan.decision === 'fail_open') { + const diagnosticReason = plan.diagnosticReason ?? plan.reason; + if (isMalformedHistoryCompactSummaryReason(diagnosticReason)) { + state.malformedSummaryFailure = diagnosticReason; + } return { decision: 'fail', - detail: plan.reason, - diagnosticReason: plan.diagnosticReason ?? plan.reason, + detail: isMalformedHistoryCompactSummaryReason(diagnosticReason) + ? diagnosticReason + : plan.reason, + diagnosticReason, }; } @@ -1519,6 +1597,8 @@ export class MidTurnCapacityCompactState { diagnosticReason: string; } | undefined; + /** Malformed summaries spend one bounded repair budget for this whole Turn. */ + malformedSummaryFailure: MalformedHistoryCompactSummaryReason | undefined; constructor( readonly headAnchor: RuntimeEvent, diff --git a/packages/runtime/src/history-compact-error.ts b/packages/runtime/src/history-compact-error.ts index 7d48caec0f..d0e42e1a99 100644 --- a/packages/runtime/src/history-compact-error.ts +++ b/packages/runtime/src/history-compact-error.ts @@ -26,6 +26,21 @@ export type HistoryCompactSummarizerFailureReason = | 'malformed_summary_truncated' | 'malformed_summary_too_small_for_fold'; +export type MalformedHistoryCompactSummaryReason = Extract< + HistoryCompactSummarizerFailureReason, + `malformed_summary_${string}` +>; + +export function isMalformedHistoryCompactSummaryReason( + reason: string, +): reason is MalformedHistoryCompactSummaryReason { + return ( + reason === 'malformed_summary_missing_section' || + reason === 'malformed_summary_truncated' || + reason === 'malformed_summary_too_small_for_fold' + ); +} + export class HistoryCompactSummarizerError extends Error { constructor( readonly reason: HistoryCompactSummarizerFailureReason, diff --git a/packages/runtime/src/history-compact-summarizer.ts b/packages/runtime/src/history-compact-summarizer.ts index 9790420f14..29d5b725e0 100644 --- a/packages/runtime/src/history-compact-summarizer.ts +++ b/packages/runtime/src/history-compact-summarizer.ts @@ -25,7 +25,10 @@ import { } from './history-compact-summary-validation.js'; import { toolResultOutput } from './tool-result-output.js'; import type { HistoryCompactSummaryInput } from './ai-sdk-compaction-contract.js'; -import { HistoryCompactSummarizerError } from './history-compact-error.js'; +import { + HistoryCompactSummarizerError, + isMalformedHistoryCompactSummaryReason, +} from './history-compact-error.js'; import { isTextHistoryCompactCheckpoint } from './history-compact-checkpoint.js'; import { fitHistoryCompactMessages } from './history-compact-input-fit.js'; import type { AiSdkUsageLike } from './model-adapter.js'; @@ -75,6 +78,16 @@ const SUMMARIZATION_SYSTEM_PROMPT = [ 'Keep each section concise. Preserve exact file paths, function names, commands, and error messages.', ].join('\n'); +function repairSummarizationSystemPrompt(reason: string): string { + return [ + SUMMARIZATION_SYSTEM_PROMPT, + '', + `A prior attempt was rejected as ${reason}.`, + 'Produce one complete replacement summary from the source conversation.', + 'Every required section must appear in order with substantive content. Do not discuss the repair.', + ].join('\n'); +} + export function buildLlmHistorySummarizer(options: BuildLlmHistorySummarizerOptions) { return async (input: HistoryCompactSummaryInput): Promise => { const previousCheckpoint = @@ -100,7 +113,7 @@ export function buildLlmHistorySummarizer(options: BuildLlmHistorySummarizerOpti ], }); } - const messages = fitHistoryCompactMessages(projectedMessages, { + const initialMessages = fitHistoryCompactMessages(projectedMessages, { maxInputEstimatedTokens: input.inputBudget?.maxEstimatedTokens, charsPerToken: input.inputBudget?.charsPerToken, fixedInputChars: SUMMARIZATION_SYSTEM_PROMPT.length, @@ -119,26 +132,70 @@ export function buildLlmHistorySummarizer(options: BuildLlmHistorySummarizerOpti ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), }) : options.resolveModel(); - const result = await generateText({ - model, - instructions: SUMMARIZATION_SYSTEM_PROMPT, - messages, - ...(options.providerOptions !== undefined - ? { providerOptions: options.providerOptions } - : {}), - ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), - }); - if (rawFinishReasonString(result.finishReason) === 'length') { - throw new HistoryCompactSummarizerError('output_length'); + const generateSummary = async ( + step: number, + instructions: string, + messages: ModelMessage[], + ) => { + providerRequestTracker?.setStep(step); + const result = await generateText({ + model, + instructions, + messages, + ...(options.providerOptions !== undefined + ? { providerOptions: options.providerOptions } + : {}), + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + }); + if (rawFinishReasonString(result.finishReason) === 'length') { + throw new HistoryCompactSummarizerError('output_length'); + } + const defect = findCheckpointSummaryDefect(result.text, { + coveredRuntimeEvents: input.source.foldedRuntimeEvents, + ...(input.inputBudget?.charsPerToken !== undefined + ? { charsPerToken: input.inputBudget.charsPerToken } + : {}), + }); + return { text: result.text, defect }; + }; + + const initial = await generateSummary(0, SUMMARIZATION_SYSTEM_PROMPT, initialMessages); + if (!initial.defect) return initial.text; + if (!isMalformedHistoryCompactSummaryReason(initial.defect)) { + throw new HistoryCompactSummarizerError(initial.defect); } - const defect = findCheckpointSummaryDefect(result.text, { - coveredRuntimeEvents: input.source.foldedRuntimeEvents, - ...(input.inputBudget?.charsPerToken !== undefined - ? { charsPerToken: input.inputBudget.charsPerToken } - : {}), + + // A malformed provider completion is often repairable, but retries must + // be bounded: one stricter attempt, then the caller's failure circuit + // records the stable defect for this compaction input. + const repairInstructions = repairSummarizationSystemPrompt(initial.defect); + const repairMessages = fitHistoryCompactMessages(projectedMessages, { + maxInputEstimatedTokens: input.inputBudget?.maxEstimatedTokens, + charsPerToken: input.inputBudget?.charsPerToken, + fixedInputChars: repairInstructions.length, }); - if (defect) throw new HistoryCompactSummarizerError(defect); - return result.text; + let repaired: Awaited>; + try { + repaired = await generateSummary(1, repairInstructions, repairMessages); + } catch (error) { + throw new HistoryCompactSummarizerError(initial.defect, { + cause: + error instanceof HistoryCompactSummarizerError + ? error + : new HistoryCompactSummarizerError('provider_error', { cause: error }), + }); + } + if (repaired.text.trim().length === 0) { + throw new HistoryCompactSummarizerError(initial.defect, { + cause: new Error('History compact repair returned an empty summary'), + }); + } + if (repaired.defect) { + throw new HistoryCompactSummarizerError(initial.defect, { + cause: new HistoryCompactSummarizerError(repaired.defect), + }); + } + return repaired.text; } catch (error) { if (error instanceof HistoryCompactSummarizerError) throw error; throw new HistoryCompactSummarizerError('provider_error', { cause: error });