Skip to content

Commit 725cfc2

Browse files
committed
refactor(core): retire context_budget_exhausted at the decode boundary
Nothing has produced this outcome since the runtime stopped issuing local termination verdicts: whether a request fits is the provider's answer, and a rejection is recovered from by compacting and retrying. What remained was a read-only chain nine files long — a `CompleteEvent.stopReason` member no backend can emit, a six-value detail enum with no writer, its predicate, the mapper's stateDelta pass-through, the Host protocol allowlist and decoder, the canonical snapshot field, the session projector's `details`, and two desktop presentation branches with their locale copy. Old sessions still carry the name, so the durable ledger's own read boundary folds it to `context_overflow` — the outcome every downstream consumer already treated it as. That is the only place that now knows two names for it. The summarizer's malformed-summary taxonomy, which derived its type from the retired enum, was already moved into the history-compaction domain. The host fixture's provider stub now reports input tokens that grow with the request. Its flat 11 made the anchored turn-start estimate meaningless, which is exactly the number that test's compaction assertions depend on.
1 parent 209b8ad commit 725cfc2

22 files changed

Lines changed: 130 additions & 201 deletions

apps/desktop/src/main/__tests__/session-error-presentation.test.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,3 @@ describe('provider capacity presentation', () => {
3737
});
3838
});
3939

40-
describe('context compaction failure presentation', () => {
41-
it('shows actionable malformed-summary guidance', () => {
42-
const message = sessionEventErrorMessage({
43-
type: 'error',
44-
id: 'error-1',
45-
turnId: 'turn-1',
46-
ts: 1,
47-
recoverable: false,
48-
reason: 'context_budget_exhausted',
49-
message: 'Turn failed: context_budget_exhausted',
50-
details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' },
51-
});
52-
53-
assert.match(message, //);
54-
assert.match(message, /||/);
55-
});
56-
});

apps/desktop/src/renderer/locales/conversation-copy.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,6 @@ export interface DesktopConversationCopy {
342342
turnError: {
343343
unknown: string;
344344
contextOverflow: string;
345-
contextBudgetExhausted: string;
346-
malformedSummary: string;
347345
timeout: string;
348346
auth: string;
349347
providerBilling: string;
@@ -658,7 +656,7 @@ const COPY = {
658656
reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' },
659657
testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' },
660658
},
661-
turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', contextBudgetExhausted: '上下文已达上限,这个任务无法继续。换模型或开启新任务。', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } },
659+
turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } },
662660
},
663661
en: {
664662
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 = {
894892
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.' },
895893
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.' },
896894
},
897-
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.' } },
895+
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.' } },
898896
},
899897
} satisfies UiCatalog<DesktopConversationCopy>;
900898

apps/desktop/src/renderer/model-connection-errors.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,6 @@ export function sessionEventErrorMessage(
6060
if (isNoRealConnectionEvent(event)) {
6161
return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale);
6262
}
63-
const contextBudgetDetail =
64-
event.details && !Array.isArray(event.details)
65-
? event.details.contextBudgetExhaustedDetail
66-
: undefined;
67-
if (typeof contextBudgetDetail === 'string' && contextBudgetDetail.startsWith('malformed_summary_')) {
68-
return getDesktopConversationCopy(locale).turnError.malformedSummary;
69-
}
7063
const reasonDescription = describeSessionErrorReason(event.reason, locale);
7164
if (reasonDescription) return reasonDescription;
7265
const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback;

apps/desktop/src/renderer/session-error-presentation.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U
3030
switch (reason?.toLowerCase()) {
3131
case 'context_overflow':
3232
return copy.contextOverflow;
33-
case 'context_budget_exhausted':
34-
return copy.contextBudgetExhausted;
3533
case 'timeout':
3634
return copy.timeout;
3735
case 'model_after_tool_timeout':

packages/cli/src/__tests__/runtime-host-run-command.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -500,15 +500,6 @@ describe('Runtime Host maka run adapter', () => {
500500
assert.equal(durable.failure?.class, 'tool_step_cap_reached');
501501
});
502502

503-
test('classifies a standalone context-budget completion as failed', async () => {
504-
const outcome = await observeFixtureOutcome({
505-
turnEvents: completionEvents('turn-1', 'context_budget_exhausted'),
506-
});
507-
508-
assert.equal(outcome.status, 'failed');
509-
assert.equal(outcome.failure?.class, 'context_budget_exhausted');
510-
});
511-
512503
test('uses the latest durable terminal state for a Graph Turn', async () => {
513504
const outcome = await observeFixtureOutcome({
514505
graph: true,

packages/core/src/events.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,14 +1247,7 @@ export interface CompleteEvent extends BaseEvent {
12471247
| 'graph_yield'
12481248
| 'permission_handoff'
12491249
| 'step_limit'
1250-
| 'max_tokens'
1251-
| 'context_budget_exhausted';
1252-
/**
1253-
* Detail for `stopReason: 'context_budget_exhausted'` — the runtime could not
1254-
* produce a provider-safe request even after mid-turn compaction. A first-class
1255-
* outcome, not a provider context-length error.
1256-
*/
1257-
contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail;
1250+
| 'max_tokens';
12581251
/** Durable result of an explicit context-compaction execution. */
12591252
contextCompactionOutcome?: ContextCompactionOutcome;
12601253
}
@@ -1264,32 +1257,14 @@ export type ContextCompactionOutcome =
12641257
| { kind: 'unchanged'; reason: string }
12651258
| { kind: 'failed'; reason: string };
12661259

1267-
export const CONTEXT_BUDGET_EXHAUSTED_DETAILS = [
1268-
'no_safe_completed_span',
1269-
'summarizer_failed',
1270-
'malformed_summary_missing_section',
1271-
'malformed_summary_truncated',
1272-
'malformed_summary_too_small_for_fold',
1273-
'head_anchor_exceeds_capacity',
1274-
] as const;
1275-
1276-
export type ContextBudgetExhaustedDetail = (typeof CONTEXT_BUDGET_EXHAUSTED_DETAILS)[number];
1277-
1278-
export function isContextBudgetExhaustedDetail(
1279-
value: unknown,
1280-
): value is ContextBudgetExhaustedDetail {
1281-
return CONTEXT_BUDGET_EXHAUSTED_DETAILS.includes(value as ContextBudgetExhaustedDetail);
1282-
}
1283-
12841260
export type CompleteStopReason = CompleteEvent['stopReason'];
12851261

12861262
/** Stable failure taxonomy for complete events that did not finish the turn. */
12871263
export function failureClassFromCompleteStopReason(
12881264
reason: CompleteStopReason,
1289-
): 'runtime_error' | 'tool_step_cap_reached' | 'context_budget_exhausted' | undefined {
1265+
): 'runtime_error' | 'tool_step_cap_reached' | undefined {
12901266
if (reason === 'error') return 'runtime_error';
12911267
if (reason === 'step_limit') return 'tool_step_cap_reached';
1292-
if (reason === 'context_budget_exhausted') return 'context_budget_exhausted';
12931268
return undefined;
12941269
}
12951270

packages/runtime-host/src/__tests__/canonical-session-projection.test.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -335,12 +335,6 @@ test('preflights the worst-case failed Turn before accepting more queued content
335335
);
336336
const worstCaseFailedTurn = worstCaseFailedTurnSnapshot(capacityCanonical.rootTurn!);
337337
assert.equal(worstCaseFailedTurn.status, 'failed');
338-
if (worstCaseFailedTurn.status === 'failed') {
339-
assert.equal(
340-
worstCaseFailedTurn.contextBudgetExhaustedDetail,
341-
'malformed_summary_too_small_for_fold',
342-
);
343-
}
344338
assert.throws(() =>
345339
createSessionContinuitySnapshot(
346340
{
@@ -429,7 +423,7 @@ test('projects a failed Turn message from the canonical terminal event', async (
429423
});
430424
});
431425

432-
test('projects context-budget exhaustion detail from the canonical terminal event', async () => {
426+
test('a legacy context_budget_exhausted terminal event still projects, as a context overflow', async () => {
433427
await withStores(async (root, stores) => {
434428
const { sessionId, rootAdmissions } = await createRunningRoot(root, stores);
435429
const context = {
@@ -450,18 +444,30 @@ test('projects context-budget exhaustion detail from the canonical terminal even
450444
newId: () => 'unused',
451445
now: () => 12,
452446
} as const;
453-
const terminalEvent = mapSessionEventToRuntimeEvent(
447+
// Exactly what a session written before this outcome was retired holds: the
448+
// runtime can no longer emit it, so the durable shape is rebuilt here.
449+
const mapped = mapSessionEventToRuntimeEvent(
454450
{
455451
type: 'complete',
456452
id: 'terminal-context-budget-1',
457453
turnId: 'turn-1',
458454
ts: 13,
459-
stopReason: 'context_budget_exhausted',
460-
contextBudgetExhaustedDetail: 'malformed_summary_missing_section',
455+
stopReason: 'error',
461456
},
462457
context,
463458
createSessionEventMapMemory(),
464459
);
460+
const terminalEvent = {
461+
...mapped,
462+
actions: {
463+
...mapped.actions,
464+
stateDelta: {
465+
stopReason: 'context_budget_exhausted',
466+
failureClass: 'context_budget_exhausted',
467+
contextBudgetExhaustedDetail: 'malformed_summary_missing_section',
468+
},
469+
},
470+
};
465471
await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent);
466472
await stores.agentRunStore.updateRun(sessionId, 'run-1', {
467473
status: 'failed',
@@ -480,10 +486,7 @@ test('projects context-budget exhaustion detail from the canonical terminal even
480486
const canonical = await reader.read(sessionId);
481487
assert.equal(canonical?.rootTurn?.status, 'failed');
482488
if (canonical?.rootTurn?.status === 'failed') {
483-
assert.equal(
484-
canonical.rootTurn.contextBudgetExhaustedDetail,
485-
'malformed_summary_missing_section',
486-
);
489+
assert.equal(canonical.rootTurn.failureClass, 'context_overflow');
487490
}
488491
});
489492
});

0 commit comments

Comments
 (0)