diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index ef98240d7e..18f4383c2a 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -81,16 +81,13 @@ export interface BackendSendInput { /** Inline quoted excerpts folded into the model-facing user content. */ quotes?: QuoteRef[]; /** - * Prior conversation projected from the RuntimeEvent ledger into the - * existing StoredMessage public shape. Adapters materialize this into the - * SDK's expected conversation shape when native RuntimeEvent replay is not - * available. + * Legacy caller projection retained for source compatibility. Runtime + * backends must not use it as provider history; RuntimeEvents are the only + * model-history authority. */ - context: StoredMessage[]; + context?: StoredMessage[]; /** - * Optional prior RuntimeEvent ledger for model-history projection. Backends - * prefer this when supplied and usable; `context` is the RuntimeEvent-derived - * compatibility projection. + * Optional prior RuntimeEvent ledger for model-history projection. */ runtimeContext?: RuntimeEvent[]; /** diff --git a/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts b/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts index 3a80bdfefd..ed38c7749c 100644 --- a/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts @@ -45,6 +45,10 @@ import { type MemoryExtractionSourceSnapshot } from '@maka/runtime/memory-extrac import { HostMemoryExtractionCoordinator } from '../server/memory-extraction-coordinator.js'; import { MemoryExtractionSessionLane } from '../server/memory-extraction-session-lane.js'; +function sectionedSummary(goal: string): string { + return `## Goal\n${goal}\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)`; +} + describe('HostMemoryExtractionCoordinator', () => { test('extracts incidental memory through the post-terminal memory_extract path', async () => { await withMemoryWriter(async (writer) => { @@ -685,8 +689,7 @@ describe('HostMemoryExtractionCoordinator', () => { checkpoint: buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [old], - summary: 'The older context was compacted.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The older context was compacted.'), now: 1_500, }), }); @@ -731,15 +734,13 @@ describe('HostMemoryExtractionCoordinator', () => { const firstCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [first, firstBoundary], - summary: 'FIRST SUMMARY MUST BE REPLACED', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('FIRST SUMMARY MUST BE REPLACED'), }); const secondCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [first, firstBoundary, second, secondBoundary], previousCheckpointId: firstCheckpoint.checkpointId, - summary: 'LATEST SECOND SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('LATEST SECOND SUMMARY'), }); await writer.initializeExtractionCursor('session-1', 4); const observed: Array<{ snapshot: MemoryExtractionSourceSnapshot; prompt: string }> = []; @@ -811,8 +812,7 @@ describe('HostMemoryExtractionCoordinator', () => { checkpoint: buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [old, anchor], - summary: 'The older context and current-turn prefix were compacted.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The older context and current-turn prefix were compacted.'), phase: 'mid_turn', headAnchor: { runtimeEventId: anchor.id, turnId: anchor.turnId }, now: 1_500, @@ -849,8 +849,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [old], - summary: 'Purported compacted context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Purported compacted context.'), now: 1_500, }); const observed: Array<{ snapshot: MemoryExtractionSourceSnapshot; prompt: string }> = []; @@ -1239,8 +1238,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [firstUser, secondUser], - summary: 'The conversation contains two durable preferences.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The conversation contains two durable preferences.'), memoryExtractionBoundary: { runId: compactionBoundary.runId, turnId: compactionBoundary.turnId, @@ -1551,8 +1549,7 @@ describe('HostMemoryExtractionCoordinator', () => { const firstCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [oldUser], - summary: 'Old context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Old context.'), memoryExtractionBoundary: { runId: oldBoundary.runId, turnId: oldBoundary.turnId, @@ -1616,8 +1613,7 @@ describe('HostMemoryExtractionCoordinator', () => { const secondCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [oldUser, oldBoundary, newUser], - summary: 'Old and new context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Old and new context.'), previousCheckpointId: firstCheckpoint.checkpointId, memoryExtractionBoundary: { runId: newBoundary.runId, @@ -1671,8 +1667,7 @@ describe('HostMemoryExtractionCoordinator', () => { const firstCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [oldUser], - summary: 'Old context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Old context.'), memoryExtractionBoundary: { runId: oldBoundary.runId, turnId: oldBoundary.turnId, @@ -1682,8 +1677,7 @@ describe('HostMemoryExtractionCoordinator', () => { const secondCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [oldUser, oldBoundary, newUser], - summary: 'Old and new context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Old and new context.'), previousCheckpointId: firstCheckpoint.checkpointId, memoryExtractionBoundary: { runId: newBoundary.runId, @@ -1771,8 +1765,7 @@ describe('HostMemoryExtractionCoordinator', () => { const deniedCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [pendingUser, pendingBoundary, deniedUser], - summary: 'Denied period.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Denied period.'), memoryExtractionBoundary: { runId: deniedBoundary.runId, turnId: deniedBoundary.turnId, @@ -1789,8 +1782,7 @@ describe('HostMemoryExtractionCoordinator', () => { deniedBoundary, eligibleUser, ], - summary: 'Eligible tail.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Eligible tail.'), previousCheckpointId: deniedCheckpoint.checkpointId, memoryExtractionBoundary: { runId: eligibleBoundary.runId, @@ -1904,8 +1896,7 @@ describe('HostMemoryExtractionCoordinator', () => { const deniedCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [requestedUser, requestedCall, deniedUser], - summary: 'DENIED_SUMMARY_SECRET', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('DENIED_SUMMARY_SECRET'), memoryExtractionBoundary: { runId: deniedBoundary.runId, turnId: deniedBoundary.turnId, @@ -1916,8 +1907,7 @@ describe('HostMemoryExtractionCoordinator', () => { const laterCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [requestedUser, requestedCall, deniedUser, deniedBoundary, laterUser], - summary: 'Cumulative summary after denial.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Cumulative summary after denial.'), previousCheckpointId: deniedCheckpoint.checkpointId, memoryExtractionBoundary: { runId: laterBoundary.runId, @@ -2046,8 +2036,7 @@ describe('HostMemoryExtractionCoordinator', () => { const deniedCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [deniedUser], - summary: 'Denied period.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Denied period.'), memoryExtractionBoundary: { runId: deniedBoundary.runId, turnId: deniedBoundary.turnId, @@ -2058,8 +2047,7 @@ describe('HostMemoryExtractionCoordinator', () => { const eligibleCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [deniedUser, deniedBoundary, eligibleUser], - summary: 'Eligible tail.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Eligible tail.'), previousCheckpointId: deniedCheckpoint.checkpointId, memoryExtractionBoundary: { runId: eligibleBoundary.runId, @@ -2117,8 +2105,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [user], - summary: 'Context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Context.'), memoryExtractionBoundary: { runId: 'wrong-run', turnId: boundary.turnId, @@ -2161,8 +2148,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [user], - summary: 'Context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Context.'), memoryExtractionBoundary: { runId: boundary.runId, turnId: boundary.turnId, @@ -2217,8 +2203,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [user], - summary: 'Context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Context.'), memoryExtractionBoundary: { runId: boundary.runId, turnId: boundary.turnId, @@ -2259,8 +2244,7 @@ describe('HostMemoryExtractionCoordinator', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [user], - summary: 'Context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Context.'), memoryExtractionBoundary: { runId: boundary.runId, turnId: boundary.turnId, diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 72e913fc87..efdc7f4895 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -78,6 +78,10 @@ const GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID = 'graph-side-conversation-remov const ARCHIVED_SIDE_CONVERSATION_TARGET_ID = 'archived-side-conversation-target'; const ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID = 'active-source-side-conversation-target'; +function sectionedSummary(goal: string): string { + return `## Goal\n${goal}\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)`; +} + test('two Clients share exact retryable Session branch and revision authority', { skip: process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false, timeout: 120_000, @@ -1642,8 +1646,7 @@ async function seedDurableOrderCheckpoint( const checkpoint = buildHistoryCompactCheckpoint({ sessionId: sourceSessionId, coveredRuntimeEvents, - summary: 'The first turn completed.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The first turn completed.'), highWaterSeq: 2, }); await execution.agentRunStore.appendEvent(sourceSessionId, 'run-turn-1', { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index ef34267203..055875101e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -43,6 +43,7 @@ import { mapSessionEventToRuntimeEvent, } from '../session-event-runtime-mapper.js'; import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; import type { AssistantMessage, StoredMessage, ToolResultMessage } from '@maka/core/session'; import { z } from 'zod'; @@ -2133,7 +2134,7 @@ describe('AiSdkBackend model history', () => { assert.equal(prompt.at(-1)?.role, 'tool'); }); - test('uses StoredMessage projection when RuntimeEvent replay is empty', async () => { + test('does not recover provider history from StoredMessages when RuntimeEvent replay is empty', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -2182,65 +2183,11 @@ describe('AiSdkBackend model history', () => { ); assert.deepEqual(compactPrompt(model), [ - { role: 'user', content: [{ type: 'text', text: 'projection user' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'projection assistant' }] }, - { role: 'user', content: [{ type: 'text', text: 'current user' }] }, - ]); - }); - - test('stored-message fallback skips empty assistant texts', async () => { - // A thinking/tool-only step projects an assistant row with empty text. - // The degraded stored-message path must not replay it: an empty text - // content block is a hard 400 on Anthropic-protocol providers, which - // permanently blocks every later turn of the session. - const model = completionModel(); - const backend = createTestAiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => {}, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => model, - tools: [], - newId: idGenerator(), - now: monotonicClock(), - }); - - await drain( - backend.send({ - turnId: 'turn-current', - text: 'current user', - context: [ - { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, - { - type: 'assistant', - id: 'projection-empty', - turnId: 'turn-prev', - ts: 2, - text: '', - modelId: 'm', - }, - { - type: 'assistant', - id: 'projection-a', - turnId: 'turn-prev', - ts: 3, - text: 'projection assistant', - modelId: 'm', - }, - ], - }), - ); - - assert.deepEqual(compactPrompt(model), [ - { role: 'user', content: [{ type: 'text', text: 'projection user' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'projection assistant' }] }, { role: 'user', content: [{ type: 'text', text: 'current user' }] }, ]); }); - test('stored-message fallback describes an attachment that is not safely addressable', async () => { + test('RuntimeEvent replay describes an attachment that is not safely addressable', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -2259,50 +2206,38 @@ describe('AiSdkBackend model history', () => { backend.send({ turnId: 'turn-current', text: 'current user', - context: [ - { - type: 'user', - id: 'projection-u', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-u', turnId: 'turn-prev', - ts: 1, - text: 'see the attached chart', - attachments: [ - { - kind: 'image', - name: 'chart.png', - mimeType: 'image/png', - bytes: 123, - ref: { - kind: 'session_file', - sessionId: 'sess-1', - relativePath: 'attachments/chart.png', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'see the attached chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'sess-1', + relativePath: 'attachments/chart.png', + }, }, - }, - ], - }, - { - type: 'assistant', - id: 'projection-a', - turnId: 'turn-prev', - ts: 2, - text: 'projection assistant', - modelId: 'm', - }, - ], - runtimeContext: [ - { - id: 'rt-terminal', - invocationId: 'inv-1', - runId: 'run-prev', - sessionId: 'session-1', + ], + }, + }), + runtimeTextEvent({ + id: 'rt-a', turnId: 'turn-prev', - ts: 1, - partial: false, role: 'model', author: 'agent', - status: 'completed', - actions: { endInvocation: true }, - }, + text: 'projection assistant', + }), ], }), ); @@ -2316,11 +2251,11 @@ describe('AiSdkBackend model history', () => { text.includes( '\nThe attachment content is unavailable to Read.\nname: "chart.png"\nmime_type: "image/png"\n', ), - `expected unavailable attachment context in stored-message fallback, got: ${text}`, + `expected unavailable attachment context in RuntimeEvent replay, got: ${text}`, ); }); - test('current and stored directory references expose paths without eager listings', async () => { + test('current and replayed directory references expose paths without eager listings', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -2342,38 +2277,26 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'inspect current', directoryReferences: [currentReference], - context: [ - { - type: 'user', - id: 'projection-u', - turnId: 'turn-prev', - ts: 1, - text: 'inspect prior', - directoryReferences: [historicalReference], - }, - { - type: 'assistant', - id: 'projection-a', - turnId: 'turn-prev', - ts: 2, - text: 'projection assistant', - modelId: 'm', - }, - ], + context: [], runtimeContext: [ - { - id: 'rt-terminal', - invocationId: 'inv-1', - runId: 'run-prev', - sessionId: 'session-1', + runtimeEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'inspect prior', + directoryReferences: [historicalReference], + }, + }), + runtimeTextEvent({ + id: 'rt-a', turnId: 'turn-prev', - ts: 1, - partial: false, role: 'model', author: 'agent', - status: 'completed', - actions: { endInvocation: true }, - }, + text: 'projection assistant', + }), ], }), ); @@ -2395,7 +2318,7 @@ describe('AiSdkBackend model history', () => { } }); - test('stored-message fallback renders image attachments as image parts when a reader is wired', async () => { + test('RuntimeEvent replay renders image attachments as image parts when a reader is wired', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 4, 5, 6]); const model = completionModel(); const backend = createTestAiSdkBackend({ @@ -2417,50 +2340,38 @@ describe('AiSdkBackend model history', () => { backend.send({ turnId: 'turn-current', text: 'current user', - context: [ - { - type: 'user', - id: 'projection-u', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-u', turnId: 'turn-prev', - ts: 1, - text: 'see the attached chart', - attachments: [ - { - kind: 'image', - name: 'chart.png', - mimeType: 'image/png', - bytes: 123, - ref: { - kind: 'session_file', - sessionId: 'sess-1', - relativePath: 'attachments/chart.png', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'see the attached chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'sess-1', + relativePath: 'attachments/chart.png', + }, }, - }, - ], - }, - { - type: 'assistant', - id: 'projection-a', - turnId: 'turn-prev', - ts: 2, - text: 'projection assistant', - modelId: 'm', - }, - ], - runtimeContext: [ - { - id: 'rt-terminal', - invocationId: 'inv-1', - runId: 'run-prev', - sessionId: 'session-1', + ], + }, + }), + runtimeTextEvent({ + id: 'rt-a', turnId: 'turn-prev', - ts: 1, - partial: false, role: 'model', author: 'agent', - status: 'completed', - actions: { endInvocation: true }, - }, + text: 'projection assistant', + }), ], }), ); @@ -2471,7 +2382,7 @@ describe('AiSdkBackend model history', () => { const imageLike = parts.find((p) => p.type !== 'text' && p.mediaType === 'image/png'); assert.ok( imageLike, - `expected a historical image/png part in stored-message fallback, got: ${JSON.stringify(parts)}`, + `expected a historical image/png part in RuntimeEvent replay, got: ${JSON.stringify(parts)}`, ); }); @@ -4730,8 +4641,7 @@ describe('AiSdkBackend model history', () => { const previous = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: oldEvents.slice(0, 1), - summary: 'MANUAL_V2_PREVIOUS_SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('MANUAL_V2_PREVIOUS_SUMMARY'), charsPerToken: 1, }); const summaryInputs: Array<{ previous?: string; newlyFoldedIds: string[] }> = []; @@ -4782,7 +4692,7 @@ describe('AiSdkBackend model history', () => { assert.deepEqual(summaryInputs, [ { - previous: 'MANUAL_V2_PREVIOUS_SUMMARY', + previous: previous.summary, newlyFoldedIds: ['manual-v2-roll-old-2', 'manual-v2-roll-recent'], }, ]); @@ -4817,8 +4727,7 @@ describe('AiSdkBackend model history', () => { const previous = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [...oldEvents, recentEvent], - summary: 'MANUAL_V2_REUSED_SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('MANUAL_V2_REUSED_SUMMARY'), charsPerToken: 1, }); let summarizeCalls = 0; @@ -6146,9 +6055,8 @@ describe('AiSdkBackend model history', () => { test('keeps RuntimeEvent replay when a tool result is unmatched (orphan dropped, rest replayed)', async () => { // `unmatched_tool_result` is a non-blocking diagnostic: the materializer - // drops the orphan itself (a standalone tool message is an Anthropic 400), - // so the ledger stays on RuntimeEvent replay instead of falling back to - // StoredMessage projection. + // drops the orphan itself (a standalone tool message is an Anthropic 400) + // while retaining the rest of canonical history. const model = completionModel(); let imageReads = 0; const backend = createTestAiSdkBackend({ @@ -6213,7 +6121,7 @@ describe('AiSdkBackend model history', () => { }), ); - // RuntimeEvent replay (not the StoredMessage projection), orphan gone. + // The orphan is gone and the rest of RuntimeEvent replay remains. assert.deepEqual(compactPrompt(model), [ { role: 'user', content: [{ type: 'text', text: 'runtime user' }] }, { role: 'user', content: [{ type: 'text', text: 'current user' }] }, @@ -6280,7 +6188,7 @@ describe('AiSdkBackend model history', () => { ]); }); - test('uses StoredMessage projection instead of leaking unsupported thinking text', async () => { + test('drops unsupported thinking while preserving RuntimeEvent text', async () => { const model = completionModel(); const openAiConnection = { ...connection(), providerType: 'openai' as const }; const backend = createTestAiSdkBackend({ @@ -6301,13 +6209,19 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'current user', context: [ - { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'user', + id: 'projection-u', + turnId: 'turn-prev', + ts: 1, + text: 'wrong projection', + }, { type: 'assistant', id: 'projection-a', turnId: 'turn-prev', ts: 2, - text: 'projection assistant', + text: 'wrong projection assistant', modelId: 'm', }, ], @@ -14794,12 +14708,10 @@ describe('AiSdkBackend steering durability and identity', () => { ]); }); - test('a degraded stored-message projection presents prior steering exactly once, in envelope form', async () => { + test('degraded RuntimeEvent replay presents prior steering exactly once, in envelope form', async () => { // A blocking replay diagnostic (here: a tool-role text event) degrades the - // whole ledger to the StoredMessage projection, which cannot carry the - // RuntimeEvent steering marker. The sidecar (keyed by the projection's - // stable ids) restores the canonical envelope + structured identity, so - // the steering appears exactly once and dedupe still works by id. + // provider-native shape to text-only RuntimeEvent replay. The canonical + // steering marker still produces one envelope with its structured id. const model = textCompletionModel('done'); const backend = steeringBackend(model); const steeredEvent = runtimeTextEvent({ @@ -14822,11 +14734,7 @@ describe('AiSdkBackend steering durability and identity', () => { backend.send({ turnId: 'turn-current', text: 'continue', - context: [ - { type: 'user', id: 'rt-u', turnId: 'turn-prev', ts: 1, text: 'original ask' }, - { type: 'user', id: 'rt-steer', turnId: 'turn-prev', ts: 2, text: 'steered earlier' }, - { type: 'assistant', id: 'rt-a', turnId: 'turn-prev', ts: 3, text: 'ok', modelId: 'm' }, - ], + context: [], runtimeContext: [ runtimeTextEvent({ id: 'rt-u', @@ -14856,108 +14764,6 @@ describe('AiSdkBackend steering durability and identity', () => { ]); }); - test('the degraded-projection sidecar restores steering keyed by providerEventId', async () => { - // A StoredMessage projection may carry the provider's event id, not the - // runtime event id, as the message's stable id. The sidecar must match on - // that key too, or the degraded replay silently loses the steering - // identity (bare text, no envelope, no dedupe id). - const model = textCompletionModel('done'); - const backend = steeringBackend(model); - const steeredEvent = runtimeTextEvent({ - id: 'rt-steer', - turnId: 'turn-prev', - role: 'user', - author: 'user', - text: 'steered earlier', - }); - (steeredEvent.content as { steering?: true }).steering = true; - steeredEvent.refs = { providerEventId: 'prov-steer' }; - const degradingEvent = runtimeTextEvent({ - id: 'rt-bad', - turnId: 'turn-prev', - role: 'user', - author: 'user', - text: 'boom', - }); - (degradingEvent as { role: string }).role = 'tool'; - await drain( - backend.send({ - turnId: 'turn-current', - text: 'continue', - context: [ - { type: 'user', id: 'prov-steer', turnId: 'turn-prev', ts: 1, text: 'steered earlier' }, - { type: 'assistant', id: 'prov-a', turnId: 'turn-prev', ts: 2, text: 'ok', modelId: 'm' }, - ], - runtimeContext: [ - steeredEvent, - degradingEvent, - runtimeTextEvent({ - id: 'rt-a', - turnId: 'turn-prev', - role: 'model', - author: 'agent', - text: 'ok', - }), - ], - }), - ); - - assert.deepEqual(compactPrompt(model), [ - { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('steered earlier') }] }, - { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, - { role: 'user', content: [{ type: 'text', text: 'continue' }] }, - ]); - }); - - test('the degraded-projection sidecar restores steering keyed by storedMessageId', async () => { - const model = textCompletionModel('done'); - const backend = steeringBackend(model); - const steeredEvent = runtimeTextEvent({ - id: 'rt-steer', - turnId: 'turn-prev', - role: 'user', - author: 'user', - text: 'steered earlier', - }); - (steeredEvent.content as { steering?: true }).steering = true; - steeredEvent.refs = { storedMessageId: 'sm-steer' }; - const degradingEvent = runtimeTextEvent({ - id: 'rt-bad', - turnId: 'turn-prev', - role: 'user', - author: 'user', - text: 'boom', - }); - (degradingEvent as { role: string }).role = 'tool'; - await drain( - backend.send({ - turnId: 'turn-current', - text: 'continue', - context: [ - { type: 'user', id: 'sm-steer', turnId: 'turn-prev', ts: 1, text: 'steered earlier' }, - { type: 'assistant', id: 'sm-a', turnId: 'turn-prev', ts: 2, text: 'ok', modelId: 'm' }, - ], - runtimeContext: [ - steeredEvent, - degradingEvent, - runtimeTextEvent({ - id: 'rt-a', - turnId: 'turn-prev', - role: 'model', - author: 'agent', - text: 'ok', - }), - ], - }), - ); - - assert.deepEqual(compactPrompt(model), [ - { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('steered earlier') }] }, - { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, - { role: 'user', content: [{ type: 'text', text: 'continue' }] }, - ]); - }); - test('a steer that equals the current prompt still injects its envelope', async () => { // Bare text is not an identity: deducting the steer against the verbatim // user prompt would drop the directive from the provider request entirely diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index dcdbe9415b..4a4639ee54 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -1200,7 +1201,8 @@ function checkpointEvent( sourceDigest: `digest-${ts}`, }, phase: 'pre_turn', - summary: 'Earlier context summary.', + summary: sectionedSummary('Earlier context summary.'), + summaryFormat: 'sections_v1', limitations: ['Estimated summary.'], estimatedTokens, }, diff --git a/packages/runtime/src/__tests__/continuation-replay.test.ts b/packages/runtime/src/__tests__/continuation-replay.test.ts index 3f305894ec..798db08877 100644 --- a/packages/runtime/src/__tests__/continuation-replay.test.ts +++ b/packages/runtime/src/__tests__/continuation-replay.test.ts @@ -27,8 +27,12 @@ import { import { buildContinuationReplayPlan, buildContinuationReplaySegment, + digestProviderReplayAdmission, } from '../continuation-replay.js'; -import { PROVIDER_REPLAY_PROJECTION_VERSION } from '../model-history.js'; +import { + PROVIDER_REPLAY_PROJECTION_VERSION, + type RuntimeEventModelReplayItem, +} from '../model-history.js'; describe('continuation replay segment', () => { it('rejects a persisted v1 admission under the route-bound v2 projection', () => { @@ -365,6 +369,34 @@ describe('continuation replay segment', () => { }); }); +describe('continuation replay digest', () => { + it('keeps the projection v2 digest compatible while excluding internal invocation identity', () => { + const digest = (invocationId: string) => + digestProviderReplayAdmission({ + providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, + targetProviderStateIdentity: undefined, + targetModelId: 'test-model', + items: [ + { + kind: 'tool_call', + invocationId, + toolCallId: 'read-1', + toolName: 'Read', + input: { path: 'notes.md' }, + eventId: 'call-1', + ts: 1, + } satisfies RuntimeEventModelReplayItem, + ], + }); + + assert.equal(digest('invocation-a'), digest('invocation-b')); + assert.equal( + digest('invocation-a'), + 'sha256:775dac9a0959d888541d9e4930431b60dee89e4f28b62238ddde64dfd5f542ee', + ); + }); +}); + function runtimeIdentity(): RuntimePrefixIdentityV1 { return { sessionId: 'session-1', diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 4bb9115a0f..37de5690e7 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -43,6 +43,7 @@ import { import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { @@ -1926,8 +1927,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-source', coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), - summary: 'The source turn called one opaque tool.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The source turn called one opaque tool.'), highWaterSeq: 3, }); const providerCheckpoint = buildHistoryCompactCheckpoint({ @@ -2401,8 +2401,7 @@ test('conversation copy rebuilds an inline checkpoint without legacy child event const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-source', coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), - summary: 'Both retained turns are complete.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Both retained turns are complete.'), highWaterSeq: 5, }); await runStore.appendEvent('session-source', 'run-2', { @@ -2516,8 +2515,7 @@ test('conversation copy drops a checkpoint from a superseded source policy inste const current = buildHistoryCompactCheckpoint({ sessionId: 'session-source', coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), - summary: 'Everything so far is complete.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Everything so far is complete.'), highWaterSeq: 5, }); const legacyPolicyCheckpoint = { @@ -2688,8 +2686,7 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-source', coveredRuntimeEvents: childSourceEvents, - summary: 'The resumed child retained both child turns.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The resumed child retained both child turns.'), highWaterSeq: 8, }); await runStore.appendEvent('session-source', 'run-child-2', { diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index ef9100d561..db635d0fcf 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -38,6 +38,7 @@ import { } from '../history-compact-ledger.js'; import { estimateRuntimeEventsTokens } from '../context-budget.js'; import { applyRuntimeEventHistoryCompact } from '../history-compaction.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; // Satisfies the sectioned summary contract for marked-checkpoint fixtures. const STRUCTURED_SUMMARY = [ @@ -206,8 +207,7 @@ describe('history compact checkpoint', () => { const v2 = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], - summary: 'text summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('text summary'), }); assert.equal(validateHistoryCompactCheckpointShape({ ...v2, providerState: {} }), false); assert.equal( @@ -235,8 +235,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'Continuation summary.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Continuation summary.'), now: 1_800_000_010_000, }); @@ -271,7 +270,6 @@ describe('history compact checkpoint', () => { sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], summary: ' ', - summaryFormat: 'legacy_freeform', }), /non-empty summary/, ); @@ -280,18 +278,22 @@ describe('history compact checkpoint', () => { test('preserves the complete model-produced summary instead of truncating it after generation', () => { const summary = [ '## Goal', - 'Keep every section intact.', + 'Keep every section intact.'.repeat(80), + '', + '## Progress', + '- done', + '', + '## Next Steps', + '1. continue', + '', '## Critical Context', 'LAST_REQUIRED_FACT', - ] - .join('\n') - .repeat(80); + ].join('\n'); const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], summary, - summaryFormat: 'legacy_freeform', }); assert.equal(checkpoint.summary, summary); @@ -304,8 +306,7 @@ describe('history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), { ...textEvent(1), sessionId: 'session-2' }], - summary: 'mixed source', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('mixed source'), }), /one session/, ); @@ -316,8 +317,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'source-bound', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('source-bound'), }); const invalid = { ...checkpoint, @@ -338,28 +338,24 @@ describe('history compact checkpoint', () => { const current = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'current', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('current'), }); const successor = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'smaller replacement', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('smaller replacement'), previousCheckpointId: current.checkpointId, }); const stale = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'stale replacement', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('stale replacement'), previousCheckpointId: 'another-checkpoint', }); const differentSource = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(2), textEvent(3)], - summary: 'different source', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('different source'), previousCheckpointId: current.checkpointId, }); @@ -377,15 +373,13 @@ describe('history compact checkpoint', () => { const first = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], - summary: 'first', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('first'), now: 10, }); const latest = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'latest', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('latest'), previousCheckpointId: first.checkpointId, now: 20, }); @@ -421,19 +415,17 @@ describe('history compact checkpoint', () => { ); }); - test('binds an automatic Memory boundary into checkpoint identity while legacy remains valid', () => { + test('binds an automatic Memory boundary into checkpoint identity', () => { const source = [textEvent(0)]; - const legacy = buildHistoryCompactCheckpoint({ + const manual = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'same summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same summary'), }); const automatic = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'same summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same summary'), memoryExtractionBoundary: { runId: 'run-1', turnId: 'turn-1', @@ -443,8 +435,7 @@ describe('history compact checkpoint', () => { const denied = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'same summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same summary'), memoryExtractionBoundary: { runId: 'run-1', turnId: 'turn-1', @@ -453,9 +444,9 @@ describe('history compact checkpoint', () => { }, }); - assert.notEqual(automatic.checkpointId, legacy.checkpointId); + assert.notEqual(automatic.checkpointId, manual.checkpointId); assert.notEqual(denied.checkpointId, automatic.checkpointId); - assert.equal(validateHistoryCompactCheckpointShape(legacy, 'session-1'), true); + assert.equal(validateHistoryCompactCheckpointShape(manual, 'session-1'), true); assert.equal(validateHistoryCompactCheckpointShape(automatic, 'session-1'), true); assert.equal( validateHistoryCompactCheckpointShape( @@ -521,53 +512,21 @@ describe('history compact checkpoint', () => { const valid = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'legacy summary without sections but complete.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('complete summary'), now: 10, }); // A truncated fragment that would otherwise win by coverage: the load gate // must drop it and fall back to the previous complete checkpoint (#3041). - const poisoned = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: [textEvent(0), textEvent(1), textEvent(2)], - summary: 'stops mid-thought...', - summaryFormat: 'legacy_freeform', - previousCheckpointId: valid.checkpointId, - now: 20, - }); - const runIds = ['run-valid', 'run-poisoned']; - const store = new StubAgentRunStore( - new Map([ - ['run-valid', [checkpointEvent('ledger-valid', 'run-valid', valid, 10)]], - ['run-poisoned', [checkpointEvent('ledger-poisoned', 'run-poisoned', poisoned, 20)]], - ]), - ); - - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( - store, - 'session-1', - runIds, - ); - - assert.equal(loaded?.checkpointId, valid.checkpointId); - }); - - test('uses the shared fence scan to quarantine only an unclosed legacy summary', async () => { - const valid = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'Legacy context:\n\n```ts\nconst ready = true;\n```', - summaryFormat: 'legacy_freeform', - now: 10, - }); - const poisoned = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: [textEvent(0), textEvent(1), textEvent(2)], - summary: 'Legacy context:\n\n```ts\nconst ready =', - summaryFormat: 'legacy_freeform', - previousCheckpointId: valid.checkpointId, - now: 20, - }); + const poisoned = { + ...buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [textEvent(0), textEvent(1), textEvent(2)], + summary: STRUCTURED_SUMMARY, + previousCheckpointId: valid.checkpointId, + now: 20, + }), + summary: '## Goal\nstops mid-thought...', + }; const runIds = ['run-valid', 'run-poisoned']; const store = new StubAgentRunStore( new Map([ @@ -585,20 +544,13 @@ describe('history compact checkpoint', () => { assert.equal(loaded?.checkpointId, valid.checkpointId); }); - test('stamps new text checkpoints with the sectioned format; legacy_freeform stays unmarked', () => { + test('stamps new text checkpoints with the sectioned format', () => { const stamped = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], summary: STRUCTURED_SUMMARY, }); assert.equal(stamped.version === 2 ? stamped.summaryFormat : undefined, 'sections_v1'); - const legacy = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: [textEvent(0)], - summary: 'legacy free-form summary', - summaryFormat: 'legacy_freeform', - }); - assert.equal(legacy.version === 2 ? legacy.summaryFormat : undefined, undefined); }); test('the builder refuses to mint the sectioned marker for unvalidated text', () => { @@ -631,11 +583,21 @@ describe('history compact checkpoint', () => { ); }); + test('shape validation rejects unmarked V2 checkpoints from 0.1.x', () => { + const stamped = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [textEvent(0)], + summary: STRUCTURED_SUMMARY, + }); + const { summaryFormat: _summaryFormat, ...unmarked } = stamped; + + assert.equal(validateHistoryCompactCheckpointShape(unmarked, 'session-1'), false); + }); + test('a marked checkpoint is held to the complete predicate at load', async () => { // A section-less summary written through a seam that bypassed the write // gates (direct recorder, older copy) but carrying the sectioned marker - // must never become authoritative again after restart; the unmarked - // legacy policy stays truncation-only. + // must never become authoritative again after restart. const valid = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], @@ -644,18 +606,16 @@ describe('history compact checkpoint', () => { }); // The builder refuses to mint the marker for unvalidated text, so a // malformed marked checkpoint can only exist as pre-existing durable data - // (or via a hand-rolled object) — modeled here by restamping a legacy - // build. + // (or via a hand-rolled object). const markedMalformed = { ...buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1), textEvent(2)], - summary: 'complete-sounding free-form prose without the mandated sections.', - summaryFormat: 'legacy_freeform', + summary: STRUCTURED_SUMMARY, previousCheckpointId: valid.checkpointId, now: 20, }), - summaryFormat: 'sections_v1' as const, + summary: 'complete-sounding free-form prose without the mandated sections.', }; const runIds = ['run-valid', 'run-marked']; const store = new StubAgentRunStore( @@ -678,16 +638,17 @@ describe('history compact checkpoint', () => { const valid = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], - summary: 'canonical complete summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('canonical complete summary'), }); const canonicalEvent = checkpointEvent('canonical-event', 'run-canonical', valid, 20); - const poisoned = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'projection fragment cut off:', - summaryFormat: 'legacy_freeform', - }); + const poisoned = { + ...buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [textEvent(0), textEvent(1)], + summary: STRUCTURED_SUMMARY, + }), + summary: '## Goal\nprojection fragment cut off:', + }; const poisonedProjection = checkpointEvent('projection-event', 'run-projection', poisoned, 30); const replacedEventIds: Array = []; const store = { @@ -717,14 +678,12 @@ describe('history compact checkpoint', () => { const furthest = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1), textEvent(2)], - summary: 'furthest coverage', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('furthest coverage'), }); const stale = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'stale coverage', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('stale coverage'), }); const runIds = ['run-furthest', 'run-stale']; const store = new StubAgentRunStore( @@ -748,23 +707,20 @@ describe('history compact checkpoint', () => { const first = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'first', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('first'), now: 10, }); const second = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'second', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('second'), previousCheckpointId: first.checkpointId, now: 20, }); const tip = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: source, - summary: 'tip', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('tip'), previousCheckpointId: second.checkpointId, now: 30, }); @@ -797,8 +753,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'bounded projection', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('bounded projection'), }); const projectedEvent = checkpointEvent('projection-event', 'run-projection', checkpoint, 20); const store = { @@ -834,8 +789,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0), textEvent(1)], - summary: 'recovered checkpoint', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('recovered checkpoint'), }); const event = checkpointEvent('recovered-event', 'run-recovered', checkpoint, 20); const repaired: Array = []; @@ -866,8 +820,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], - summary: 'recovered checkpoint', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('recovered checkpoint'), }); const event = checkpointEvent('recovered-event', 'run-recovered', checkpoint, 20); let repaired = false; @@ -891,8 +844,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [textEvent(0)], - summary: 'canonical checkpoint', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('canonical checkpoint'), }); const canonicalEvent = checkpointEvent('canonical-event', 'run-canonical', checkpoint, 20); const invalidProjection = { @@ -951,8 +903,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events.slice(0, 4), - summary: 'checkpoint summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('checkpoint summary'), }); const replay = applyRuntimeEventHistoryCompact(events, { @@ -983,8 +934,7 @@ describe('history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events.slice(0, 4), - summary: 'recovery checkpoint summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('recovery checkpoint summary'), }); // The raw history is deliberately small. Once a durable diff --git a/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts index 99b04fad39..a1cf96236c 100644 --- a/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts @@ -28,6 +28,7 @@ import { validateHistoryCompactCheckpointShape, } from '../history-compact-checkpoint.js'; import { applyRuntimeEventHistoryCompact } from '../history-compaction.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; describe('mid-turn history compact checkpoint', () => { test('builds a mid_turn checkpoint that re-renders the covered head anchor verbatim', () => { @@ -41,8 +42,7 @@ describe('mid-turn history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'Prior work plus the current turn opening.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('Prior work plus the current turn opening.'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, now: 1_800_000_010_000, @@ -84,8 +84,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', }), /requires a head anchor/, @@ -95,8 +94,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'missing', turnId: 'turn-1' }, }), @@ -112,8 +110,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'a', turnId: 'turn-9' }, }), @@ -125,8 +122,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'b', turnId: 'turn-1' }, }), @@ -150,8 +146,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'prior-user', turnId: 'turn-0' }, }), @@ -163,8 +158,7 @@ describe('mid-turn history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'mid turn summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('mid turn summary'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, }); @@ -190,8 +184,7 @@ describe('mid-turn history compact checkpoint', () => { buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'x', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('x'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'a', turnId: 'turn-1' }, }), @@ -208,8 +201,7 @@ describe('mid-turn history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events.slice(0, 2), - summary: 'mid turn summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('mid turn summary'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, }); @@ -251,15 +243,13 @@ describe('mid-turn history compact checkpoint', () => { const implicit = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'same', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same'), now: 5, }); const explicit = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'same', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same'), phase: 'pre_turn', now: 5, }); @@ -269,8 +259,7 @@ describe('mid-turn history compact checkpoint', () => { const mid = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events, - summary: 'same', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('same'), now: 5, phase: 'mid_turn', headAnchor: { runtimeEventId: 'a', turnId: 'turn-0' }, @@ -289,8 +278,7 @@ describe('mid-turn history compact checkpoint', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: events.slice(0, 4), - summary: 'mid turn summary', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('mid turn summary'), phase: 'mid_turn', headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, }); diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts index 9d93700b86..158a41f336 100644 --- a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -38,6 +38,7 @@ import { } from '../history-compact-summarizer.js'; import { buildHistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; import { SUMMARY_FORMAT_TEMPLATE } from '../history-compact-summary-validation.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; const ts = 1_700_000_000_000; let __seq = 0; @@ -1242,8 +1243,7 @@ describe('buildLlmHistorySummarizer', () => { const previousCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'sess-1', coveredRuntimeEvents: [old], - summary: 'PRIOR_SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('PRIOR_SUMMARY'), }); const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', @@ -1356,8 +1356,7 @@ describe('buildLlmHistorySummarizer', () => { const previousCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'sess-1', coveredRuntimeEvents: [old], - summary: 'PRIOR_SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('PRIOR_SUMMARY'), }); const input = inputWith([old, newer]); diff --git a/packages/runtime/src/__tests__/history-compact-test-fixtures.ts b/packages/runtime/src/__tests__/history-compact-test-fixtures.ts new file mode 100644 index 0000000000..3041927a36 --- /dev/null +++ b/packages/runtime/src/__tests__/history-compact-test-fixtures.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export function sectionedSummary(goal: string): string { + return `## Goal\n${goal}\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)`; +} diff --git a/packages/runtime/src/__tests__/memory-extraction.test.ts b/packages/runtime/src/__tests__/memory-extraction.test.ts index 5d9eb07b69..e420c331c2 100644 --- a/packages/runtime/src/__tests__/memory-extraction.test.ts +++ b/packages/runtime/src/__tests__/memory-extraction.test.ts @@ -22,6 +22,7 @@ import { describe, test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { buildHistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import { bindProviderVisibleEvidence, projectMemoryExtractionEvidence, @@ -204,8 +205,7 @@ describe('bounded Memory Extraction', () => { const previousCheckpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: [compacted], - summary: 'The old summary remains interpretation context.', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('The old summary remains interpretation context.'), }); const source = buildMemoryCompactionSourceContext( [ diff --git a/packages/runtime/src/__tests__/model-history-timeline.test.ts b/packages/runtime/src/__tests__/model-history-timeline.test.ts new file mode 100644 index 0000000000..4811cd4afb --- /dev/null +++ b/packages/runtime/src/__tests__/model-history-timeline.test.ts @@ -0,0 +1,224 @@ +/* + * 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 { + buildRuntimeEventModelReplayPlan, + buildRuntimeEventReplayTimeline, +} from '../model-history.js'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +test('model history keeps reused step ids as separate chronological segments', () => { + const items = buildRuntimeEventModelReplayPlan([ + assistantText('text-a', 'shared-step', 'Text A'), + assistantText('text-b', 'intervening-step', 'Text B'), + toolCall('call', 'shared-step'), + toolResult('result'), + ]).items; + + assert.deepEqual( + buildRuntimeEventReplayTimeline(items).map((entry) => { + if (entry.kind !== 'assistant_step') return entry.kind; + return { + stepId: entry.stepId, + text: entry.text?.content, + calls: entry.calls.map(({ call, result }) => ({ + id: call.toolCallId, + settled: result !== undefined, + })), + }; + }), + [ + { stepId: 'shared-step', text: 'Text A', calls: [] }, + { stepId: 'intervening-step', text: 'Text B', calls: [] }, + { stepId: 'shared-step', text: undefined, calls: [{ id: 'read-1', settled: true }] }, + ], + ); +}); + +test('model history separates settled legacy calls but keeps overlapping calls together', () => { + const settledItems = buildRuntimeEventModelReplayPlan([ + toolCall('call-1', undefined, 'read-1'), + toolResult('result-1', 'read-1', 'first'), + toolCall('call-2', undefined, 'read-2'), + toolResult('result-2', 'read-2', 'second'), + ]).items; + const overlappingItems = buildRuntimeEventModelReplayPlan([ + toolCall('call-1', undefined, 'read-1'), + toolCall('call-2', undefined, 'read-2'), + toolResult('result-1', 'read-1', 'first'), + toolResult('result-2', 'read-2', 'second'), + ]).items; + + assert.deepEqual( + buildRuntimeEventReplayTimeline(settledItems).map((entry) => + entry.kind === 'assistant_step' ? entry.calls.map(({ call }) => call.toolCallId) : [], + ), + [['read-1'], ['read-2']], + ); + assert.deepEqual( + buildRuntimeEventReplayTimeline(overlappingItems).map((entry) => + entry.kind === 'assistant_step' ? entry.calls.map(({ call }) => call.toolCallId) : [], + ), + [['read-1', 'read-2']], + ); +}); + +test('model history pairs reused tool call ids by durable occurrence', () => { + const items = buildRuntimeEventModelReplayPlan([ + toolCall('call-1', 'step-1'), + toolResult('result-1', 'read-1', 'first'), + toolCall('call-2', 'step-2'), + toolResult('result-2', 'read-1', 'second'), + ]).items; + + assert.deepEqual( + buildRuntimeEventReplayTimeline(items).flatMap((entry) => + entry.kind === 'assistant_step' ? entry.calls.map(({ result }) => result?.output) : [], + ), + ['first', 'second'], + ); +}); + +test('model history does not pair an orphan result with a later reused call id', () => { + const items = buildRuntimeEventModelReplayPlan([ + toolResult('orphan-result', 'read-1', 'orphan'), + toolCall('call', 'step-1'), + toolResult('matching-result', 'read-1', 'matching'), + ]).items; + + assert.deepEqual( + buildRuntimeEventReplayTimeline(items).flatMap((entry) => + entry.kind === 'assistant_step' ? entry.calls.map(({ result }) => result?.output) : [], + ), + ['matching'], + ); +}); + +test('model history scopes reused tool call ids to their invocation', () => { + const callA = inInvocation(toolCall('call-a', 'shared-step'), 'a'); + const callB = inInvocation(toolCall('call-b', 'shared-step'), 'b'); + const resultB = inInvocation(toolResult('result-b', 'read-1', 'second'), 'b'); + + const plan = buildRuntimeEventModelReplayPlan([callA, callB, resultB]); + + assert.deepEqual( + plan.diagnostics + .filter(({ code }) => code === 'unmatched_tool_call') + .map(({ eventId }) => eventId), + ['call-a'], + ); + assert.deepEqual( + buildRuntimeEventReplayTimeline(plan.items).flatMap((entry) => + entry.kind === 'assistant_step' + ? entry.calls.map(({ call, result }) => ({ + call: call.eventId, + result: result?.eventId, + })) + : [], + ), + [{ call: 'call-b', result: 'result-b' }], + ); +}); + +test('model history keeps reused step ids separate across invocations', () => { + const textA = inInvocation(assistantText('text-a', 'shared-step', 'First invocation text'), 'a'); + const callB = inInvocation(toolCall('call-b', 'shared-step'), 'b'); + const resultB = inInvocation(toolResult('result-b'), 'b'); + + const items = buildRuntimeEventModelReplayPlan([textA, callB, resultB]).items; + + assert.deepEqual( + buildRuntimeEventReplayTimeline(items).map((entry) => { + if (entry.kind !== 'assistant_step') return entry.kind; + return { + text: entry.text?.eventId, + calls: entry.calls.map(({ call }) => call.eventId), + }; + }), + [ + { text: 'text-a', calls: [] }, + { text: undefined, calls: ['call-b'] }, + ], + ); +}); + +function inInvocation(event: RuntimeEvent, suffix: string): RuntimeEvent { + return { + ...event, + invocationId: `invocation-${suffix}`, + runId: `run-${suffix}`, + turnId: `turn-${suffix}`, + }; +} + +function assistantText(id: string, stepId: string, text: string): RuntimeEvent { + return event({ + id, + role: 'model', + author: 'agent', + refs: { providerEventId: stepId }, + content: { kind: 'text', text }, + }); +} + +function toolCall(id: string, stepId?: string, toolCallId = 'read-1'): RuntimeEvent { + return event({ + id, + role: 'model', + author: 'agent', + refs: stepId ? { stepId } : undefined, + content: { + kind: 'function_call', + id: toolCallId, + name: 'Read', + args: { path: 'notes.md' }, + }, + }); +} + +function toolResult(id: string, toolCallId = 'read-1', result = 'contents'): RuntimeEvent { + return event({ + id, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: toolCallId, + name: 'Read', + result, + }, + }); +} + +function event( + input: Pick & + Partial>, +): RuntimeEvent { + return { + ...input, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + }; +} diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index a778ba50c6..36a3b8e2e9 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -30,6 +30,7 @@ import { z } from 'zod'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import { AiSdkBackend } from '../ai-sdk-backend.js'; import { LATEST_CONTEXT_PROJECTION_TYPE, @@ -1265,8 +1266,7 @@ describe('reactive overflow recovery in the streaming backend', () => { const checkpoint = buildHistoryCompactCheckpoint({ sessionId: 'session-1', coveredRuntimeEvents: fixture.priorEvents, - summary: 'EARLIER_TURN_SUMMARY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('EARLIER_TURN_SUMMARY'), }); carried = checkpoint; await runTurn(fixture); @@ -1295,8 +1295,7 @@ describe('reactive overflow recovery in the streaming backend', () => { coveredRuntimeEvents: [ runtimeTextEvent('never-happened', 'turn-x', 'user', 'AN EVENT THIS LEDGER NEVER HELD'), ], - summary: 'SUMMARY_OF_ANOTHER_HISTORY', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('SUMMARY_OF_ANOTHER_HISTORY'), }); const fixture = buildReactiveFixture({ script: ['done'], diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 3acbdb5837..4aff33d521 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -18,6 +18,7 @@ */ import { nextId } from '@maka/core/test-only/async-primitives'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; @@ -2355,9 +2356,9 @@ describe('SessionManager child-session runtime primitive', () => { childContext?.tools?.map((tool) => tool.name), ['Read', 'Glob', 'Grep'], ); - assert.deepStrictEqual( + assert.strictEqual( backendsBySession.get(result.childSessionId)?.sendInputs[0]?.context, - [], + undefined, ); assert.strictEqual( backendsBySession @@ -8956,13 +8957,9 @@ describe('SessionManager permission mode updates', () => { secondInput.runtimeContext?.map((event) => event.turnId), ['turn-1', 'turn-1', 'turn-1', 'turn-1'], ); - const turnState = secondInput.context.find( - (message) => message.type === 'turn_state' && message.turnId === 'turn-1', - ); - if (turnState?.type !== 'turn_state') - throw new Error('prior failed turn_state was not projected'); - assert.strictEqual(turnState.status, 'failed'); - assert.strictEqual(turnState.errorClass, 'tool_failed'); + const failed = secondInput.runtimeContext?.find((event) => event.status === 'failed'); + assert.strictEqual(failed?.actions?.stateDelta?.failureClass, 'tool_failed'); + assert.strictEqual(secondInput.context, undefined); }); test('next parent turn excludes child run RuntimeEvents from model context', async () => { @@ -9049,12 +9046,7 @@ describe('SessionManager permission mode updates', () => { secondInput.runtimeContext?.some((event) => event.turnId === 'child-turn'), false, ); - assert.strictEqual( - secondInput.context.some( - (message) => message.type === 'user' && message.turnId === 'child-turn', - ), - false, - ); + assert.strictEqual(secondInput.context, undefined); }); test('stopSession owns an active Run and a parent turn admitted before reservation', async () => { @@ -11098,8 +11090,7 @@ describe('SessionManager permission mode updates', () => { content: { kind: 'text', text: `source ${index}` }, }), ), - summary: 'durable checkpoint before projection loss', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('durable checkpoint before projection loss'), }); const durableEvent = makeRunEvent({ sessionId: session.id, @@ -12535,8 +12526,7 @@ class HistoryCompactCheckpointBackend implements AgentBackend { content: { kind: 'text', text: 'source' }, }, ], - summary: 'persist the bounded checkpoint', - summaryFormat: 'legacy_freeform', + summary: sectionedSummary('persist the bounded checkpoint'), }); this.ctx.recordHistoryCompactCheckpoint?.( { ...checkpoint, checkpointId: 'hcheckpoint-test' }, @@ -12588,8 +12578,7 @@ class SameCoverageCheckpointReplacementProbeBackend implements AgentBackend { buildHistoryCompactCheckpoint({ sessionId: this.sessionId, coveredRuntimeEvents, - summary: `${input.turnId} summary`, - summaryFormat: 'legacy_freeform', + summary: sectionedSummary(`${input.turnId} summary`), ...(current ? { previousCheckpointId: current.checkpointId } : {}), }), input.turnId, @@ -12647,8 +12636,7 @@ class CheckpointRecorderContractProbeBackend implements AgentBackend { buildHistoryCompactCheckpoint({ sessionId: this.sessionId, coveredRuntimeEvents, - summary: `${input.turnId} checkpoint`, - summaryFormat: 'legacy_freeform', + summary: sectionedSummary(`${input.turnId} checkpoint`), }), input.turnId, ); diff --git a/packages/runtime/src/__tests__/session-recap.test.ts b/packages/runtime/src/__tests__/session-recap.test.ts index f717047340..2a7d9d4fcf 100644 --- a/packages/runtime/src/__tests__/session-recap.test.ts +++ b/packages/runtime/src/__tests__/session-recap.test.ts @@ -66,13 +66,14 @@ test('session recap keeps bounded evidence from one oversized latest turn', () = assert.ok(serialized.length <= 13_000); }); -test('session recap bounds an oversized tool result without splitting its protocol pair', () => { +test('session recap includes a concise durable outcome without tool protocol or raw output', () => { const oversizedOutput = 'tool-output-sentinel '.repeat(2_000); + const durableOutcome = 'state.txt says ready=true'; const messages = buildSessionRecapMessages({ events: [ textEvent('latest-user', 'turn-1', 'user', 'Inspect the current state.'), toolCallEvent('call-event', 'call-1', 'turn-1'), - toolResultEvent('result-event', 'call-1', 'turn-1', oversizedOutput), + toolResultEvent('result-event', 'call-1', 'turn-1', oversizedOutput, durableOutcome), ], connection: connection(), modelId: 'gpt-4', @@ -87,15 +88,75 @@ test('session recap bounds an oversized tool result without splitting its protoc .filter((part) => part.type === 'tool-call' || part.type === 'tool-result') .map((part) => ({ type: part.type, toolCallId: part.toolCallId })), ), - [ - { type: 'tool-call', toolCallId: 'call-1' }, - { type: 'tool-result', toolCallId: 'call-1' }, - ], + [], ); + assert.equal(serialized.includes(durableOutcome), true); assert.equal(serialized.includes(oversizedOutput), false); assert.ok(serialized.length <= 13_000); }); +test('session recap budgets only the evidence it sends', () => { + const earlierSentinel = 'EARLIER-RECAP-SENTINEL'; + const oversizedArgs = 'tool-args-sentinel '.repeat(4_000); + const call = toolCallEvent('call-event', 'call-1', 'turn-2'); + const messages = buildSessionRecapMessages({ + events: [ + textEvent('earlier-user', 'turn-1', 'user', earlierSentinel), + textEvent('latest-user', 'turn-2', 'user', 'Inspect the current state.'), + { + ...call, + content: { + kind: 'function_call', + id: 'call-1', + name: 'Read', + args: { query: oversizedArgs }, + }, + }, + toolResultEvent('result-event', 'call-1', 'turn-2', 'raw output', 'state is ready'), + ], + connection: { + ...connection(), + defaultModel: 'declared-16k-model', + relayModelProfiles: { 'declared-16k-model': { contextWindow: 16_384 } }, + }, + modelId: 'declared-16k-model', + }); + const serialized = JSON.stringify(messages); + + assert.equal(serialized.includes(earlierSentinel), true); + assert.equal(serialized.includes(oversizedArgs), false); +}); + +test('session recap excludes model-hidden tool outcomes', () => { + const messages = buildSessionRecapMessages({ + events: [ + { + ...toolResultEvent( + 'hidden-result', + 'nested-call', + 'turn-1', + 'raw nested output', + 'internal nested outcome', + ), + modelVisibility: 'hidden', + }, + ], + connection: { + ...connection(), + defaultModel: 'declared-4k-model', + relayModelProfiles: { 'declared-4k-model': { contextWindow: 4_096 } }, + }, + modelId: 'declared-4k-model', + }); + + assert.deepEqual(messages, [ + { + role: 'user', + content: SESSION_RECAP_INSTRUCTION, + }, + ]); +}); + test('session recap treats a zero evidence budget as no evidence, not unbounded input', () => { const sentinel = 'ZERO-BUDGET-SENTINEL'; const messages = buildSessionRecapMessages({ @@ -153,7 +214,13 @@ function toolCallEvent(id: string, callId: string, turnId: string): RuntimeEvent }; } -function toolResultEvent(id: string, callId: string, turnId: string, result: string): RuntimeEvent { +function toolResultEvent( + id: string, + callId: string, + turnId: string, + result: string, + durableOutcome: string, +): RuntimeEvent { return { id, sessionId: 'session-1', @@ -164,7 +231,13 @@ function toolResultEvent(id: string, callId: string, turnId: string, result: str partial: false, role: 'tool', author: 'tool', - content: { kind: 'function_response', id: callId, name: 'Read', result }, + content: { + kind: 'function_response', + id: callId, + name: 'Read', + result, + modelProjection: { version: 1, kind: 'text', text: durableOutcome }, + }, }; } diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 7f119bfda3..ff919edf1d 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -74,7 +74,6 @@ import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; import type { RunTraceEvent } from './run-trace.js'; import type { StopSessionInput } from './session-manager.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; -import { projectRuntimeEventsToStoredMessages } from './runtime-event-read-model.js'; import { buildPriorRuntimeContext as buildPriorRuntimeContextProjection, type PriorRuntimeContext, @@ -704,11 +703,6 @@ export class AgentRun { await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); const priorRuntimeContext = await this.buildPriorRuntimeContext(); - const projectionContext = priorRuntimeContext - ? projectRuntimeEventsToStoredMessages(priorRuntimeContext.events, { - invocations: priorRuntimeContext.invocations, - }).messages - : []; return { backend: this.active.backend, @@ -727,7 +721,6 @@ export class AgentRun { ? { directoryReferences: this.input.userInput.directoryReferences } : {}), ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), - context: projectionContext, ...(priorRuntimeContext ? { runtimeContext: priorRuntimeContext.events, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..2a065fffb2 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -18,59 +18,14 @@ */ /** - * AiSdkBackend — single backend for all LLM providers via Vercel AI SDK. - * - * Provides one `streamText` API across Anthropic / OpenAI / Google / DeepSeek / - * OpenAI-compatible endpoints, while keeping all of our home-grown - * machinery: session sandbox boundaries, materializer, AsyncEventQueue, - * SessionStore SQLite persistence. - * - * Maka owns the agent loop. Each ModelAdapter call performs exactly one - * provider request; returned tool calls settle through ToolRuntime, become - * durable, and are reloaded before the next provider request. - * - * Design: - * send() - * ├─ build AsyncEventQueue - * ├─ resolve LanguageModelV2 via deps.modelFactory(connection, modelId) - * ├─ expose schema-only tools to the provider - * ├─ background task: project → stream one step → settle → reload - * └─ yield from queue + * Session-level AI SDK backend. It wires provider, compaction, projection, + * telemetry, and tool services once, then delegates each `send()` to an + * isolated AiSdkTurn. Provider-message construction and turn execution live in + * their own modules; this file owns composition and cross-turn control only. */ -import type { - SessionEvent, - CompleteEvent, - AbortEvent, - ErrorEvent, - TextCompleteEvent, - ThinkingCompleteEvent, - TokenUsageEvent, - TextDeltaEvent, - ThinkingDeltaEvent, - ProviderRetryEvent, - ProviderRetryReason, - ToolResultEvent, - ToolResultContent, - ToolStartEvent, - StorageRef, - AttachmentRef, - DirectoryReference, - QuoteRef, -} from '@maka/core/events'; -import type { - StoredMessage, - AssistantMessage, - AssistantStepContentKind, - AssistantThinkingPart, - ToolCallMessage, - ToolResultMessage, - PermissionDecisionMessage, - TokenUsageMessage, - SystemNoteMessage, - BackendKind, - SessionHeader, -} from '@maka/core/session'; +import type { SessionEvent } from '@maka/core/events'; +import type { BackendKind, SessionHeader, StoredMessage } from '@maka/core/session'; import type { AgentBackend, BackendCompactHistoryInput, @@ -78,169 +33,37 @@ import type { BackendSendInput, HostedInteractionBridge, } from '@maka/core/backend-types'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; -import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; -import { - resolveEffectiveOrchestration, - type EffectiveOrchestration, -} from '@maka/core/orchestration'; -import type { PlanToolResult } from './plan-tools.js'; -import { - bindToolResultArchiveDecoder, - type ToolResultArchiveCapability, -} from './tool-result-archive-capability.js'; -import { - YIELD_AGENT_GRAPH_TOOL_NAME, - type YieldAgentGraphToolResult, -} from './stream-graph-supervisor-tools.js'; +import type { EffectiveOrchestration } from '@maka/core/orchestration'; import type { AttachmentByteReader } from '@maka/core/attachments'; -import { - MAX_PROVIDER_IMAGE_REQUEST_BYTES, - PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE, -} from '@maka/core/attachments'; -import { stripUndefinedDeep } from '@maka/core/tool-args-identity'; import { pricingModelKey } from '@maka/core/usage-stats/pricing'; -import type { - LlmCallRecord, - PricingConfig, - ToolInvocationRecord, -} from '@maka/core/usage-stats/types'; -import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; -import type { - JSONValue, - ModelFinishReason, - ModelMessage, - ModelStepOutcome, - ReasoningPart, - ModelFailure, - ModelToolSet, - NormalizedUsage, - ModelFailureKind, - ToolCallPart, - ToolResultOutput, - UserContent, -} from './model-protocol.js'; +import type { PricingConfig, ToolInvocationRecord } from '@maka/core/usage-stats/types'; import type { ModelCallCommit } from '@maka/core/agent-run'; -import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; -import Ajv2019 from 'ajv/dist/2019.js'; -import Ajv2020 from 'ajv/dist/2020.js'; -import { z } from 'zod'; +import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; -import { AsyncEventQueue } from './async-queue.js'; import { AdmissionLimiter } from './admission-limiter.js'; import { - type CodeModeExecutionResult, - DEFAULT_CODE_MODE_EXECUTION_POLICY, - executeCodeCell, -} from './code-mode.js'; -import { - StreamWatchdog, - formatStreamWatchdogError, - type StreamWatchdogInput, - type StreamWatchdogPhase, -} from './stream-watchdog.js'; -import { - MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN, - MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, - TOOL_ERROR_RESULT_MAX_CHARS, ToolRuntime, - formatSyntheticToolErrorText, - formatToolArgsViolationText, - isRuntimeCommitBoundaryError, type MakaTool, type MakaToolContext, - type DurableSessionEventSink, type ToolRuntimeInput, } from './tool-runtime.js'; import type { RuntimeCommitSink } from './runtime-commit-sink.js'; -import { - ModelAdapter, - type ModelFactoryInput, - type NormalizedAiSdkUsage, - type ModelStreamResult, - type RepairableAiSdkToolCall, -} from './model-adapter.js'; +import { ModelAdapter, type NormalizedAiSdkUsage } from './model-adapter.js'; import { buildProviderOptions } from './model-factory.js'; -import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; -import { nonCanonicalContentOrder } from './runtime-event-read-model.js'; -import { - composeRequestProjection, - type DispatchRequestShape, - type RequestProjection, - type RequestProjectionContext, - type RequestProjectionStage, -} from './request-projection.js'; -import { - decodePlaintextResponsesReasoningState, - replayPlaintextResponsesProviderOptions, - responsesReasoningItemId, -} 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 { - contextDiagnosticsCompactionOf, - type ContextDiagnosticsCompaction, -} from './context-diagnostics.js'; -import { - AiSdkCompaction, - hasActiveToolResultPruneDiagnosticPatch, - hasBlockingReplayDiagnostics, -} from './ai-sdk-compaction.js'; +import type { StreamWatchdogInput } from './stream-watchdog.js'; +import { AiSdkCompaction } from './ai-sdk-compaction.js'; import type { AiSdkCompactionCapabilities } from './ai-sdk-compaction-contract.js'; import type { ToolArtifactRecorder } from './tool-artifacts.js'; -import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; -import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; -import { openAiChatReasoningFieldFromProviderOptions } from './openai-chat-reasoning-transport.js'; -import { RunTrace, type RunTraceRecorder } from './run-trace.js'; -import { SandboxCommandError } from './sandbox/errors.js'; -import { - REQUEST_SANDBOX_BOUNDARY_TOOL_NAME, - SANDBOX_BOUNDARY_DENIED_FOR_TURN, - SANDBOX_BOUNDARY_FINALIZATION_PROMPT, -} from './sandbox-boundary-tool.js'; -import { computeCost } from './telemetry/cost.js'; +import type { RunTraceRecorder } from './run-trace.js'; import { getBuiltinPricing } from './telemetry/builtin-pricing.js'; -import { - admitProviderReasoningReplayItems, - buildRuntimeEventModelReplayPlan, - buildSteeringEnvelope, - collectToolActivityTurnIds, - compatibleProviderReasoningReplayEventIds, - formatTextWithInlineRefs, - steeringMessagesMissingFromBase, - steeringModelMessage, - steeringProviderOptions, - stripSteeringMessages, - type RuntimeEventModelReplayItem, - type RuntimeEventModelReplayPlan, - type RuntimeEventReplayFallbackGate, -} from './model-history.js'; -import { toolSchemaCharsForDiagnostics } from './request-shape.js'; -import type { ModelCallAttempt, ModelCallKind } from '@maka/core/model-call-attempt'; -import { - ProviderRequestTracker, - type ModelCallAccountingInput, - type ProviderRequestUsage, - type ResolvedModelCallCost, -} from './provider-request-telemetry.js'; -import { - ToolAvailabilityRuntime, - type ToolAvailabilityConfig, - type ToolAvailabilityPlan, -} from './tool-availability.js'; -import { renderSwarmModePrompt } from './swarm-mode.js'; -import { renderGraphModePrompt } from './graph-mode.js'; +import { ProviderRequestTelemetry } from './provider-request-telemetry.js'; +import { AiSdkMessageProjection } from './ai-sdk-message-projection.js'; +import { AiSdkTurn, type AiSdkSessionState } from './ai-sdk-turn.js'; +import { buildInvalidMakaTool } from './ai-sdk-tool-repair.js'; +import { ToolAvailabilityRuntime, type ToolAvailabilityConfig } from './tool-availability.js'; import { MEMORY_EXTRACT_TOOL_NAME, MEMORY_REMEMBER_TOOL_NAME, @@ -250,34 +73,8 @@ import { type MemoryExtractionTrigger, } from './memory-extraction.js'; import { modelUsesNativeOpenAiResponses, resolveModelRuntime } from './model-runtime.js'; -import { - applyPatchReplayFactText, - normalizeApplyPatchReplayInput, - routeApplyPatchTools, - type ApplyPatchProfile, -} from './apply-patch-profile.js'; -import { - applyRuntimeEventContextBudget, - buildContextBudgetDiagnosticShell, - estimateRuntimeEventsTokens, - mergeContextBudgetDiagnostic, - mergeContextBudgetDiagnosticPatches, - minimalContextBudgetDiagnostic, - shouldAppendContextCompactedNote, - shouldAppendContextCompactionFailedOpenNote, - type ContextBudgetPolicy, -} from './context-budget.js'; -import { isHistoryCompactContentEvent } from './history-compaction.js'; -import { - canContinueHistoryCompactCheckpointForModel, - historyCompactCheckpointToModelMessage, - historyCompactCheckpointToRuntimeEvent, - isProviderHistoryCompactCheckpoint, - isTextHistoryCompactCheckpoint, - matchHistoryCompactCheckpointPrefix, - projectHistoryCompactCheckpointReplay, - type HistoryCompactCheckpoint, -} from './history-compact-checkpoint.js'; +import { routeApplyPatchTools } from './apply-patch-profile.js'; +import { bindToolResultArchiveDecoder } from './tool-result-archive-capability.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; export { DEFAULT_PERMISSION_TIMEOUT_MS, @@ -294,395 +91,14 @@ export type { } from './model-adapter.js'; export type { RunTraceEvent, RunTraceRecorder } from './run-trace.js'; -const CHILD_STEP_BUDGET_FINALIZATION_PROMPT = [ - '', - 'This is the final budgeted step for this child-agent turn.', - 'Do not call tools. Return the best concise final answer now using evidence already gathered.', - 'Clearly separate verified findings from inference and explicitly name any remaining gaps.', - '', -].join('\n'); - -function providerToolResultContent( - toolName: string, - output: unknown, - input?: unknown, -): ToolResultContent { - if (output === undefined) { - return { - kind: 'text', - text: `${toolName} completed without a structured result.`, - }; - } - if (toolName !== 'WebSearch') { - return { kind: 'json', value: output }; - } - const queryFromInput = providerWebSearchQuery(input); - if (Array.isArray(output)) { - const rows: Array<{ - title: string; - url: string; - snippet: string; - source: string; - }> = []; - for (const result of output) { - if ( - !result || - typeof result !== 'object' || - (result as { type?: unknown }).type !== 'web_search_result' || - typeof (result as { url?: unknown }).url !== 'string' - ) { - continue; - } - const item = result as { - url: string; - title?: unknown; - pageAge?: unknown; - }; - try { - const parsed = new URL(item.url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; - rows.push({ - title: typeof item.title === 'string' && item.title.trim() ? item.title : parsed.hostname, - url: parsed.toString(), - snippet: typeof item.pageAge === 'string' ? item.pageAge : '', - source: parsed.hostname, - }); - } catch { - // Provider source rows are untrusted; malformed URLs are dropped. - } - } - return { - kind: 'web_search', - provider: 'model', - query: queryFromInput, - rows, - }; - } - if (!output || typeof output !== 'object') return { kind: 'json', value: output }; - const providerError = output as { type?: unknown; errorCode?: unknown }; - if ( - providerError.type === 'web_search_tool_result_error' || - typeof providerError.errorCode === 'string' - ) { - return { - kind: 'web_search_error', - ok: false, - provider: 'model', - ...(queryFromInput ? { query: queryFromInput } : {}), - reason: 'provider_error', - message: - typeof providerError.errorCode === 'string' - ? `Provider web search failed: ${providerError.errorCode}` - : 'Provider web search failed.', - }; - } - const action = (output as { action?: unknown }).action; - const sources = (output as { sources?: unknown }).sources; - let query = queryFromInput; - if (action && typeof action === 'object') { - const value = action as { - type?: unknown; - query?: unknown; - queries?: unknown; - }; - if (Array.isArray(value.queries)) { - query = value.queries.filter((item): item is string => typeof item === 'string').join(' | '); - } else if (typeof value.query === 'string') { - query = value.query; - } - } - const rows: Array<{ - title: string; - url: string; - snippet: string; - source: string; - }> = []; - if (Array.isArray(sources)) { - for (const source of sources) { - if ( - !source || - typeof source !== 'object' || - (source as { type?: unknown }).type !== 'url' || - typeof (source as { url?: unknown }).url !== 'string' - ) { - continue; - } - const url = (source as { url: string }).url; - try { - const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; - rows.push({ - title: parsed.hostname, - url: parsed.toString(), - snippet: '', - source: parsed.hostname, - }); - } catch { - // Provider source rows are untrusted; malformed URLs are dropped. - } - } - } - return { kind: 'web_search', provider: 'model', query, rows }; -} - -function providerWebSearchQuery(input: unknown): string { - let value = input; - if (typeof input === 'string') { - try { - value = JSON.parse(input); - } catch { - return ''; - } - } - if (!value || typeof value !== 'object') return ''; - const query = (value as { query?: unknown }).query; - return typeof query === 'string' ? query : ''; -} - -function mergeTextProviderOptions( - current: NonNullable | undefined, - next: NonNullable, - textOffset: number, -): NonNullable { - const shifted = structuredClone(next); - const shiftedOpenAi = shifted.openai; - if (shiftedOpenAi && typeof shiftedOpenAi === 'object' && !Array.isArray(shiftedOpenAi)) { - const annotations = (shiftedOpenAi as { annotations?: unknown }).annotations; - if (Array.isArray(annotations) && textOffset > 0) { - (shiftedOpenAi as { annotations: unknown[] }).annotations = annotations.map((annotation) => { - if (!annotation || typeof annotation !== 'object' || Array.isArray(annotation)) { - return annotation; - } - const value = { ...annotation } as Record; - if (typeof value.startIndex === 'number') value.startIndex += textOffset; - if (typeof value.endIndex === 'number') value.endIndex += textOffset; - if (typeof value.start_index === 'number') value.start_index += textOffset; - if (typeof value.end_index === 'number') value.end_index += textOffset; - return value; - }); - } - } - if (!current) return shifted; - - const merged = { ...structuredClone(current), ...shifted }; - const currentOpenAi = current.openai; - if ( - currentOpenAi && - typeof currentOpenAi === 'object' && - !Array.isArray(currentOpenAi) && - shiftedOpenAi && - typeof shiftedOpenAi === 'object' && - !Array.isArray(shiftedOpenAi) - ) { - const left = currentOpenAi as Record; - const right = shiftedOpenAi as Record; - const openai: Record = { ...left, ...right }; - const leftAnnotations = Array.isArray(left.annotations) ? left.annotations : []; - const rightAnnotations = Array.isArray(right.annotations) ? right.annotations : []; - if (leftAnnotations.length > 0 || rightAnnotations.length > 0) { - openai.annotations = [...leftAnnotations, ...rightAnnotations]; - } - if ( - typeof left.itemId === 'string' && - typeof right.itemId === 'string' && - left.itemId !== right.itemId - ) { - delete openai.itemId; - } - merged.openai = openai as NonNullable[string]; - } - return merged; -} - -// ============================================================================ -// AgentBackend interface — port contract now lives in @maka/core/backend-types; -// re-exported here for backward compatibility with existing import sites. -// ============================================================================ - +// AgentBackend's port contract lives in core; keep the historical exports. export type { AgentBackend, BackendCompactHistoryInput, BackendCompactHistoryResult, } from '@maka/core/backend-types'; +export { INVALID_TOOL_NAME, repairMakaToolCall } from './ai-sdk-tool-repair.js'; -export const INVALID_TOOL_NAME = 'invalid'; - -function projectToolModePlan( - plan: ToolAvailabilityPlan, - toolMode: ToolMode, - execTool: MakaTool, -): ToolAvailabilityPlan { - if (toolMode === 'direct') return plan; - const withExec = (names: readonly string[]): string[] => - [...new Set([...names, execTool.name])].sort((a, b) => a.localeCompare(b)); - const invalid = plan.providerTools.filter((tool) => tool.name === INVALID_TOOL_NAME); - const visible = [ - ...plan.providerTools.filter((tool) => tool.name !== INVALID_TOOL_NAME), - execTool, - ].sort((a, b) => a.name.localeCompare(b.name)); - return { - ...plan, - providerTools: [...visible, ...invalid], - activeTools: withExec(plan.activeTools), - ...(plan.projectActiveTools - ? { - projectActiveTools: (options) => ({ - activeTools: withExec(plan.projectActiveTools?.(options).activeTools ?? []), - }), - } - : {}), - currentRepairToolNames: () => withExec(plan.currentRepairToolNames()), - diagnostics: (activeTools, visibleToolSchemaChars) => { - const baseActive = activeTools.filter((name) => name !== execTool.name); - const baseChars = toolSchemaCharsForDiagnostics(plan.providerTools, baseActive); - const diagnostic = plan.diagnostics(baseActive, baseChars); - if (!diagnostic) return undefined; - const execSchemaChars = Math.max(0, visibleToolSchemaChars - baseChars); - return { - ...diagnostic, - visibleToolCount: (diagnostic.visibleToolCount ?? baseActive.length) + 1, - fullToolCount: - (diagnostic.fullToolCount ?? baseActive.length + (diagnostic.hiddenToolCount ?? 0)) + 1, - visibleToolSchemaChars, - fullToolSchemaChars: - (diagnostic.fullToolSchemaChars ?? - baseChars + (diagnostic.toolSchemaCharReduction ?? 0)) + execSchemaChars, - }; - }, - }; -} - -function nestableToolSnapshot( - providerTools: readonly MakaTool[], - activeToolNames: readonly string[], -): ReadonlyMap { - const active = new Set(activeToolNames); - return new Map( - providerTools - .filter( - (tool) => - active.has(tool.name) && - tool.name !== INVALID_TOOL_NAME && - tool.name !== 'exec' && - tool.providerTool === undefined && - tool.nesting !== 'direct_only', - ) - .map((tool) => [tool.name, tool] as const), - ); -} - -const codeModeJsonSchemaOptions = { - allErrors: true, - strict: false, - validateFormats: false, -} as const; -const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); -const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); -const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); -const codeModeCompiledSchemas = new WeakMap(); - -async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { - const parameters = tool.parameters as { - safeParseAsync?: ( - value: unknown, - ) => Promise<{ success: true; data: unknown } | { success: false; error: unknown }>; - safeParse?: ( - value: unknown, - ) => { success: true; data: unknown } | { success: false; error: unknown }; - validate?: ( - value: unknown, - ) => - | { success: true; value: unknown } - | { success: false; error: unknown } - | Promise<{ success: true; value: unknown } | { success: false; error: unknown }>; - jsonSchema?: unknown; - }; - const parserResult = parameters.safeParseAsync - ? await parameters.safeParseAsync(input) - : parameters.safeParse?.(input); - if (parserResult) { - if (parserResult.success) return parserResult.data; - throw invalidCodeModeToolArguments(tool.name, parserResult.error); - } - - if (parameters.validate) { - const validationResult = await parameters.validate(input); - if (validationResult.success) return validationResult.value; - throw invalidCodeModeToolArguments(tool.name, validationResult.error); - } - - const schema = await parameters.jsonSchema; - const validator = compileCodeModeJsonSchema(schema ?? tool.parameters); - if (!validator || validator(input)) return input; - throw invalidCodeModeToolArguments(tool.name, validator.errors); -} - -function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { - if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); - if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; - const cached = codeModeCompiledSchemas.get(schema); - if (cached) return cached; - const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; - const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; - const validator = dialect.includes('draft-07') - ? codeModeDraft7Validator - : dialect.includes('2019-09') - ? codeModeDraft2019Validator - : codeModeDraft2020Validator; - const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') - ? { ...schema, $schema: dialect.replace('https://', 'http://') } - : schema; - const compiled = validator.compile(schemaForCompile as AnySchema); - codeModeCompiledSchemas.set(schema, compiled); - return compiled; -} - -function invalidCodeModeToolArguments(toolName: string, error: unknown): Error { - return new Error(`Invalid arguments for tool "${toolName}": ${schemaErrorSummary(error)}`); -} - -function schemaErrorSummary(error: unknown): string { - if (error && typeof error === 'object' && Array.isArray((error as { issues?: unknown }).issues)) { - const issues = (error as { issues: Array<{ path?: unknown; message?: unknown }> }).issues; - return issues - .slice(0, 5) - .map((issue) => { - const path = Array.isArray(issue.path) ? issue.path.join('.') : ''; - const message = typeof issue.message === 'string' ? issue.message : 'invalid value'; - return path ? `${path}: ${message}` : message; - }) - .join('; ') - .slice(0, 1000); - } - if (Array.isArray(error)) { - return (error as ErrorObject[]) - .slice(0, 5) - .map((issue) => { - const path = issue.instancePath || issue.schemaPath; - return `${path || 'input'} ${issue.message ?? 'is invalid'}`; - }) - .join('; ') - .slice(0, 1000); - } - return 'input does not match the declared schema'; -} - -function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined { - const joined = fragments - .map((fragment) => fragment?.trim()) - .filter((fragment): fragment is string => Boolean(fragment)) - .join('\n\n'); - return joined.length > 0 ? joined : undefined; -} - -// ============================================================================ -// Constructor input — single object matches @kabi's BackendRegistry call site -// ============================================================================ - -/** - * Append-message writer — usually `(m) => store.appendMessage(sessionId, m)`. - * Allows callers to inject a custom queueing/buffering strategy if needed. - */ export type AppendMessageFn = (m: StoredMessage) => Promise; export type ToolTelemetryRecorder = (record: ToolInvocationRecord) => void; export type { @@ -808,120 +224,8 @@ export interface SystemPromptContext { emitSkillCatalogTrace?: (message: string, data?: Record) => void; } -function isImageToolResult( - value: unknown, -): value is { kind: 'image'; mimeType: string; ref: StorageRef } { - if (!value || typeof value !== 'object') return false; - const image = value as { kind?: unknown; mimeType?: unknown; ref?: unknown }; - return ( - image.kind === 'image' && - typeof image.mimeType === 'string' && - image.ref !== null && - typeof image.ref === 'object' - ); -} - -function toolResultText(text: string): ToolResultOutput { - return { type: 'content', value: [{ type: 'text', text }] }; -} - -function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutput { - const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; - const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; - const message = - output.type === 'text' || output.type === 'error-text' - ? output.value - : typeof record?.output === 'string' - ? record.output - : typeof record?.text === 'string' - ? record.text - : typeof record?.error === 'string' - ? record.error - : undefined; - return { - type: 'json', - value: { status: 'failed', ...(message ? { output: message } : {}) }, - }; -} - -function durableApplyPatchReplayFactText( - input: unknown, - projection: DurableToolResultProjection, - isError: boolean, -): string | null { - if (projection.kind === 'json') { - const fact = applyPatchReplayFactText(input, projection, isError); - if (fact) return fact; - } - const output = durableProjectionToToolResultOutput(projection); - switch (output.type) { - case 'text': - case 'error-text': - return output.value; - case 'json': - case 'error-json': - return JSON.stringify(output.value); - case 'content': { - const text = output.value - .filter((part): part is Extract => part.type === 'text') - .map((part) => part.text) - .join('\n'); - return text || null; - } - case 'execution-denied': - return output.reason - ? `ApplyPatch execution denied: ${output.reason}` - : 'ApplyPatch execution denied.'; - } -} - -/** - * One Code Mode cell runs at a time on a backend, with one allowed to wait. - * Widening either needs evidence that concurrent cells are wanted; none exists - * today, and this is the bound the Code Mode adapter enforced before execution - * admission moved to the side that owns it. - */ +/** Bounds Code Mode execution across turns on this backend. */ const MAX_ACTIVE_CODE_MODE_CELLS = 1; -const MAX_WAITING_CODE_MODE_CELLS = 1; - -const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; -const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; -const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; -// A mid-stream cut after partial thinking seals one transcript fragment per -// retry. A gateway that systematically kills long thinking streams (the -// 2026-08-28 incident shape) would otherwise spend the full attempt budget -// accumulating fragments before failing anyway, so fail fast after one. -const MAX_SEALED_THINKING_RETRIES_PER_STEP = 1; -const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; -const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; -const PROVIDER_RETRY_JITTER_FACTOR = 0.25; - -function providerRetryDelayMs(failedAttempt: number, retryAfterMs?: number): number { - if (retryAfterMs !== undefined) return retryAfterMs; - const base = Math.min( - PROVIDER_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, failedAttempt - 1), - PROVIDER_RETRY_MAX_DELAY_MS, - ); - return Math.ceil(base + Math.random() * PROVIDER_RETRY_JITTER_FACTOR * base); -} - -function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason { - switch (kind) { - case 'network': - case 'provider_unavailable': - case 'rate_limit': - case 'timeout': - return kind; - case 'provider_capacity': - return 'provider_capacity'; - default: - return 'unknown'; - } -} - -function isIncompleteProviderFinishReason(reason: ModelFinishReason | undefined): boolean { - return reason === undefined || reason === 'other' || reason === 'unknown'; -} function sleepForProviderRetry(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) { @@ -943,103 +247,10 @@ function sleepForProviderRetry(delayMs: number, signal: AbortSignal): Promise(promise: Promise, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => signal.removeEventListener('abort', onAbort); - const onAbort = () => { - cleanup(); - reject(turnAbortError()); - }; - promise.then( - (value) => { - cleanup(); - resolve(value); - }, - (error) => { - cleanup(); - reject(error); - }, - ); - if (signal.aborted) onAbort(); - else signal.addEventListener('abort', onAbort, { once: true }); - }); -} - -function turnAbortError(): Error { - return Object.assign(new Error('aborted'), { name: 'AbortError' }); -} - // ============================================================================ // Implementation // ============================================================================ -/** - * The mutable state of ONE `send()`. - * - * Identity is readonly and captured at dispatch: a tool that executes minutes - * later commits against the run that actually issued it, never against whatever - * run happens to be current when it finishes. The remaining fields are the - * turn's own stream/abort bookkeeping, isolated so an overlapping turn on the - * same backend cannot observe or clear them. - * - * Each scope owns its ToolRuntime for the same reason: gating, the loop gate, - * the subagent and child-run limiters, durable attempts, and step admission are - * all per-turn facts. - */ -class TurnScope { - readonly abortController = new AbortController(); - /** Monotonic provider-visible activations owned by this one send(). */ - readonly activeTools = new Map(); - aborted = false; - loopStopRequested = false; - loopStopReason: CompleteEvent['stopReason'] | undefined; - /** Paused while this turn waits on a user permission decision. */ - watchdog: StreamWatchdog | null = null; - runTrace: RunTrace | null = null; - /** - * Image allowance for this turn, accumulated across its provider steps. Owned - * by the scope so an overlapping turn cannot spend it, and non-null for the - * scope's whole life so no path has to decide what "no budget" means. - */ - readonly imageBudget: ProviderImageBudget = { used: 0, decisions: new Map() }; - /** - * User messages steered into this turn, drained from the caller's queue at - * step boundaries. Each entry is the canonical envelope-wrapped user - * ModelMessage — the SAME form the replay plan projects the persisted - * steering event as, so the envelope text is the message's identity when - * deduping against ledger-derived request bases (bare text is not an - * identity: a steer can equal the current prompt verbatim). Entries are added - * only AFTER the echoed steering_message event is durably consumed (seq-ack), - * so a provider request never carries an unpersisted steering directive. - */ - injectedSteeringMessages: ModelMessage[] = []; - memoryExtractRequested = false; - memorySourceMessages: readonly ModelMessage[] | undefined; - memorySourceEventMessagePositions: Readonly> | undefined; - memorySourceSystemPrompt: string | undefined; - memorySourceTools: ModelToolSet | undefined; - memorySourceActiveTools: readonly string[] | undefined; - finalAssistantText: string | undefined; - codeModeTools: ReadonlyMap | undefined; - - constructor( - readonly turnId: string, - readonly runId: string | undefined, - readonly orchestration: EffectiveOrchestration, - readonly toolRuntime: ToolRuntime, - ) {} -} - -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'; readonly sessionId: string; @@ -1051,9 +262,10 @@ export class AiSdkBackend implements AgentBackend { private readonly maxSteps: number | undefined; private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; private readonly modelAdapter: ModelAdapter; + private readonly messageProjection: AiSdkMessageProjection; + private readonly providerTelemetry: ProviderRequestTelemetry; private readonly resolvedProviderOptions: Record; private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; - private readonly applyPatchProfile: ApplyPatchProfile | null; /** Bounds outstanding Code Mode cells on this backend. */ private readonly codeCellAdmission = new AdmissionLimiter(MAX_ACTIVE_CODE_MODE_CELLS); @@ -1072,7 +284,7 @@ export class AiSdkBackend implements AgentBackend { * teardown cleared the run identity a *different* turn's tool execution then * read back as absent. */ - private readonly activeTurns = new Set(); + private readonly activeTurns = new Set(); private readonly compaction: AiSdkCompaction; /** * The provider has been reported dropping context, for this backend. @@ -1082,10 +294,9 @@ export class AiSdkBackend implements AgentBackend { * told. The scope is this backend's lifetime rather than the Session's, so a * backend that is disposed and rebuilt may say it once more. */ - private contextProviderDroppingReported = false; - /** Session-scoped running total, deliberately accumulated across turns. */ - private cumulativeUsageCheckpoint: NormalizedAiSdkUsage | undefined; - private readonly memoryReplayMessageEvents = new WeakMap(); + private readonly turnSessionState: AiSdkSessionState = { + contextProviderDroppingReported: false, + }; constructor(input: AiSdkBackendInput) { this.input = input; this.sessionId = input.sessionId; @@ -1116,6 +327,32 @@ export class AiSdkBackend implements AgentBackend { ? { openAiResponsesTransportState: input.openAiResponsesTransportState } : {}), }); + this.providerTelemetry = new ProviderRequestTelemetry({ + sessionId: this.sessionId, + connectionSlug: input.connection.slug, + providerId: input.connection.providerType, + defaultModelId: input.modelId, + now: this.now, + newId: this.newId, + resolveContextWindow: (modelId) => + resolveSelectedModelContextWindow(input.connection, modelId), + resolvePricing: (modelId) => + (input.lookupPricing ?? getBuiltinPricing)( + pricingModelKey(input.connection.providerType, modelId), + ), + recordModelCallAttempt: input.recordModelCallAttempt, + assertModelCallAccountingReady: input.assertModelCallAccountingReady, + beforeRunProviderDispatch: input.beforeRunProviderDispatch, + }); + const runtime = resolveModelRuntime(input.connection, input.modelId); + const applyPatchProfile = runtime.applyPatchProfile; + this.messageProjection = new AiSdkMessageProjection({ + modelAdapter: this.modelAdapter, + applyPatchProfile, + supportsVision: input.supportsVision, + readAttachmentBytes: input.readAttachmentBytes, + maxProviderImageRequestBytes: input.maxProviderImageRequestBytes, + }); this.compaction = new AiSdkCompaction({ input, sessionId: this.sessionId, @@ -1124,20 +361,20 @@ export class AiSdkBackend implements AgentBackend { now: this.now, modelAdapter: this.modelAdapter, createProviderRequestTracker: (trackerInput) => - this.createProviderRequestTracker(trackerInput), + this.providerTelemetry.createTracker(trackerInput), materializeRuntimeReplayPlan: ( plan, imageBudget, checkpoint, providerReasoningReplayEventIds, ) => - this.materializeRuntimeReplayPlan( + this.messageProjection.materializeRuntimeReplayPlan( plan, imageBudget, checkpoint, providerReasoningReplayEventIds, ), - canReplayProviderNative: (plan) => this.canReplayProviderNative(plan), + canReplayProviderNative: (plan) => this.messageProjection.canReplayProviderNative(plan), }); if ( input.tools.some( @@ -1151,20 +388,18 @@ export class AiSdkBackend implements AgentBackend { capabilities: input.memoryExtraction, snapshot: (trigger, context) => this.memorySourceSnapshot(trigger, context), markExtractRequested: (context) => { - const scope = [...this.activeTurns].find( + const turn = [...this.activeTurns].find( (candidate) => candidate.turnId === context.turnId && candidate.runId === context.runId, ); - if (scope) scope.memoryExtractRequested = true; + if (turn) turn.memoryExtractRequested = true; }, ...(modelUsesNativeOpenAiResponses(input.connection, input.modelId) ? { unsupportedReason: 'provider_unsupported' as const } : {}), }) : []; - const runtime = resolveModelRuntime(input.connection, input.modelId); - this.applyPatchProfile = runtime.applyPatchProfile; - const modelTools = routeApplyPatchTools(input.tools, this.applyPatchProfile); + const modelTools = routeApplyPatchTools(input.tools, applyPatchProfile); this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( // The archive decoder is a runtime protocol tool, not a host binding: // this session's placeholders name it, so this session advertises it. @@ -1179,149 +414,17 @@ export class AiSdkBackend implements AgentBackend { context: MakaToolContext, ): MemoryExtractionSourceSnapshot | undefined { if (trigger !== 'remember') return undefined; - const scope = [...this.activeTurns].find( + const turn = [...this.activeTurns].find( (candidate) => candidate.turnId === context.turnId && candidate.runId === context.runId, ); - return scope - ? this.memorySourceSnapshotFromScope(scope, { + return turn + ? turn.memorySourceSnapshot({ trigger: 'remember', toolCallId: context.toolCallId, }) : undefined; } - private memorySourceSnapshotFromScope( - scope: TurnScope, - boundary: - | { readonly trigger: 'remember'; readonly toolCallId: string } - | { readonly trigger: 'extract'; readonly terminalEventId: string }, - ): MemoryExtractionSourceSnapshot | undefined { - if ( - !scope.runId || - !scope.memorySourceMessages || - !scope.memorySourceTools || - !scope.memorySourceActiveTools - ) { - return undefined; - } - const sourceMessages = - boundary.trigger === 'extract' && scope.finalAssistantText - ? [ - ...scope.memorySourceMessages, - { - role: 'assistant' as const, - content: [{ type: 'text' as const, text: scope.finalAssistantText }], - } as ModelMessage, - ] - : scope.memorySourceMessages; - const memoryProjection = projectMemoryConversationPrefix( - sourceMessages, - scope.memorySourceEventMessagePositions, - ); - return { - ...boundary, - sourceHeader: memoryExtractionModelHeader(this.input.header), - ...(scope.memorySourceSystemPrompt - ? { sourceSystemPrompt: scope.memorySourceSystemPrompt } - : {}), - sourceMessages: structuredClone(memoryProjection.messages), - ...(memoryProjection.eventMessagePositions - ? { - sourceEventMessagePositions: structuredClone(memoryProjection.eventMessagePositions), - } - : {}), - sourceTools: { ...scope.memorySourceTools }, - sourceActiveTools: [...scope.memorySourceActiveTools], - sourceProviderOptions: structuredClone(this.resolvedProviderOptions), - ...(this.modelAdapter.maxOutputTokens() !== undefined - ? { sourceMaxOutputTokens: this.modelAdapter.maxOutputTokens() } - : {}), - ...(resolveSelectedModelContextWindow(this.input.connection, this.input.modelId) !== undefined - ? { - sourceContextWindowTokens: resolveSelectedModelContextWindow( - this.input.connection, - this.input.modelId, - ), - } - : {}), - sessionId: this.sessionId, - runId: scope.runId, - turnId: scope.turnId, - workspaceKey: this.input.header.workspaceRoot, - }; - } - - private dispatchAutomaticMemoryCompaction( - scope: TurnScope, - dispatch: AutomaticMemoryCompactionDispatch, - ): void { - const capabilities = this.input.memoryExtraction; - const boundary = dispatch.checkpoint.memoryExtractionBoundary; - if ( - !capabilities || - !scope.runId || - !boundary || - modelUsesNativeOpenAiResponses(this.input.connection, this.input.modelId) - ) { - return; - } - try { - capabilities.extract({ - trigger: 'compaction', - sourceHeader: memoryExtractionModelHeader(this.input.header), - // Compaction messages are rebuilt from the durable RuntimeEvent prefix - // inside the background lane, avoiding a full Memory projection here. - sourceMessages: [], - rebuildSourceContextFromCompactionCheckpoint: true, - sourceTools: {}, - sourceActiveTools: [], - ...(this.modelAdapter.maxOutputTokens() !== undefined - ? { sourceMaxOutputTokens: this.modelAdapter.maxOutputTokens() } - : {}), - ...(resolveSelectedModelContextWindow(this.input.connection, this.input.modelId) !== - undefined - ? { - sourceContextWindowTokens: resolveSelectedModelContextWindow( - this.input.connection, - this.input.modelId, - ), - } - : {}), - sessionId: this.sessionId, - runId: scope.runId, - turnId: scope.turnId, - workspaceKey: this.input.header.workspaceRoot, - compactionCheckpointId: dispatch.checkpoint.checkpointId, - compactionBoundaryEventId: boundary.runtimeEventId, - }); - } catch { - // Automatic memory extraction is fail-open and must never perturb the caller. - } - } - - private automaticMemoryCompactionSupported(): boolean { - return ( - this.input.memoryExtraction !== undefined && - !modelUsesNativeOpenAiResponses(this.input.connection, this.input.modelId) - ); - } - - private automaticMemoryCompactionDecision(): AutomaticMemoryCompactionDecision { - const capabilities = this.input.memoryExtraction; - if (!capabilities) return { disposition: 'eligible', dispatch: false }; - if (this.input.header.subagentParent || this.input.header.isArchived) { - return { disposition: 'policy_denied', dispatch: false }; - } - const gate = capabilities.automaticGate?.() ?? { - allowed: false as const, - reason: 'unavailable' as const, - }; - if (gate.allowed) return { disposition: 'eligible', dispatch: true }; - return gate.reason === 'unavailable' - ? { disposition: 'eligible', dispatch: false } - : { disposition: 'policy_denied', dispatch: false }; - } - /** * One ToolRuntime per `send()`, bound to that turn's identity for its whole * lifetime. The scope is passed in rather than read back so a tool settling @@ -1333,7 +436,7 @@ export class AiSdkBackend implements AgentBackend { invocationId: string | undefined; hostedInteraction: HostedInteractionBridge | undefined; orchestrationMode: EffectiveOrchestration['mode']; - scope: () => TurnScope; + scope: () => AiSdkTurn; }): ToolRuntime { const input = this.input; return new ToolRuntime({ @@ -1364,26 +467,6 @@ export class AiSdkBackend implements AgentBackend { }); } - private createCodeModeExecTool( - scope: TurnScope, - eventSink: DurableSessionEventSink, - ): MakaTool<{ code: string }> { - return { - name: 'exec', - description: [ - 'Execute a bounded orchestration cell over the active tools.', - 'Use tools.(args), await dependent calls, and Promise.all for independent calls.', - 'The sandbox has no process, filesystem, network, timer, eval, import, or cross-cell state.', - 'Terminate by returning a JSON-serializable value. Failures return a structured diagnostic.', - ].join(' '), - parameters: z.object({ code: z.string() }), - executionSemantics: 'exclusive_step', - nesting: 'direct_only', - recoveryMode: 'never_auto_retry', - impl: (args, context) => this.executeCodeModeCell(scope, eventSink, args.code, context), - }; - } - // -------------------------------------------------------------------------- // manual history compaction // -------------------------------------------------------------------------- @@ -1396,2009 +479,48 @@ export class AiSdkBackend implements AgentBackend { // send() // -------------------------------------------------------------------------- - /** - * Register one turn's execution scope, with its own ToolRuntime and identity. - * - * The ToolRuntime holds the scope by reference, so a tool settling long after - * its step still reaches this turn's watchdog, trace, and budget — never a - * successor's. The accessor exists only because the ToolRuntime is built while - * the scope it belongs to is still being constructed. - */ - private openTurnScope(input: BackendSendInput): TurnScope { - const orchestration = - input.orchestration ?? - resolveEffectiveOrchestration(this.input.header.orchestrationMode, undefined); - let scope: TurnScope; - scope = new TurnScope( - input.turnId, - input.runId, - orchestration, - this.createToolRuntime({ - turnId: input.turnId, - runId: input.runId, - invocationId: input.invocationId ?? input.runId, - hostedInteraction: input.hostedInteraction, - orchestrationMode: orchestration.mode, - scope: () => scope, - }), - ); - this.activeTurns.add(scope); - return scope; - } - - async *send(input: BackendSendInput): AsyncIterable { - // Registration and deregistration live in ONE frame, so a scope that made it - // into activeTurns is always removed exactly once — including when setup - // throws before the provider pump exists (a mismatched hosted Interaction - // Run, an unreadable attachment). A leaked scope would be permanent: nothing - // overwrites a Set entry, and stop()/dispose() only iterate it. - const scope = this.openTurnScope(input); - try { - yield* this.sendWithinScope(scope, input); - } finally { - await this.cleanupAfterTurn(scope); - } - } - - private async *sendWithinScope( - scope: TurnScope, - input: BackendSendInput, - ): AsyncIterable { - const turnId = input.turnId; - const maxSteps = input.maxSteps ?? this.maxSteps; - const toolRuntime = scope.toolRuntime; - const turnAbortController = scope.abortController; - - const midTurnState = this.compaction.buildMidTurnCapacityCompactState(input); - const queue = new AsyncEventQueue(); - const codeModeExecTool = this.createCodeModeExecTool(scope, queue); - - // One AssistantMessage is flushed per provider step (not per turn), so the - // ledger records the text↔tool timeline at step granularity and each step's - // Anthropic thinking signature stays paired with its own thinking text. The - // turn's first step reuses this id; every later step rotates to a fresh one - // at its step boundary (see the stream loop below). - let currentStepMessageId = this.newId(); - let stepText = ''; - let stepTextProviderOptions: NonNullable | undefined; - let stepTextPartStartOffset = 0; - let stepThinkingParts: AssistantThinkingPart[] = []; - let stepThinkingPartsById = new Map(); - let stepContentOrder: AssistantStepContentKind[] = []; - const startedAt = this.now(); - - const recordStepContent = (kind: AssistantStepContentKind): void => { - if (!stepContentOrder.includes(kind)) stepContentOrder.push(kind); - }; - // Flush the current step's AssistantMessage (text + thinking) and the paired - // terminal thinking/text events, then clear the per-step accumulators. - // Persist when the step produced text OR reasoning — a thinking-only step - // (Anthropic's signed/omitted reasoning has empty text) still round-trips its - // signed block; a pure-tool step (no text, no thinking) writes nothing, so - // tool-only steps leave no placeholder assistant row. thinking_complete - // precedes text_complete so the read-model attaches this step's reasoning to - // this step's assistant row. Hoisted to send() scope so both the streaming - // path and the abort/error handler can flush a partial step. - const resetStep = (): void => { - stepText = ''; - stepTextProviderOptions = undefined; - stepTextPartStartOffset = 0; - stepThinkingParts = []; - stepThinkingPartsById = new Map(); - stepContentOrder = []; - }; - const flushStep = async (): Promise => { - const hasThinking = stepThinkingParts.length > 0; - if (stepText.length === 0 && !hasThinking) { - resetStep(); - return; - } - const stepId = currentStepMessageId; - const thinkingText = stepThinkingParts.map((part) => part.text).join(''); - const contentOrder = nonCanonicalContentOrder(stepContentOrder); - const msg: AssistantMessage = { - type: 'assistant', - id: stepId, - turnId, - ts: this.now(), - text: stepText, - ...(stepTextProviderOptions !== undefined - ? { providerOptions: stepTextProviderOptions } - : {}), - ...(contentOrder ? { contentOrder } : {}), - modelId: this.input.modelId, - ...(hasThinking - ? { - thinking: { - text: thinkingText, - ...(stepThinkingParts.length === 1 && stepThinkingParts[0]!.signature !== undefined - ? { signature: stepThinkingParts[0]!.signature } - : {}), - ...(stepThinkingParts.length === 1 && - stepThinkingParts[0]!.providerOptions !== undefined - ? { providerOptions: stepThinkingParts[0]!.providerOptions } - : {}), - ...(stepThinkingParts.length > 1 ? { parts: stepThinkingParts } : {}), - }, - } - : {}), - }; - await this.input.appendMessage(msg); - if (hasThinking) { - for (const part of stepThinkingParts) { - queue.push({ - type: 'thinking_complete', - id: this.newId(), - turnId, - ts: this.now(), - messageId: stepId, - text: part.text, - ...(part.signature !== undefined ? { signature: part.signature } : {}), - // No sanitiser here, unlike the tool call below: these options are - // not the provider's object. `translateChunk` rebuilds reasoning - // metadata from two named string fields, so an omitted provider - // field cannot arrive as an explicit `undefined` and break the - // canonical encoding. Passing the provider's object through - // instead would need the same `stripUndefinedDeep` a tool call has. - ...(part.providerOptions !== undefined - ? { providerOptions: part.providerOptions } - : {}), - } satisfies ThinkingCompleteEvent); - } - } - queue.push({ - type: 'text_complete', - id: this.newId(), - turnId, - ts: this.now(), - messageId: stepId, - text: stepText, - ...(stepTextProviderOptions !== undefined - ? { providerOptions: stepTextProviderOptions } - : {}), - } satisfies TextCompleteEvent); - scope.finalAssistantText = stepText.length > 0 ? stepText : undefined; - resetStep(); - }; - let tokenUsage: NormalizedAiSdkUsage | undefined; - let tokenUsageCostUsd: number | undefined; - // Per-send sum of every COMPLETED step's usage, merged at each finish-step - // boundary. When the send aborts (mid-turn exhaust, user stop, stream - // error) the SDK's cumulative `usage` promise may not resolve, but this sum is - // real provider-reported evidence for the steps that did finish — IF every - // completed step produced a usable sample. One unusable sample makes the - // sum a partial cost, and LlmCallRecord has no partial marker, so the flag - // fails the whole fallback closed (#972: incomplete usage is no usage). - let completedStepUsage: NormalizedAiSdkUsage | undefined; - let sawUnusableStepUsage = false; - // Input tokens from the last completed step — the actual prompt token count - // of the final API request. Used to compute contextRemaining for the TUI - // statusline ctx segment (#1067): contextRemaining = contextWindow - this. - // result.usage.inputTokens is cumulative across steps and would produce - // misleading >100% percentages, so the per-step value is captured here. - let lastStepInputTokens: number | undefined; - /** Tool count of the request that produced `lastStepInputTokens`. */ - let lastStepActiveToolCount: number | undefined; - // Output tokens of the same step: with the input they are the baseline the - // next request is judged from (everything the model produced is re-sent). - let lastStepOutputTokens: number | undefined; - let streamStatus: LlmCallRecord['status'] = 'success'; - let streamErrorClass: string | undefined; - let runtimeSteps = 0; - let toolAvailabilityForTelemetry: ReturnType = undefined; - let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined; - let contextCompactedNoteWritten = false; - let contextCompactionFailedOpenNoteWritten = false; - let contextWindowOverrunNoteWritten = false; - let contextReportedWindowNoteWritten = false; - let contextOverflowAfterCompactionNoteWritten = false; - let contextWindowSuggestionNoteWritten = false; - // Request index (0-based) at which the active prune last rewrote the - // request. A step Maka pruned is not append-only, so usage may legitimately - // shrink. - let pruneAppliedAtStep: number | undefined; - const trace = new RunTrace({ - sessionId: this.sessionId, - turnId, - connectionSlug: this.input.connection.slug, - providerId: this.input.connection.providerType, - modelId: this.input.modelId, - newId: this.newId, - now: this.now, - record: this.input.recordRunTrace, - }); - scope.runTrace = trace; - trace.turnStarted({ - orchestrationMode: scope.orchestration.mode, - orchestrationSource: scope.orchestration.source, - agentSwarmAuthorization: scope.orchestration.agentSwarmAuthorization, - }); - if (this.input.planTraceContext) { - trace.emit('plan', 'plan_context_resolved', 'Plan context resolved', { - ...this.input.planTraceContext, - }); - if (this.input.planTraceContext.executionId) { - trace.emit('plan', 'plan_execution_started', 'Plan execution turn started', { - ...this.input.planTraceContext, - }); - } - } - const providerRequestTracker = this.createProviderRequestTracker({ - turnId, - callKind: 'main', - modelId: this.input.modelId, - runId: scope.runId, - }); - const providerRequestTraceId = providerRequestTracker?.traceId; - - // --- Resolve model (API key already attached at construct time) --- - let model: unknown; - try { - model = this.modelAdapter.resolveModel(); - trace.modelResolved(); - } catch (err) { - trace.modelResolveFailed(err); - queue.push(this.makeErrorEvent(turnId, err)); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'error', - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } - - // --- Build the provider-visible schema set. Tool execution stays in Runtime. --- - // One immutable runtime owns the bound search catalog and cached index. - // Mutable activation belongs to this send's TurnScope. - const requiredOrchestrationTools = - scope.orchestration.mode === 'swarm' - ? new Set([ - 'agent_list', - 'update_agent_graph', - 'yield_agent_graph', - 'agent_swarm_status', - 'agent_output', - ]) - : scope.orchestration.mode === 'graph' - ? new Set([ - 'agent_list', - 'view_agent_graph', - 'update_agent_graph', - 'yield_agent_graph', - 'agent_swarm_status', - 'agent_output', - ]) - : new Set(); - const requestedToolMode: unknown = - input.toolMode === undefined ? DEFAULT_TOOL_MODE : input.toolMode; - if (!isToolMode(requestedToolMode)) { - throw new Error(`Invalid tool mode: ${String(requestedToolMode)}`); - } - const toolMode = requestedToolMode; - if (toolMode === 'code_mode' && this.input.tools.some((tool) => tool.name === 'exec')) { - throw new Error('Tool name "exec" is reserved for Code Mode.'); - } - const plan = projectToolModePlan( - this.toolAvailabilityRuntime.prepare(scope.activeTools, requiredOrchestrationTools), - toolMode, - codeModeExecTool, - ); - const providerTools = plan.providerTools; - let activeToolResultPruneDiagnosticPatch: ActiveToolResultPruneDiagnosticPatch = {}; - let midTurnCompactDiagnosticPatch: Partial | undefined; - // Tool names the repair path matches a mis-cased call against — follows the - // current step's snapshot so a tool activated mid-turn is repairable on the - // step it becomes active, not routed to `invalid`. - const boundaryAwareToolNames = (names: readonly string[]): string[] => { - if (toolRuntime.shouldFinalizeSandboxBoundary()) return []; - return toolRuntime.hasSandboxBoundaryDenial() - ? names.filter((name) => name !== REQUEST_SANDBOX_BOUNDARY_TOOL_NAME) - : [...names]; - }; - const currentRepairToolNames = () => boundaryAwareToolNames(plan.currentRepairToolNames()); - if (plan.gating) { - toolRuntime.setGating(plan.gating); - } - - const modelTools: ModelToolSet = {}; - for (const t of providerTools) { - modelTools[t.name] = t.providerTool - ? { kind: 'provider', providerTool: t.providerTool } - : { - kind: 'function', - description: t.description, - inputSchema: t.parameters, - }; - } - - // Resolve the stable Provider envelope before automatic Compaction freezes - // its source. The same value is reused by the primary request; Memory does - // not resolve or mutate Agent configuration after the checkpoint commits. - let systemPrompt: string | undefined; - try { - systemPrompt = joinPromptFragments([ - await this.resolveSystemPrompt(scope), - scope.orchestration?.mode === 'swarm' ? renderSwarmModePrompt() : undefined, - scope.orchestration?.mode === 'graph' ? renderGraphModePrompt() : undefined, - ]); - } catch (err) { - trace.modelStreamFailed(this.modelAdapter.classifyError(err), err); - queue.push(this.makeErrorEvent(turnId, err)); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'error', - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } - - // --- Build messages from RuntimeEvent history and its compatibility projection. --- - const priorReplayResult = await this.buildPriorMessages( - scope, + private openTurnScope(input: BackendSendInput): AiSdkTurn { + const turn = new AiSdkTurn( + { + backend: this.input, + modelAdapter: this.modelAdapter, + messageProjection: this.messageProjection, + providerTelemetry: this.providerTelemetry, + compaction: this.compaction, + toolAvailabilityRuntime: this.toolAvailabilityRuntime, + codeCellAdmission: this.codeCellAdmission, + resolvedProviderOptions: this.resolvedProviderOptions, + session: this.turnSessionState, + newId: this.newId, + now: this.now, + maxSteps: this.maxSteps, + providerRetrySleep: this.providerRetrySleep, + createToolRuntime: (owner) => + this.createToolRuntime({ + turnId: owner.turnId, + runId: owner.runId, + invocationId: input.invocationId ?? input.runId, + hostedInteraction: input.hostedInteraction, + orchestrationMode: owner.orchestration.mode, + scope: () => owner, + }), + }, input, - midTurnState, - this.automaticMemoryCompactionSupported() ? true : undefined, ); - if (scope.aborted) { - queue.push({ - type: 'abort', - id: this.newId(), - turnId, - ts: this.now(), - reason: 'user_stop', - } satisfies AbortEvent); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'user_stop', - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } - const priorReplay = priorReplayResult; - if (input.continuation && priorReplay.messages.length === 0) { - const replay = priorReplayFailureTrace(priorReplay); - const error = new ContinuationReplayEmptyError(replay.gate, replay.diagnosticCodes); - trace.modelStreamFailed(error.code, error, replay); - queue.push(this.makeErrorEvent(turnId, error)); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'error', - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } - if (midTurnState) { - // Roll-forward seed: the latest durable checkpoint (loaded or written at - // turn start) so a mid-turn summary only re-reads the newly folded span. - const checkpoint = priorReplay.latestHistoryCompactCheckpoint; - midTurnState.previousCheckpoint = - checkpoint && - canContinueHistoryCompactCheckpointForModel( - checkpoint, - this.input.connection, - this.input.header.llmConnectionId, - this.input.modelId, - ) - ? checkpoint - : undefined; - } - /** - * The fold THIS request's prompt was built under (#2323). - * - * Called once per physical dispatch rather than once per send, because the - * boundary moves between dispatches of the same send: mid-turn capacity - * compaction advances it before a later step, and overflow recovery - * advances it before it resends the request the provider just rejected. - * Sealed from session state at settlement it would be whichever fold - * arrived last — not the one the sealed prompt was actually made of. - * - * Mid-turn state is the single rolling authority whenever the turn has one: - * it is seeded just above from the pre-turn checkpoint and is what both of - * those folds write to. A turn without that seam can only have been built - * under the pre-turn checkpoint. - */ - const requestHistoryCompactBoundary = (): ContextDiagnosticsCompaction | undefined => { - const checkpoint = midTurnState - ? midTurnState.previousCheckpoint - : priorReplay.latestHistoryCompactCheckpoint; - return checkpoint ? contextDiagnosticsCompactionOf(checkpoint) : undefined; - }; - - // --- Background pump: streamText → stream → normalize → queue --- - const pumpDone: Promise = (async () => { - const watchdogState: { current: StreamWatchdog | null } = { - current: null, - }; - let providerRequestAbortController = new AbortController(); - const watchdogTimeoutState: { - current: { - readonly phase: StreamWatchdogPhase; - readonly error: Error; - } | null; - } = { current: null }; - const currentWatchdogTimeout = () => watchdogTimeoutState.current; - const consumeWatchdogTimeout = () => { - const timeout = watchdogTimeoutState.current; - watchdogTimeoutState.current = null; - return timeout; - }; - let lastCompletedStepHadToolResult = false; - let terminalProviderErrorReason: string | undefined; - try { - const startWatchdog = (): void => { - watchdogState.current?.stop(); - const next = new StreamWatchdog({ - now: this.now, - connectTimeoutMs: this.input.streamConnectTimeoutMs, - idleTimeoutMs: this.input.streamIdleTimeoutMs, - ...this.input.streamWatchdogTimer, - onTimeout: (timeout) => { - const error = new Error(formatStreamWatchdogError(timeout)); - watchdogTimeoutState.current = { phase: timeout.phase, error }; - providerRequestAbortController.abort(error); - }, - }); - watchdogState.current = next; - scope.watchdog = next; - next.start(); - }; - const activeTools = plan.activeTools; - const currentUserContent = input.continuation - ? undefined - : await this.buildCurrentUserContent( - scope.imageBudget, - input.text, - input.attachments, - input.directoryReferences, - input.quotes, - input.headAnchorRuntimeEvent?.id, - ); - const messages = - currentUserContent === undefined - ? [...priorReplay.messages] - : [ - ...priorReplay.messages, - { - role: 'user' as const, - content: currentUserContent, - } as ModelMessage, - ]; - const loadDurableTurnEvents = async (): Promise => { - const loadTurnRuntimeEvents = this.input.loadTurnRuntimeEvents; - if (!loadTurnRuntimeEvents) { - throw new Error('durable current-run reader is required for tool continuation'); - } - await queue.waitUntilConsumedThroughCurrent(); - return (await loadTurnRuntimeEvents(turnId)).filter((event) => event.turnId === turnId); - }; - const loadDurableTurnProjection = async (): Promise => { - const turnEvents = await loadDurableTurnEvents(); - const projectionCheckpoint = midTurnState?.projectionCheckpoint; - const rawProjectionEvents = projectionCheckpoint - ? [ - ...midTurnState.priorContentEvents, - ...turnEvents.filter(isHistoryCompactContentEvent), - ] - : turnEvents; - let replayEvents = rawProjectionEvents; - if (projectionCheckpoint) { - const checkpointMatch = matchHistoryCompactCheckpointPrefix( - projectionCheckpoint, - rawProjectionEvents, - ); - if (checkpointMatch.reason) { - throw new Error(`durable checkpoint projection mismatch: ${checkpointMatch.reason}`); - } - replayEvents = projectHistoryCompactCheckpointReplay( - projectionCheckpoint, - checkpointMatch.coveredRuntimeEvents, - checkpointMatch.successorRuntimeEvents, - ); - // The checkpoint was capacity-validated before it was persisted. - // Do not re-run that gate against a later, larger successor tail: - // the active-step shaper must see that growth so it can roll the - // checkpoint forward instead of resurrecting raw history. - } - // The current Turn is model-visible history like any other, so it is - // folded through the same reducer before it becomes messages. Without - // this, a result archived at step N is rebuilt in full at step N+1 and - // the ledger's account of what the model sees stops being true. - const foldedReplayEvents = await this.compaction.foldEffectiveModelHistory(replayEvents); - const replayPlan = buildRuntimeEventModelReplayPlan(foldedReplayEvents, { - toolActivityTurnIds: collectToolActivityTurnIds([ - ...(input.runtimeContext ?? []), - ...turnEvents, - ]), - }); - if ( - hasBlockingReplayDiagnostics(replayPlan) || - (replayPlan.hasProviderNativeSemantics && !this.canReplayProviderNative(replayPlan)) - ) { - throw new Error('durable current-run projection is not replayable'); - } - const currentTurnMessages = await this.materializeRuntimeReplayPlan( - replayPlan, - scope.imageBudget, - projectionCheckpoint, - compatibleProviderReasoningReplayEventIds( - replayEvents, - input.runtimeContextInvocations, - this.input.providerStateIdentity, - this.input.modelId, - scope.runId, - ), - ); - return projectionCheckpoint - ? currentTurnMessages - : [...priorReplay.messages, ...currentTurnMessages]; - }; - // Tool Availability describes the provider-visible (active) subset. A - // group loaded this turn expands that subset on later requests, so the - // terminal trace is refined against the final active set below. - contextBudgetForTelemetry = priorReplay.contextBudget; - const computeToolAvailability = (active: readonly string[]) => { - const toolSchemaChars = toolSchemaCharsForDiagnostics(providerTools, active); - return plan.diagnostics(active, toolSchemaChars); - }; - toolAvailabilityForTelemetry = computeToolAvailability(activeTools); - trace.modelStreamStarted(activeTools, { - ...(toolAvailabilityForTelemetry !== undefined - ? { toolAvailability: toolAvailabilityForTelemetry } - : {}), - ...(priorReplay.contextBudget ? { contextBudget: priorReplay.contextBudget } : {}), - }); - - const onMidTurnDiagnosticPatch = (patch: Partial): void => { - midTurnCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( - midTurnCompactDiagnosticPatch, - patch, - ); - }; - const midTurnCapacityHook = this.compaction.buildMidTurnCapacityCompactProjection( - turnId, - midTurnState, - queue, - providerTools, - onMidTurnDiagnosticPatch, - scope, - this.automaticMemoryCompactionSupported() - ? () => this.automaticMemoryCompactionDecision() - : undefined, - this.automaticMemoryCompactionSupported() - ? (dispatch) => this.dispatchAutomaticMemoryCompaction(scope, dispatch) - : undefined, - turnAbortController.signal, - ); - // When mid-turn capacity compaction is active, the prune must also cover - // the newest completed step; see collectPrunableCompletedStepToolCallIds. - const activeToolResultPruneIncludesNewestStep = midTurnState !== undefined; - const activeToolResultPruneHook = this.compaction.buildActiveToolResultPruneProjection( - turnId, - activeToolResultPruneIncludesNewestStep, - (patch) => { - pruneAppliedAtStep = runtimeSteps; - activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( - activeToolResultPruneDiagnosticPatch, - patch, - ); - }, - ); - const shapedProjection = composeRequestProjection( - plan.projectActiveTools, - midTurnCapacityHook, - activeToolResultPruneHook, - ); - // Hooks shape; nothing measures the final payload. Whether it fits is - // the provider's answer (#4559). - const requestProjection = shapedProjection; - - const completedProviderSteps: RequestProjectionContext['completedSteps'][number][] = []; - let requestMessages: ModelMessage[] = messages; - // The compaction module runs at most once per send. This tracks the - // reactive entry; the proactive one sets the same flag on the mid-turn - // state, and each consults the other, so a send that already folded - // reports the oversized message instead of folding again (#4559). - let overflowRetryUsed = false; - let result: ModelStreamResult; - let providerOutcome: ModelStepOutcome; - let finishReason: ModelFinishReason = 'stop'; - let terminalProviderError: unknown; - agentLoop: for (;;) { - await this.drainSteeringInto(scope, input, queue); - if (this.input.loadTurnRuntimeEvents) { - requestMessages = await loadDurableTurnProjection(); - } else { - const missingSteering = steeringMessagesMissingFromBase( - scope.injectedSteeringMessages, - requestMessages, - ); - if (missingSteering.length > 0) - requestMessages = [...requestMessages, ...missingSteering]; - } - // 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 && - maxSteps > 1 && - runtimeSteps === maxSteps - 1 && - completedProviderSteps.length > 0; - const sandboxBoundaryFinalizationStep = - toolRuntime.shouldFinalizeSandboxBoundary() || - (toolRuntime.hasSandboxBoundaryDenial() && - maxSteps !== undefined && - runtimeSteps === maxSteps - 1); - if (sandboxBoundaryFinalizationStep) { - toolRuntime.forceSandboxBoundaryFinalization(); - } - 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; - let idleWatchdogRetryCount = 0; - let incompleteStreamRetryCount = 0; - let sealedThinkingRetryCount = 0; - const returnedToolCalls: ToolCallPart[] = []; - let providerToolActivityCount = 0; - const providerToolInputs = new Map(); - let providerStepUsage: NormalizedUsage | undefined; - for (;;) { - providerRequestAbortController = new AbortController(); - watchdogTimeoutState.current = null; - startWatchdog(); - // Monotonic facts for this physical request. The step accumulators - // are cleared after flushStep(), so they cannot decide whether a - // later stream failure is safe to retry. - let attemptSawText = false; - let attemptSawThinking = false; - let attemptSawToolActivity = false; - let attemptSawContinuationMetadata = false; - let attemptReachedStepBoundary = false; - const attemptHasNoObservableOutput = () => - !attemptSawText && - !attemptSawThinking && - !attemptSawToolActivity && - !attemptSawContinuationMetadata && - !attemptReachedStepBoundary; - // Thinking is the only output that can be sealed into its own - // message before a retry: flushStep() closes the fragment under - // the current message id and the retry streams into a fresh one, - // so the user never sees spliced or duplicated content. Text, - // tool activity, continuation metadata, and step boundaries stay - // non-recoverable for the reasons each of them is tracked. - const attemptCanRecoverWithSealedThinking = () => - !attemptSawText && - !attemptSawToolActivity && - !attemptSawContinuationMetadata && - !attemptReachedStepBoundary; - scope.memorySourceMessages = [...attemptMessages]; - scope.memorySourceEventMessagePositions = - this.memoryEventMessagePositions(attemptMessages); - scope.memorySourceSystemPrompt = requestSystemPrompt; - scope.memorySourceTools = modelTools; - scope.memorySourceActiveTools = [...activeToolsForRequest]; - scope.finalAssistantText = undefined; - // Keep a denied boundary request as a Code Mode trap: the provider - // no longer sees it as a direct tool, but a nested retry must still - // reach ToolRuntime's denial latch instead of becoming an endlessly - // variable unknown-tool error inside `exec`. - const codeModeActiveTools = - toolRuntime.hasSandboxBoundaryDenial() && activeToolsForRequest.includes('exec') - ? [...activeToolsForRequest, REQUEST_SANDBOX_BOUNDARY_TOOL_NAME] - : activeToolsForRequest; - scope.codeModeTools = - toolMode === 'code_mode' - ? nestableToolSnapshot(providerTools, codeModeActiveTools) - : undefined; - const requestWatchdog = watchdogState.current; - // Read here, beside the messages it describes: `attemptMessages` is - // rebuilt in place by overflow recovery, and the boundary it folded - // under must travel with that rebuild, not with the step. - const historyCompactBoundary = requestHistoryCompactBoundary(); - result = await this.modelAdapter.startStream({ - model, - messages: attemptMessages, - tools: modelTools, - activeTools: activeToolsForRequest, - onStreamActivity: () => requestWatchdog?.markActivity(), - repairToolCall: async ({ - toolCall, - error, - }: { - toolCall: RepairableAiSdkToolCall; - error: unknown; - }) => { - return repairMakaToolCall({ - toolCall, - availableToolNames: currentRepairToolNames(), - toolParameters: (name) => - providerTools.find((candidate) => candidate.name === name)?.parameters, - toolCategoryHint: (name) => - providerTools.find((candidate) => candidate.name === name)?.categoryHint, - error, - }); - }, - system: requestSystemPrompt, - abortSignal: AbortSignal.any([ - turnAbortController.signal, - providerRequestAbortController.signal, - ]), - ...(providerRequestTracker ? { providerRequestTracker } : {}), - ...(historyCompactBoundary ? { historyCompactBoundary } : {}), - continuationKey: scope.turnId, - }); - - for await (const event of result.events) { - if (scope.aborted) break; - if (event.kind === 'error') { - // Settlement owns the failure; stop before any synthesized - // trailer and consume the one authoritative outcome below. - break; - } - const incompleteFinish = - (event.kind === 'finish' || event.kind === 'step-finish') && - isIncompleteProviderFinishReason(event.finishReason); - if ((event.kind === 'finish' || event.kind === 'step-finish') && !incompleteFinish) { - attemptReachedStepBoundary = true; - } - if (event.kind === 'step-finish') { - // AI SDK can synthesize `finish-step(other)` when the provider - // stream reaches EOF without a terminal frame. That is not a - // completed model step and must not consume the step budget or - // checkpoint imaginary usage before the safe retry below. - if (!incompleteFinish) { - // Step boundary: AI SDK 7 delimits steps with `finish-step` - // (and `step-finish` for legacy replay fixtures); the adapter - // reduces both to this event. A duplicate boundary is harmless: - // the second flush no-ops (accumulators already cleared) and one - // extra id rotation just discards an unused id. - runtimeSteps += 1; - const stepUsage = event.usage; - providerStepUsage = stepUsage; - if (!stepUsage) sawUnusableStepUsage = true; - // Silent eviction / rewrite check (#4559): this step only - // appended (no fold, no prune, no image omission) yet the - // provider counted no more input tokens than for the previous - // request. Not-greater, not strictly-fewer: a provider that - // truncates to a fixed window (Ollama's `num_ctx`) reports the - // same total on every later request while Maka keeps - // appending, so a plateau is the signal, and an equal count - // after an append is already impossible without provider-side - // eviction or rewriting. Input against input: the previous - // reply's reasoning may not be resent, so input + output is - // not the floor of the next input on every wire. - const completedRequestIndex = runtimeSteps - 1; - // A finalization step resolves an empty tool set, so its - // request legitimately drops several thousand schema tokens - // with no fold, prune or image omission. Maka shaped that - // request; the provider did not drop anything. - const toolSchemaShrank = - lastStepActiveToolCount !== undefined && - activeToolsForRequest.length < lastStepActiveToolCount; - // Across the send boundary the comparison is the same one, - // against the last request a provider accepted before this - // send. A provider that truncates to a fixed window reports - // the same input on every later request while the user keeps - // adding turns, and a send of one or two steps never sees - // that from the inside: the live evidence plateaus at 3,716 - // input tokens across eight turns with nothing reported - // (#4623). The first request of a send therefore compares - // against the persisted anchor, which is route-validated - // where it is read; a fold before that request would explain - // a smaller input by itself, so it disables the comparison. - const acrossSends = completedRequestIndex === 0; - const priorInput = acrossSends - ? midTurnState?.compactionAppliedThisSend === true - ? undefined - : midTurnState?.priorAcceptedInputTokens - : lastStepInputTokens; - if ( - !this.contextProviderDroppingReported && - !toolSchemaShrank && - midTurnState && - priorInput !== undefined && - midTurnState.replacedStepNumber !== completedRequestIndex && - pruneAppliedAtStep !== completedRequestIndex && - midTurnState.omittedImageToolResults.size === 0 && - stepUsage !== undefined && - Number.isFinite(stepUsage.inputTokens) && - stepUsage.inputTokens > 0 && - // Across sends the test is equality, not "did not grow". - // Inside a send Maka knows it only appended, so any - // shortfall is the provider's. Across the boundary it does - // not: a manual compaction leaves the pre-compaction anchor - // behind, a turn can carry a smaller tool set, and a user - // can edit or branch history. All three shrink the input - // legitimately, and none of them lands on exactly the same - // count. A provider truncating to a fixed window does, on - // every later request. - (acrossSends - ? stepUsage.inputTokens === priorInput - : stepUsage.inputTokens <= priorInput) - ) { - this.contextProviderDroppingReported = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_provider_dropping', - data: { inputTokens: stepUsage.inputTokens, priorInputTokens: priorInput }, - }; - await this.input.appendMessage(note).catch(() => {}); - } - // Fail closed: reset on every step boundary so a missing final - // step's usage does not leave a stale value from an earlier step. - // The reply needed more room than the declared window had - // left after this request's own input. Both halves are the - // provider's numbers, read after the fact: the reserve that - // should have kept them apart was measured from a smaller - // previous reply. Say so once per send; the next request - // folds anyway because the baseline now exceeds the window. - if ( - !contextWindowOverrunNoteWritten && - midTurnState?.capacity !== undefined && - stepUsage !== undefined && - Number.isFinite(stepUsage.inputTokens) && - stepUsage.inputTokens > 0 && - Number.isFinite(stepUsage.outputTokens) && - stepUsage.outputTokens > 0 && - stepUsage.inputTokens + stepUsage.outputTokens > midTurnState.capacity - ) { - contextWindowOverrunNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_window_overrun', - data: { - usedTokens: stepUsage.inputTokens + stepUsage.outputTokens, - declaredContextWindow: midTurnState.capacity, - }, - }; - await this.input.appendMessage(note).catch(() => {}); - } - // Nothing declared, and the provider accepted a request past - // the window this model reports. Every other signal in this - // design stays dark there: no rejection to recover from, no - // plateau to read, and no declaration to arm the proactive - // threshold, so the session degrades quietly and - // indefinitely (#4634). Report the two real numbers and - // leave the decision with the user: a reported window is a - // hint, and Maka still declares nothing on their behalf. - // - // Once per crossing, not once per send. On these providers - // usage keeps growing past the line (305K → 322K observed), - // so the note fires on the transition: the previous accepted - // total was still inside the reported window and this one is - // not. The baseline carries that previous total across - // sessions through the persisted anchor, so a resumed - // session does not repeat a crossing it already reported. - if ( - !contextReportedWindowNoteWritten && - midTurnState !== undefined && - midTurnState.capacity === undefined && - stepUsage !== undefined && - Number.isFinite(stepUsage.inputTokens) && - stepUsage.inputTokens > 0 && - Number.isFinite(stepUsage.outputTokens) - ) { - const reported = resolveSelectedModelContextWindow( - this.input.connection, - this.input.modelId, - ); - const used = stepUsage.inputTokens + Math.max(0, stepUsage.outputTokens); - // `baselineTokens` still describes the request before this - // one: the capacity hook sets it from the previous step, or - // from the persisted anchor on a send's first request. - const previousTotal = midTurnState.baselineTokens; - const crossedNow = - reported !== undefined && - used > reported && - (previousTotal === undefined || previousTotal <= reported); - if (reported !== undefined && crossedNow) { - contextReportedWindowNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_reported_window_exceeded', - data: { usedTokens: used, reportedContextWindow: reported }, - }; - await this.input.appendMessage(note).catch(() => {}); - } - } - lastStepInputTokens = stepUsage?.inputTokens; - lastStepOutputTokens = stepUsage?.outputTokens; - lastStepActiveToolCount = activeToolsForRequest.length; - // A `finishReason: length` is deliberately not a trigger. The - // reply may have been cut because the provider ran out of - // window room, or because the provider's own output cap is - // lower than the one Maka sends. Those are indistinguishable - // from outside, and an indistinguishable signal must not - // drive an action; the cut reply is visible to the user - // either way (#4559). - if (stepUsage) { - completedStepUsage = mergeNormalizedUsage(completedStepUsage, stepUsage); - this.cumulativeUsageCheckpoint = mergeNormalizedUsage( - this.cumulativeUsageCheckpoint, - stepUsage, - ); - await this.input.recordUsageCheckpoint?.({ - ...this.cumulativeUsageCheckpoint, - costUsd: this.computeTokenUsageCostUsd(this.cumulativeUsageCheckpoint), - }); - } - } - } - if (event.kind === 'text-start') { - if (stepText.length > 0 && event.providerItemBoundary === true) { - await flushStep(); - currentStepMessageId = this.newId(); - } - stepTextPartStartOffset = stepText.length; - } else if (event.kind === 'text') { - if (event.text.length > 0) recordStepContent('text'); - stepText += event.text; - if (event.text.length > 0) attemptSawText = true; - queue.push({ - type: 'text_delta', - id: this.newId(), - turnId, - ts: this.now(), - messageId: currentStepMessageId, - text: event.text, - } satisfies TextDeltaEvent); - } else if (event.kind === 'text-end') { - if (event.providerOptions !== undefined) { - attemptSawContinuationMetadata = true; - stepTextProviderOptions = mergeTextProviderOptions( - stepTextProviderOptions, - stripUndefinedDeep(event.providerOptions) as NonNullable< - ModelMessage['providerOptions'] - >, - stepTextPartStartOffset, - ); - } - if (event.providerItemBoundary === true) { - await flushStep(); - currentStepMessageId = this.newId(); - } - } else if (event.kind === 'thinking-start') { - if (event.providerOptions !== undefined) { - attemptSawContinuationMetadata = true; - } - const part: AssistantThinkingPart = { - text: '', - ...(event.providerOptions !== undefined - ? { providerOptions: event.providerOptions } - : {}), - }; - stepThinkingParts.push(part); - if (event.reasoningPartId) { - stepThinkingPartsById.set(event.reasoningPartId, part); - } - } else if (event.kind === 'thinking') { - if (event.text.length > 0) recordStepContent('thinking'); - if (event.text.length > 0) attemptSawThinking = true; - if (event.providerOptions !== undefined) { - if (event.providerOptionsOrigin !== 'maka_transport') { - attemptSawContinuationMetadata = true; - } - } - const partId = - event.reasoningPartId ?? responsesReasoningItemId(event.providerOptions); - let part: AssistantThinkingPart | undefined; - if (typeof partId === 'string' && partId.length > 0) { - part = stepThinkingPartsById.get(partId); - if ( - part && - event.providerOptions === undefined && - decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' - ) { - // The SDK does not suppress a stray delta after - // output_item.done. Keep it out of the finalized item or - // its durable summary boundaries will no longer match. - part = { text: '' }; - stepThinkingParts.push(part); - stepThinkingPartsById.set(partId, part); - } - if (!part) { - part = { text: '' }; - stepThinkingParts.push(part); - stepThinkingPartsById.set(partId, part); - } - } else { - part = stepThinkingParts.at(-1); - if ( - part && - decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' - ) { - // An invalid next item has no usable stream id. Do not - // append its deltas to the finalized item: partial-error - // flush must keep that item's durable boundaries valid. - part = undefined; - } - } - if (!part) { - part = { text: '' }; - stepThinkingParts.push(part); - } - const nextPartText = part.text + event.text; - if ( - event.reasoningSummaryText !== undefined && - event.reasoningSummaryText !== nextPartText - ) { - throw new Error( - 'Streamed plaintext Responses reasoning does not match final provider summary', - ); - } - part.text = nextPartText; - if (event.providerOptions !== undefined) { - part.providerOptions = event.providerOptions; - } - queue.push({ - type: 'thinking_delta', - id: this.newId(), - turnId, - ts: this.now(), - messageId: currentStepMessageId, - text: event.text, - } satisfies ThinkingDeltaEvent); - } else if (event.kind === 'thinking-signature') { - attemptSawContinuationMetadata = true; - let part = event.reasoningPartId - ? stepThinkingPartsById.get(event.reasoningPartId) - : stepThinkingParts.at(-1); - if (!part) { - part = { text: '' }; - stepThinkingParts.push(part); - if (event.reasoningPartId) { - stepThinkingPartsById.set(event.reasoningPartId, part); - } - } - part.signature = event.signature; - } else if (event.kind === 'provider-tool-input') { - // The provider has started its own tool. Even without a - // final tool-call/result event, retrying can repeat external - // work that the Runtime cannot observe or reconcile. - attemptSawToolActivity = true; - } else if (event.kind === 'tool-call') { - attemptSawToolActivity = true; - recordStepContent('tools'); - if (event.toolCall.providerExecuted) { - providerToolActivityCount += 1; - providerToolInputs.set(event.toolCall.toolCallId, event.toolCall.input); - queue.push({ - type: 'tool_start', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId: event.toolCall.toolCallId, - toolName: event.toolCall.toolName, - args: event.toolCall.input, - providerExecuted: true, - activityKind: 'websearch', - displayName: 'Web search', - stepId: currentStepMessageId, - ...(event.toolCall.providerOptions !== undefined - ? { - providerOptions: stripUndefinedDeep(event.toolCall.providerOptions), - } - : {}), - } satisfies ToolStartEvent); - } else { - returnedToolCalls.push(event.toolCall); - } - } else if (event.kind === 'provider-tool-result') { - attemptSawToolActivity = true; - providerToolActivityCount += 1; - const providerOutput = stripUndefinedDeep(event.output); - queue.push({ - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId: event.toolCallId, - providerExecuted: true, - ...(providerOutput !== undefined ? { providerOutput } : {}), - isError: event.isError === true, - content: providerToolResultContent( - event.toolName, - providerOutput, - providerToolInputs.get(event.toolCallId), - ), - } satisfies ToolResultEvent); - providerToolInputs.delete(event.toolCallId); - } else if (event.kind === 'step-finish' && !incompleteFinish) { - // The step's text/thinking deltas are all in (the stream is - // drained in order), so flush this step's AssistantMessage and - // rotate to a fresh id for the next step. Tool settlement - // below receives this step's pre-rotation id, so durable replay - // can regroup calls with this reasoning/text. - await flushStep(); - if (midTurnState) { - // Durability clock: step N's thinking/text completion events - // are enqueued by flushStep just above, so only after this - // boundary can a seq-ack wait for step N mean anything. Wake - // waiters AFTER the increment or they would re-check a stale - // count and sleep. - midTurnState.flushedSteps += 1; - queue.wake(); - } - } - } - watchdogState.current?.stop(); - // This timeout belongs to the physical request that just settled. - // Consume it before recovery/flush work: a later persistence error - // must not be reported as the already-handled watchdog timeout. - const settledWatchdogTimeout = consumeWatchdogTimeout(); - providerOutcome = await result.outcome; - const incompleteStreamTerminal = providerOutcome.kind === 'truncated'; - const incompleteStreamHasNoObservableOutput = - incompleteStreamTerminal && - !attemptSawText && - !attemptSawThinking && - !attemptSawToolActivity && - !attemptSawContinuationMetadata; - const attemptFailure = - settledWatchdogTimeout?.error ?? - (providerOutcome.kind === 'completed' ? undefined : providerOutcome.failure); - - if (attemptFailure && !scope.aborted) { - const failure = - settledWatchdogTimeout || providerOutcome.kind === 'completed' - ? this.modelAdapter.normalizeFailure(attemptFailure) - : providerOutcome.failure; - if (scope.loopStopRequested) { - terminalProviderError = settledWatchdogTimeout?.error ?? failure; - terminalProviderErrorReason = - lastCompletedStepHadToolResult && failure.kind === 'timeout' - ? 'model_after_tool_timeout' - : undefined; - break agentLoop; - } - // A retry is a fresh provider request that would run at least one - // more step; with the send-level budget already spent there is - // nothing left to grant it, so the error is terminal. - const stepBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps; - const recovered = - stepBudgetRemains && attemptHasNoObservableOutput() - ? await this.compaction.recoverFromOverflowError({ - error: attemptFailure, - retryAlreadyUsed: - overflowRetryUsed || (midTurnState?.compactionAttemptedThisSend ?? false), - midTurnState, - turnId, - stepNumber: runtimeSteps, - currentMessages: attemptMessages, - activeTools: activeToolsForRequest, - queue, - onDiagnosticPatch: onMidTurnDiagnosticPatch, - origin: scope, - ...(this.automaticMemoryCompactionSupported() - ? { - memoryCompactionDecision: () => - this.automaticMemoryCompactionDecision(), - onMemoryCompaction: (dispatch: AutomaticMemoryCompactionDispatch) => - this.dispatchAutomaticMemoryCompaction(scope, dispatch), - } - : {}), - abortSignal: turnAbortController.signal, - }) - : undefined; - if (recovered) { - overflowRetryUsed = true; - // Recovery rebuilds the request from the durable ledger, whose - // tool results intentionally retain their full bodies. Re-enter - // the active-result projection before dispatch so an archived - // result cannot reappear in provider context on the retry. - const recoveredProjection = activeToolResultPruneHook - ? await activeToolResultPruneHook({ - completedSteps: completedProviderSteps, - stepNumber: runtimeSteps, - model, - messages: recovered.messages, - activeTools: activeToolsForRequest, - resolveDispatch, - }) - : undefined; - attemptMessages = recoveredProjection?.messages ?? recovered.messages; - continue; - } - // Window suggestion (#4559): the provider rejected a request and - // no recovery is left — the one fold is spent, or there was no - // seam. The baseline is a proven-fit total (input + output of an - // accepted request), so it is a number the user can declare; the - // trigger is `>=`, so declaring exactly it folds before this - // point next time. Once per send, and only when the turn is - // about to surface the error rather than continue. - const acceptedTotal = midTurnState?.lastAcceptedTotalTokens; - if ( - !contextWindowSuggestionNoteWritten && - failure.kind === 'context_overflow' && - midTurnState && - acceptedTotal !== undefined && - (midTurnState.capacity === undefined || acceptedTotal < midTurnState.capacity) - ) { - contextWindowSuggestionNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_window_suggestion', - data: { - suggestedContextWindow: acceptedTotal, - ...(midTurnState.capacity !== undefined - ? { declaredContextWindow: midTurnState.capacity } - : {}), - }, - }; - await this.input.appendMessage(note).catch(() => {}); - } - // A folded projection was selected in this send and the provider - // still rejects the request. That is worth saying, because the - // usual remedy has already been applied; it is NOT proof that the - // new message alone is the cause, since what remains also carries - // the system prompt, the tool schemas, the summary and the recent - // tail. A fold that failed open is deliberately excluded: that - // request went out with its full raw history, so nothing about - // its size can be concluded (#4559). - if ( - !contextOverflowAfterCompactionNoteWritten && - failure.kind === 'context_overflow' && - midTurnState?.compactionAppliedThisSend === true - ) { - contextOverflowAfterCompactionNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_overflow_after_compaction', - }; - await this.input.appendMessage(note).catch(() => {}); - } - const idleWatchdogRecovery = - settledWatchdogTimeout?.phase === 'idle' && - idleWatchdogRetryCount < MAX_IDLE_WATCHDOG_RETRIES_PER_STEP && - attemptCanRecoverWithSealedThinking(); - const incompleteStreamRecovery = - incompleteStreamTerminal && - incompleteStreamRetryCount < MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP && - incompleteStreamHasNoObservableOutput; - // Same seal-and-retry contract as the watchdog path, entered when - // the failure arrives as a retryable provider/network error - // instead of a local idle timeout. `!idleWatchdogRecovery` keeps - // every watchdog-shaped outcome on its existing path, and - // `!attemptHasNoObservableOutput()` keeps no-output retries on - // the plain budget so this one is spent only on sealed fragments. - const sealedThinkingRecovery = - !idleWatchdogRecovery && - failure.retryable && - sealedThinkingRetryCount < MAX_SEALED_THINKING_RETRIES_PER_STEP && - attemptCanRecoverWithSealedThinking() && - !attemptHasNoObservableOutput(); - if ( - (failure.retryable || idleWatchdogRecovery || incompleteStreamRecovery) && - failure.kind !== 'context_overflow' && - providerAttempt < MAX_PROVIDER_ATTEMPTS_PER_STEP && - stepBudgetRemains && - (attemptHasNoObservableOutput() || - idleWatchdogRecovery || - incompleteStreamRecovery || - sealedThinkingRecovery) - ) { - if (idleWatchdogRecovery) idleWatchdogRetryCount += 1; - if (sealedThinkingRecovery) sealedThinkingRetryCount += 1; - if (incompleteStreamRecovery) incompleteStreamRetryCount += 1; - if ( - (idleWatchdogRecovery || sealedThinkingRecovery) && - stepThinkingParts.length > 0 - ) { - await flushStep(); - currentStepMessageId = this.newId(); - } - // The failed request did not return authoritative usage. Keep - // effectiveness recoverable, but fail final metering closed. - sawUnusableStepUsage = true; - const delayMs = providerRetryDelayMs(providerAttempt, failure.retryAfterMs); - const nextAttempt = providerAttempt + 1; - const maxAttempts = - idleWatchdogRecovery || incompleteStreamRecovery || sealedThinkingRecovery - ? nextAttempt - : MAX_PROVIDER_ATTEMPTS_PER_STEP; - const reason = providerRetryReason(failure.kind); - queue.push({ - type: 'provider_retry', - id: this.newId(), - turnId, - ts: this.now(), - phase: 'scheduled', - attempt: nextAttempt, - maxAttempts, - delayMs, - remainingMs: delayMs, - reason, - } satisfies ProviderRetryEvent); - await this.providerRetrySleep(delayMs, turnAbortController.signal); - providerAttempt = nextAttempt; - queue.push({ - type: 'provider_retry', - id: this.newId(), - turnId, - ts: this.now(), - phase: 'started', - attempt: providerAttempt, - maxAttempts, - reason, - } satisfies ProviderRetryEvent); - continue; - } - // Unrecoverable (not context-length, latch spent, no seam, or no - // safe fold): surface the real provider error via the terminal - // handler after settling any authoritative usage — never a - // fabricated success. - terminalProviderError = settledWatchdogTimeout?.error ?? failure; - terminalProviderErrorReason = - lastCompletedStepHadToolResult && failure.kind === 'timeout' - ? 'model_after_tool_timeout' - : undefined; - break agentLoop; - } - break; - } - - // If the stream loop exited because stop() flipped scope.aborted while a - // provider kept yielding after abort instead of throwing, route to the - // abort handling below. Without this, the post-stream success path would - // persist a partial assistant turn and emit a false end_turn completion. - if (scope.aborted) { - throw Object.assign(new Error('aborted'), { name: 'AbortError' }); - } - - // 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; - await flushStep(); - - if (providerOutcome.kind !== 'completed') throw providerOutcome.failure; - finishReason = providerOutcome.finishReason; - await queue.waitUntilConsumedThroughCurrent(); - - if (returnedToolCalls.length > 0) { - const continuationBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps; - if (continuationBudgetRemains && !this.input.loadTurnRuntimeEvents) { - throw new Error('durable current-run reader is required for tool continuation'); - } - if (this.input.loadTurnRuntimeEvents) { - // Queue consumption alone does not prove that the latest assistant - // facts remain readable. Fail before any external tool side effect - // when the authoritative ledger became unavailable after the step. - await loadDurableTurnEvents(); - } - const toolsByName = new Map(providerTools.map((tool) => [tool.name, tool])); - const settlementOutcomes = await Promise.allSettled( - returnedToolCalls.map(async (toolCall) => { - if (toolCall.providerExecuted) { - throw new Error( - `Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`, - ); - } - const sandboxBoundaryAttempt = isProviderSandboxBoundaryAttempt(toolCall); - const deniedBoundaryRequest = - toolRuntime.hasSandboxBoundaryDenial() && - toolCall.toolName.toLowerCase() === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME; - if (deniedBoundaryRequest) { - toolRuntime.forceSandboxBoundaryFinalization(); - } - const blockedToolCall = sandboxBoundaryFinalizationStep || deniedBoundaryRequest; - const requestedTool = blockedToolCall - ? undefined - : toolsByName.get(toolCall.toolName); - const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME); - if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable'); - const unavailableError = sandboxBoundaryFinalizationStep - ? 'Sandbox boundary finalization does not permit tool execution.' - : deniedBoundaryRequest - ? SANDBOX_BOUNDARY_DENIED_FOR_TURN - : 'returned tool is unavailable'; - return await toolRuntime.settleToolCall({ - tool, - turnId, - stepId: providerStepId, - toolCallId: toolCall.toolCallId, - // Provider metadata is persisted verbatim into an immutable - // RuntimeEvent, and a field the response did not carry - // arrives as an explicit `undefined` — which JSON drops, so - // the event no longer reads back as it was written and the - // store refuses it. One refusal took every tool-calling turn - // with it. - ...(toolCall.providerOptions !== undefined - ? { - providerOptions: stripUndefinedDeep(toolCall.providerOptions), - } - : {}), - input: - requestedTool !== undefined - ? toolCall.input - : { - tool: toolCall.toolName, - error: unavailableError, - ...(sandboxBoundaryAttempt ? { sandboxBoundaryAttempt: true } : {}), - }, - abortSignal: turnAbortController.signal, - eventSink: queue, - }); - }), - ); - const rejectedSettlement = settlementOutcomes.find( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', - ); - if (rejectedSettlement) throw rejectedSettlement.reason; - const settlements = settlementOutcomes.map((outcome) => { - // A rejected settlement was handled above, so preserving the - // original array shape also preserves tool-call identity by index. - if (outcome.status === 'rejected') throw outcome.reason; - return outcome.value; - }); - for (let index = 0; index < settlements.length; index += 1) { - const settlement = settlements[index]!; - const toolCall = returnedToolCalls[index]; - if (isPlanToolResult(settlement.result)) { - this.handlePlanToolResult(scope, settlement.result, queue); - } - if ( - returnedToolCalls.length === 1 && - toolCall?.toolName === YIELD_AGENT_GRAPH_TOOL_NAME && - isAgentGraphYieldToolResult(settlement.result) - ) { - this.handleAgentGraphYieldToolResult(scope, settlement.result); - } - } - await queue.waitUntilConsumedThroughCurrent(); - - const continuationWillRun = - (maxSteps === undefined || runtimeSteps < maxSteps) && - !scope.loopStopRequested && - !scope.aborted; - if (continuationWillRun && providerOutcome.continuation === 'pending') { - const persistedProjection = await loadDurableTurnProjection(); - const responseMessages = persistedOpenAiResponsesStepMessages( - attemptMessages, - persistedProjection, - returnedToolCalls.map((toolCall) => toolCall.toolCallId), - ); - if (responseMessages) { - this.modelAdapter.recordContinuationResponse(scope.turnId, responseMessages); - } else { - this.modelAdapter.clearContinuation(scope.turnId); - } - } - } - - completedProviderSteps.push({ - toolCalls: returnedToolCalls, - ...(providerStepUsage ? { usage: providerStepUsage } : {}), - }); - lastCompletedStepHadToolResult = returnedToolCalls.length > 0; - const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; - if ( - sandboxBoundaryFinalizationStep || - (stepLimitReached && - (toolRuntime.shouldFinalizeSandboxBoundary() || - toolRuntime.hasSandboxBoundaryDenial())) - ) { - scope.loopStopReason = 'permission_handoff'; - scope.loopStopRequested = true; - } - const mayTakeAnotherStep = - !stepLimitReached && !scope.loopStopRequested && !scope.aborted; - if (returnedToolCalls.length > 0 && mayTakeAnotherStep) { - currentStepMessageId = this.newId(); - continue agentLoop; - } - // Continuing the turn needs the durable current-run reader, for the - // same reason the tool-call edge above demands it: the next request - // has to carry the assistant output this step just produced, and only - // the ledger projection has it. The no-reader fallback at the top of - // the loop appends steering alone, which would ask the model to - // redirect work it cannot see. Without a reader this edge is skipped - // rather than throwing — the turn still completes and the Host folds - // the message into the next Turn, which is today's behaviour. - if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) { - // Last chance for a steer that landed after this turn's final - // tool-call boundary — including the only boundary a tool-free - // turn has, which precedes the model's first token. Without it the - // message is never pulled at all, and whether Steer works would - // depend on the model happening to call a tool afterwards (#3529). - // A step-limited turn deliberately skips this: its budget is spent, - // and the Host folds the message into the next Turn instead. - const injectedBefore = scope.injectedSteeringMessages.length; - await this.drainSteeringInto(scope, input, queue); - // Re-read the stop flags: the drain awaits a durable push, so an - // `after_step` stop or an abort can land while it is in flight, and - // `mayTakeAnotherStep` is stale by now. Stop wins — the message is - // already durable, so the Host folds it into the next Turn. - if ( - scope.injectedSteeringMessages.length > injectedBefore && - !scope.loopStopRequested && - !scope.aborted - ) { - currentStepMessageId = this.newId(); - continue agentLoop; - } - } - break agentLoop; - } - - // Refine Tool Availability against the final active set. Deferred - // loading may add tools, while boundary convergence may remove them; - // comparing membership avoids missing a same-size swap. - const finalActiveTools = currentRepairToolNames(); - if ( - finalActiveTools.length !== activeTools.length || - finalActiveTools.some((name, index) => name !== activeTools[index]) - ) { - toolAvailabilityForTelemetry = computeToolAvailability(finalActiveTools); - } - - // Final usage event. Each adapter result covers one provider request. - // The send-level owner is `completedStepUsage`, which spans every - // Runtime loop step and retry. Recording only the final result would - // silently drop prior requests. An unusable sample in ANY request fails - // the whole record closed (#972). - try { - const attemptTotalUsage = providerOutcome.usage; - tokenUsage = sawUnusableStepUsage ? undefined : (completedStepUsage ?? attemptTotalUsage); - if (tokenUsage) { - tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage); - const contextBudgetForUsage = contextBudgetWithRequestProjectionDiagnostics( - contextBudgetForTelemetry, - activeToolResultPruneDiagnosticPatch, - midTurnCompactDiagnosticPatch, - ); - // Persisted alongside the live event so transcript rebuilds from - // stored messages keep the TUI ctx segment instead of degrading to - // `?/` (#4019). Computed once; both writers share it. - const contextRemainingForUsage = (() => { - const contextWindow = resolveSelectedModelContextWindow( - this.input.connection, - this.input.modelId, - ); - if (lastStepInputTokens !== undefined && contextWindow !== undefined) { - return Math.max(0, contextWindow - lastStepInputTokens); - } - return undefined; - })(); - // The anchor the NEXT turn judges its first request from — see - // `LastRequestAnchor`. `input` below is the sum across this send's - // steps and anchors nothing; the LAST step's real input and output - // are what the next request re-sends. No usable input count, no - // anchor: the next turn then has no proactive fold until its first - // accepted request. - const anchorInputTokens = finitePositive(lastStepInputTokens); - const anchorOutputTokens = - lastStepOutputTokens !== undefined && Number.isFinite(lastStepOutputTokens) - ? Math.max(0, lastStepOutputTokens) - : undefined; - // 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. - const usageFields = { - input: tokenUsage.inputTokens, - output: tokenUsage.outputTokens, - cacheHitInput: tokenUsage.cacheHitInputTokens, - cacheMissInput: tokenUsage.cacheMissInputTokens, - cacheMissInputSource: tokenUsage.cacheMissInputSource, - cacheWriteInput: tokenUsage.cacheWriteInputTokens, - reasoning: tokenUsage.reasoningTokens, - total: tokenUsage.totalTokens, - ...(tokenUsage.rawFinishReason !== undefined - ? { rawFinishReason: tokenUsage.rawFinishReason } - : {}), - ...(runtimeSteps > 0 ? { runtimeSteps } : {}), - ...(tokenUsage.cachedInputTokens > 0 - ? { cacheRead: tokenUsage.cachedInputTokens } - : {}), - ...(tokenUsage.cacheWriteInputTokens > 0 - ? { cacheCreation: tokenUsage.cacheWriteInputTokens } - : {}), - ...(tokenUsageCostUsd !== undefined ? { costUsd: tokenUsageCostUsd } : {}), - ...(contextBudgetForUsage ? { contextBudget: contextBudgetForUsage } : {}), - ...(contextRemainingForUsage !== undefined - ? { contextRemaining: contextRemainingForUsage } - : {}), - ...(providerRequestTraceId ? { providerRequestTraceId } : {}), - ...(anchorInputTokens !== undefined - ? { - lastRequestAnchor: { - inputTokens: anchorInputTokens, - ...(anchorOutputTokens !== undefined - ? { outputTokens: anchorOutputTokens } - : {}), - modelId: this.input.modelId, - ...(this.input.header.llmConnectionId !== undefined - ? { connectionId: this.input.header.llmConnectionId } - : {}), - }, - } - : {}), - }; - const tu: TokenUsageMessage = { - type: 'token_usage', - id: this.newId(), - turnId, - ts: this.now(), - ...usageFields, - }; - await this.input.appendMessage(tu).catch(() => {}); - if ( - !contextCompactionFailedOpenNoteWritten && - shouldAppendContextCompactionFailedOpenNote(contextBudgetForUsage) - ) { - contextCompactionFailedOpenNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_compaction_failed_open', - }; - await this.input.appendMessage(note).catch(() => {}); - } - if ( - !contextCompactedNoteWritten && - shouldAppendContextCompactedNote(contextBudgetForUsage) - ) { - contextCompactedNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.newId(), - turnId, - ts: this.now(), - kind: 'context_compacted', - }; - await this.input.appendMessage(note).catch(() => {}); - } - queue.push({ - type: 'token_usage', - id: this.newId(), - turnId, - ts: this.now(), - ...usageFields, - } satisfies TokenUsageEvent); - } - } catch { - // best-effort; ai-sdk usage promise may reject on abort - } - - // Nothing may await between this check and terminal emission: Stop must - // win even when it arrives during post-stream usage persistence. - if (scope.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); - if (terminalProviderError) throw terminalProviderError; - const stopReason = - scope.loopStopReason ?? - (maxSteps !== undefined && finishReason === 'tool-calls' - ? 'step_limit' - : this.mapFinishReason(finishReason)); - trace.modelStreamCompleted(stopReason); - const completeEvent = { - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason, - } satisfies CompleteEvent; - queue.push(completeEvent); - if (scope.memoryExtractRequested && this.input.memoryExtraction) { - const snapshot = this.memorySourceSnapshotFromScope(scope, { - trigger: 'extract', - terminalEventId: completeEvent.id, - }); - if (snapshot) { - void queue - .waitUntilConsumedThroughCurrent() - .then(() => this.input.memoryExtraction?.extract(snapshot)) - .catch(() => undefined); - } - } - } catch (err) { - streamStatus = scope.aborted ? 'aborted' : 'error'; - streamErrorClass = this.modelAdapter.classifyError(currentWatchdogTimeout()?.error ?? err); - // Flush the in-flight step's partial text/thinking before the terminal - // abort/error events. Earlier steps already flushed at their - // `finish-step`; this keeps their and this step's streamed-out output on - // BOTH exits — user stop and provider error / watchdog timeout — so - // partialOutputRetained reflects what the user actually saw. - await flushStep().catch(() => {}); - if (scope.aborted) { - queue.push({ - type: 'abort', - id: this.newId(), - turnId, - ts: this.now(), - reason: 'user_stop', - } satisfies AbortEvent); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'user_stop', - } satisfies CompleteEvent); - } else { - const terminalError = currentWatchdogTimeout()?.error ?? err; - queue.push(this.makeErrorEvent(turnId, terminalError, terminalProviderErrorReason)); - trace.modelStreamFailed( - streamErrorClass, - terminalError, - priorReplayFailureTrace(priorReplay), - ); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'error', - } satisfies CompleteEvent); - } - } finally { - watchdogState.current?.stop(); - if (scope.watchdog === watchdogState.current) scope.watchdog = null; - contextBudgetForTelemetry = contextBudgetWithRequestProjectionDiagnostics( - contextBudgetForTelemetry, - activeToolResultPruneDiagnosticPatch, - midTurnCompactDiagnosticPatch, - ); - // `tokenUsage` still backfills from the completed steps when the send - // ended without a final `usage`: the terminal outcome and the - // `token_usage` SessionEvent below both read it. An unusable sample in - // any step fails it closed rather than posing a partial sum as the - // whole call (#972). - // - // The send-level `recordLlmCall` that used to sit here is gone (#1679). - // It measured the same provider requests the canonical seam now settles - // into `ModelCallAttempt`, one record per physical request instead of - // one aggregate per send, and keeping both would have been two - // independent meters free to disagree. - // - // What does NOT follow it out is the diagnostics that rode on it. The - // exhausted and aborted paths emit no `token_usage` SessionEvent, so - // their compaction decisions and the accumulated usage of the steps that - // did complete had that record as their only durable home. They move to - // the run trace, which carries no cost and meters nothing. - if (!tokenUsage && completedStepUsage && !sawUnusableStepUsage) { - tokenUsage = completedStepUsage; - tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage); - } - trace.sendDiagnostics({ - status: streamStatus, - ...(streamErrorClass ? { errorClass: streamErrorClass } : {}), - ...(tokenUsage - ? { - inputTokens: tokenUsage.inputTokens, - outputTokens: tokenUsage.outputTokens, - totalTokens: tokenUsage.totalTokens, - } - : {}), - ...(contextBudgetForTelemetry !== undefined - ? { contextBudget: contextBudgetForTelemetry } - : {}), - ...(toolAvailabilityForTelemetry !== undefined - ? { toolAvailability: toolAvailabilityForTelemetry } - : {}), - }); - queue.close(); - } - })(); - - let drainedNormally = false; - try { - // drain() carries the seq-ack semantics (consumer pull = processed ack); - // every consumer-facing path must go through it. - yield* this.drain(queue); - drainedNormally = true; - } finally { - if (!drainedNormally) turnAbortController.abort(); - await pumpDone.catch(() => {}); - } + this.activeTurns.add(turn); + return turn; } - private async executeCodeModeCell( - scope: TurnScope, - eventSink: DurableSessionEventSink, - code: string, - context: MakaToolContext, - ): Promise { - const snapshot = new Map(scope.codeModeTools); - let nestedOutputBytes = 0; - let nestedOutputLimitExceeded = false; - const nestedEventSink: DurableSessionEventSink = { - push: (event) => { - if (event.type === 'tool_output_delta') { - const nextBytes = new TextEncoder().encode(event.chunk).byteLength; - if ( - nestedOutputLimitExceeded || - nestedOutputBytes + nextBytes > DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes - ) { - nestedOutputLimitExceeded = true; - return; - } - nestedOutputBytes += nextBytes; - } - eventSink.push(event); - }, - pushAndWaitUntilConsumed: (event) => eventSink.pushAndWaitUntilConsumed(event), - }; - // A permit is held across the cell's complete lifecycle, not just its - // sandbox run: `executeCodeCell` settles only once the cell's host - // operations have drained, so releasing on settlement covers the drain. - // The sandbox worker cap cannot serve this purpose — on cancellation - // `runCodeMode` releases its worker and rejects at once, by design, while - // host operations started by the cell may still be running with durable - // side effects. Only the Runtime waits for those, so only the Runtime can - // bound them; releasing when the worker is released would let repeated - // cancellation accumulate host work without bound. - // - // One cell may wait; the next is turned away rather than queued, which is - // what the Code Mode adapter did before this moved to the side that owns - // execution. Nothing awaits between reading `waitingCount` and the enqueue - // inside `acquire`, so the pair is atomic. - if (this.codeCellAdmission.waitingCount >= MAX_WAITING_CODE_MODE_CELLS) { - return { - ok: false, - error: { kind: 'limit_exceeded', message: 'Code Mode execution queue is full' }, - toolCalls: [], - } satisfies CodeModeExecutionResult; - } - const permit = await this.codeCellAdmission.acquire(context.abortSignal); + async *send(input: BackendSendInput): AsyncIterable { + const turn = this.openTurnScope(input); try { - return await executeCodeCell({ - code, - signal: context.abortSignal, - tools: [...snapshot.values()].map((tool) => ({ - name: tool.name, - })), - isFatalToolError: isRuntimeCommitBoundaryError, - callTool: async (name, input, signal) => { - const tool = snapshot.get(name); - if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); - const parsedInput = await validateCodeModeToolInput(tool, input); - const settlement = await scope.toolRuntime.settleToolCall({ - tool, - turnId: context.turnId, - toolCallId: `${context.toolCallId}:nested:${this.newId()}`, - input: parsedInput, - abortSignal: signal, - eventSink: nestedEventSink, - origin: 'code_mode', - parentToolCallId: context.toolCallId, - ...(context.operationId ? { parentOperationId: context.operationId } : {}), - maxResultBytes: DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes, - }); - if (settlement.providerError !== undefined) { - throw new Error(settlement.providerError); - } - if (nestedOutputLimitExceeded) { - throw new Error('Code Mode nested output byte limit exceeded'); - } - return settlement.result; - }, - }); + yield* turn.run(); } finally { - permit.release(); - } - } - - private handlePlanToolResult( - scope: TurnScope, - result: PlanToolResult, - queue: AsyncEventQueue, - ): void { - const turnId = scope.turnId; - if (result.kind === 'plan_submitted') { - const proposal = result.proposal; - queue.push({ - type: 'plan_submitted', - id: this.newId(), - turnId, - ts: this.now(), - planId: proposal.planId, - proposalId: proposal.proposalId, - revision: proposal.revision, - title: proposal.title, - ...(proposal.overview ? { overview: proposal.overview } : {}), - ...(proposal.risks ? { risks: proposal.risks } : {}), - steps: proposal.steps.map((step) => ({ ...step, status: 'pending' })), - }); - scope.runTrace?.emit('plan', 'plan_submitted', 'Plan submitted', { - planId: proposal.planId, - proposalId: proposal.proposalId, - revision: proposal.revision, - storeVersion: result.storeVersion, - }); - scope.loopStopReason = 'plan_handoff'; - scope.loopStopRequested = true; - return; + this.activeTurns.delete(turn); + await turn.close(); } - - const traceType = result.kind; - scope.runTrace?.emit('plan', traceType, 'Plan execution state changed', { - planId: result.execution.planId, - proposalId: result.execution.proposalId, - executionId: result.execution.executionId, - storeVersion: result.storeVersion, - }); - // Completing or cancelling the execution is a tool boundary, not the end of - // the conversational Turn. The execution prompt tells the model to persist - // final progress before its final response, so let it consume this result - // and produce that response on the next provider step. } - private handleAgentGraphYieldToolResult( - scope: TurnScope, - result: YieldAgentGraphToolResult, - ): void { - scope.runTrace?.emit('agent_graph', 'graph_supervisor_yielded', 'Graph supervisor yielded', { - pendingWorkCount: result.pendingWorkCount, - liveOperatorCount: result.liveOperatorCount, - reason: result.reason, - }); - scope.loopStopReason = 'graph_yield'; - scope.loopStopRequested = true; - } - - // -------------------------------------------------------------------------- - // Helpers - // -------------------------------------------------------------------------- - /** * Stop every turn this backend is currently running. * @@ -3421,23 +543,14 @@ export class AiSdkBackend implements AgentBackend { _reason: 'user_stop' | 'redirect', mode: 'immediate' | 'after_step' = 'immediate', ): Promise { - const scopes = [...this.activeTurns]; + const turns = [...this.activeTurns]; if (mode === 'after_step') { - for (const scope of scopes) { - scope.loopStopRequested = true; - scope.runTrace?.abortRequested(_reason); - } + for (const turn of turns) turn.requestStop(_reason, mode); return; } this.compaction.abortHistoryCompact(); - for (const scope of scopes) { - scope.aborted = true; - scope.abortController.abort(); - scope.runTrace?.abortRequested(_reason); - } - const settled = await Promise.allSettled( - scopes.map((scope) => scope.toolRuntime.endTurn('aborted')), - ); + for (const turn of turns) turn.requestStop(_reason, mode); + const settled = await Promise.allSettled(turns.map((turn) => turn.endAbortedTools())); const failures = settled.flatMap((result) => result.status === 'rejected' ? [result.reason] : [], ); @@ -3448,15 +561,15 @@ export class AiSdkBackend implements AgentBackend { async respondToSandboxBoundary(decision: SandboxBoundaryResponse): Promise { // Routed by request id, which is already the identity the registry matches // on: at most one turn parked this request. - for (const scope of this.activeTurns) { - if (await scope.toolRuntime.respondToSandboxBoundaryResponse(decision)) return; + for (const turn of this.activeTurns) { + if (await turn.respondToSandboxBoundary(decision)) return; } throw new Error(`No pending sandbox boundary request ${decision.requestId}`); } async respondToUserQuestion(response: UserQuestionResponse): Promise { - for (const scope of this.activeTurns) { - if (scope.toolRuntime.respondToUserQuestion(response)) return; + for (const turn of this.activeTurns) { + if (turn.respondToUserQuestion(response)) return; } } @@ -3465,1673 +578,4 @@ export class AiSdkBackend implements AgentBackend { else this.compaction.abortHistoryCompact(); this.modelAdapter.dispose(); } - - /** Map a completed provider finish reason → our CompleteEvent.stopReason. */ - private mapFinishReason(reason: ModelFinishReason): CompleteEvent['stopReason'] { - return this.modelAdapter.mapFinishReason(reason); - } - - private makeErrorEvent(turnId: string, err: unknown, reasonOverride?: string): ErrorEvent { - return this.modelAdapter.makeErrorEvent(turnId, err, reasonOverride); - } - - private computeTokenUsageCostUsd(usage: NormalizedAiSdkUsage): number | undefined { - try { - const pricing = (this.input.lookupPricing ?? getBuiltinPricing)( - pricingModelKey(this.input.connection.providerType, this.input.modelId), - ); - if (pricing === null) return undefined; - return computeCost( - { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cacheHitInputTokens: usage.cacheHitInputTokens, - cacheMissInputTokens: usage.cacheMissInputTokens, - cacheWriteInputTokens: usage.cacheWriteInputTokens, - }, - pricing, - ).totalCost; - } catch { - return undefined; - } - } - - /** - * One tracker for one physical provider call kind (#1679). - * - * Auxiliary calls get the same observation, attempt, and accounting plumbing the - * main send uses, built here because the sinks and the current run live on - * this backend. Callers receive a ready tracker rather than the ingredients: - * a half-wired tracker is what produces records nothing can attribute. - * - * Absent only when there is nothing to feed: no canonical sink and no - * dispatch gate. - */ - private createProviderRequestTracker(input: { - turnId: string; - callKind: ModelCallKind; - modelId: string; - historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; - /** - * Stated by every caller, never defaulted: an unattributed provider request - * is silently dropped by usage accounting, so the compiler has to be the - * thing that catches a missing run id (#1990). - */ - runId: string | undefined; - }): ProviderRequestTracker | undefined { - const accounting = this.modelCallAccounting(input.callKind, { - modelId: input.modelId, - ...(input.runId ? { runId: input.runId } : {}), - ...(input.historyCompactRoute ? { historyCompactRoute: input.historyCompactRoute } : {}), - }); - const runId = input.runId; - const beforeRunProviderDispatch = this.input.beforeRunProviderDispatch; - const beforeDispatch = - runId && beforeRunProviderDispatch - ? () => - beforeRunProviderDispatch({ - sessionId: this.sessionId, - turnId: input.turnId, - runId, - }) - : undefined; - if (!accounting && !beforeDispatch) return undefined; - return new ProviderRequestTracker({ - traceId: this.newId(), - turnId: input.turnId, - contextWindow: resolveSelectedModelContextWindow(this.input.connection, input.modelId), - now: this.now, - newId: this.newId, - ...(beforeDispatch ? { beforeDispatch } : {}), - ...(accounting ? { accounting } : {}), - }); - } - - /** - * Accounting identity for one call kind (#1679). - * - * The run is always supplied by the caller. It cannot be read back off this - * backend: one instance serves several concurrent runs, so whichever turn - * touched it last would speak for all of them (#1990). - * - * Absent when there is no canonical sink, which leaves the corresponding - * tracker purely diagnostic. - */ - private modelCallAccounting( - callKind: ModelCallKind, - identity?: { - /** The run this call is billed to; absent only when there is none. */ - runId?: string; - /** The model this call actually runs against; priced as that model. */ - modelId?: string; - historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; - }, - ): ModelCallAccountingInput | undefined { - const record = this.input.recordModelCallAttempt; - if (!record) return undefined; - const modelId = identity?.modelId ?? this.input.modelId; - return { - sessionId: this.sessionId, - resolveRunId: () => identity?.runId, - connectionSlug: this.input.connection.slug, - providerId: this.input.connection.providerType, - callKind, - ...(identity?.historyCompactRoute - ? { historyCompactRoute: identity.historyCompactRoute } - : {}), - record, - resolveCost: (usage: ProviderRequestUsage) => this.resolveModelCallCost(usage, modelId), - ...(this.input.assertModelCallAccountingReady - ? { assertReady: this.input.assertModelCallAccountingReady } - : {}), - }; - } - - /** - * Resolves cost for a canonical accounting record at settlement time, together - * with the rates it was computed against. - * - * The basis travels with the amount because a figure recomputed later from - * whatever pricing is current would silently drift from what the call actually - * cost. An unresolvable price returns `undefined` rather than zero — the - * record then carries `costBasis: 'unpriced'`, which is not the same claim as - * a call that was free. - * - * Priced against the model that actually served the request. Recording one - * model's id beside another model's rates would make the stored amount - * unauditable in exactly the way `pricingRates` exists to prevent. - */ - private resolveModelCallCost( - usage: ProviderRequestUsage, - modelId: string, - ): ResolvedModelCallCost | undefined { - try { - const pricing = (this.input.lookupPricing ?? getBuiltinPricing)( - pricingModelKey(this.input.connection.providerType, modelId), - ); - if (pricing === null) return undefined; - const costUsd = computeCost( - { - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - cacheHitInputTokens: usage.cacheReadInputTokens ?? 0, - cacheMissInputTokens: usage.cacheMissInputTokens ?? 0, - cacheWriteInputTokens: usage.cacheWriteInputTokens ?? 0, - }, - pricing, - ).totalCost; - if (costUsd === undefined || !Number.isFinite(costUsd)) return undefined; - return { costUsd, pricingRates: pricing }; - } catch { - return undefined; - } - } - - /** Materialize RuntimeEvent-derived projections into ai-sdk's message format. - * V0.1: text-only round-tripping. Tool calls / results within projected - * history are deliberately NOT replayed unless RuntimeEvent native replay - * is available for the provider. */ - private async buildPriorMessages( - scope: TurnScope, - input: BackendSendInput, - midTurnState: MidTurnCapacityCompactState | undefined, - automaticMemory?: true, - ): Promise { - const priorStored = input.context.filter((message) => message.turnId !== input.turnId); - if (!input.runtimeContext) { - return { - status: 'ready', - messages: await this.materializePriorMessages(scope.imageBudget, priorStored), - gate: 'stored_message_projection', - diagnostics: [], - }; - } - const rawPriorRuntimeContext = input.runtimeContext.filter( - (event) => event.turnId !== input.turnId, - ); - // Everything below reads EFFECTIVE model history: raw events folded through - // the durable projection-transition reducer (#4283). Replay, budgeting and - // compaction share one input, so no RuntimeEvent replay path can resurrect - // content a committed transition removed. The StoredMessage projection used - // by the degraded fallbacks below is a separate representation that the fold - // does not reach — see #4283 for that remaining gap. - const preparedContextBudget = await this.compaction.prepareContextBudgetPolicy( - rawPriorRuntimeContext, - input.turnId, - ); - const priorRuntimeContext = preparedContextBudget.events; - const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( - priorRuntimeContext, - input.runtimeContextInvocations, - this.input.providerStateIdentity, - this.input.modelId, - ); - const projectedMessages = await this.materializePriorMessages( - scope.imageBudget, - priorStored, - buildSteeringSidecar(priorRuntimeContext), - ); - let contextBudget = preparedContextBudget.policy; - const budgeted = applyRuntimeEventContextBudget(priorRuntimeContext, contextBudget); - let runtimeContext = budgeted?.events ?? priorRuntimeContext; - let contextBudgetDiagnostic = budgeted?.diagnostic; - let projectedHistoryCompactCheckpoint = budgeted?.historyCompactCheckpoint; - if (preparedContextBudget.diagnosticPatch) { - contextBudgetDiagnostic = mergeContextBudgetDiagnostic( - contextBudgetDiagnostic ?? - buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget), - preparedContextBudget.diagnosticPatch, - ); - } - - // No pre-turn estimate gate: the turn's first request is judged by the - // request-projection hook from the previous request's real usage, and by - // the provider when it goes out (#4559). - // 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 - // stood on (#2323). - const replayBoundary = (fromRuntimeReplay: boolean) => - fromRuntimeReplay && projectedHistoryCompactCheckpoint - ? { latestHistoryCompactCheckpoint: projectedHistoryCompactCheckpoint } - : {}; - - const plan = buildRuntimeEventModelReplayPlan( - runtimeContext, - // `runtimeContext` may be a budget/history-search slice; the tool-turn - // thinking skip is a whole-history invariant, so seed it from the full - // prior ledger so a sliced-in tool-turn thinking still gets skipped. - { toolActivityTurnIds: collectToolActivityTurnIds(priorRuntimeContext) }, - ); - const hasProviderHistoryCompactCheckpoint = - projectedHistoryCompactCheckpoint !== undefined && - isProviderHistoryCompactCheckpoint(projectedHistoryCompactCheckpoint); - const fallbackUsesRuntimeReplay = - Boolean(input.continuation) || hasProviderHistoryCompactCheckpoint; - // StoredMessage projection cannot represent an opaque provider checkpoint. - // Once one is selected, every degraded replay path must remain in the - // RuntimeEvent materializer so the checkpoint boundary is not bypassed. - const materializeReplayFallback = (): Promise => - fallbackUsesRuntimeReplay - ? this.materializeRuntimeReplayTextOnly( - scope.imageBudget, - plan, - projectedHistoryCompactCheckpoint, - ) - : Promise.resolve(projectedMessages); - if (plan.items.length === 0 && !hasProviderHistoryCompactCheckpoint) { - return { - status: 'ready', - messages: await materializeReplayFallback(), - gate: input.continuation ? 'runtime_replay_text_only' : 'stored_message_projection', - diagnostics: plan.diagnostics, - runtimeEventCount: runtimeContext.length, - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...replayBoundary(fallbackUsesRuntimeReplay), - }; - } - - if (hasBlockingReplayDiagnostics(plan)) { - return { - status: 'ready', - messages: await materializeReplayFallback(), - gate: input.continuation - ? 'runtime_replay_text_only' - : 'runtime_replay_unsupported_semantics', - diagnostics: plan.diagnostics, - runtimeEventCount: runtimeContext.length, - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...replayBoundary(fallbackUsesRuntimeReplay), - }; - } - - if (!plan.hasProviderNativeSemantics) { - return { - status: 'ready', - messages: await this.materializeRuntimeReplayPlan( - plan, - scope.imageBudget, - projectedHistoryCompactCheckpoint, - providerReasoningReplayEventIds, - ), - gate: 'runtime_replay_text_only', - diagnostics: plan.diagnostics, - runtimeEventCount: runtimeContext.length, - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...replayBoundary(true), - }; - } - - if (!this.canReplayProviderNative(plan)) { - // Degrade per item, not per plan: an unsupported provider-executed pair - // must not cost unrelated client tool history (#2972). Thinking items - // stay in the plan; materializeRuntimeReplayPlan degrades unsupported - // reasoning per item via reasoningReplay. - const degradedPlan = this.dropUnsupportedReplayItems(plan); - return { - status: 'ready', - messages: - degradedPlan.items.length > 0 || hasProviderHistoryCompactCheckpoint - ? await this.materializeRuntimeReplayPlan( - degradedPlan, - scope.imageBudget, - projectedHistoryCompactCheckpoint, - providerReasoningReplayEventIds, - ) - : await materializeReplayFallback(), - gate: input.continuation - ? 'runtime_replay_text_only' - : 'runtime_replay_unsupported_semantics', - diagnostics: plan.diagnostics, - runtimeEventCount: runtimeContext.length, - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...replayBoundary(fallbackUsesRuntimeReplay), - }; - } - - return { - status: 'ready', - messages: await this.materializeRuntimeReplayPlan( - plan, - scope.imageBudget, - projectedHistoryCompactCheckpoint, - providerReasoningReplayEventIds, - ), - gate: 'runtime_replay_provider_native', - diagnostics: plan.diagnostics, - runtimeEventCount: runtimeContext.length, - ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...replayBoundary(true), - }; - } - - private canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean { - const support = this.modelAdapter.runtimeEventReplaySupport(); - for (const item of plan.items) { - if (item.kind === 'tool_call' && !support.toolCalls) return false; - if (item.kind === 'tool_result' && !support.toolResults) return false; - if ( - (item.kind === 'tool_call' || item.kind === 'tool_result') && - item.providerExecuted === true && - !support.providerExecutedTools - ) { - return false; - } - if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false; - } - return true; - } - - /** - * Per-item counterpart to {@link canReplayProviderNative}: drop only the - * items the adapter cannot represent so one unsupported provider-executed - * pair does not cost unrelated client tool history (#2972). Call and result - * items fall together — a call without its result is a dangling wire item, - * and provider-executed pairs are flagged on both items by the plan. - */ - private dropUnsupportedReplayItems( - plan: RuntimeEventModelReplayPlan, - ): RuntimeEventModelReplayPlan { - const support = this.modelAdapter.runtimeEventReplaySupport(); - return { - ...plan, - items: plan.items.filter((item) => { - if (item.kind === 'tool_call' || item.kind === 'tool_result') { - if (!support.toolCalls || !support.toolResults) return false; - if (item.providerExecuted === true && !support.providerExecutedTools) return false; - } - return true; - }), - }; - } - - /** - * Materialize a replay plan into provider messages, grouping each assistant - * step's reasoning + text + tool calls into ONE assistant message (Anthropic - * requires the signed thinking block to lead the tool-use assistant message). - * - * The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking, - * text (the per-step AssistantMessage flushes at `finish-step`, after the - * step's tool events). Model text carries the step id and closes the step. - * Client tools replay as `[reasoning, text, tool-call…]` followed by tool - * messages; provider-executed tools replay as - * `[reasoning, tool-call, tool-result, text]`, preserving provider chronology - * for item references and grounded text. Steps with no text closer — a - * thinking + tool step (its empty text closer is skipped from the plan as - * `empty_text_skipped`) or a pure-tool step — flush grouped by stepId, - * claiming any parked reasoning for that step. Legacy per-turn items (no step - * id) keep the older shape: tool calls form a tool-only assistant, - * text/thinking become standalone messages. - */ - private async materializeRuntimeReplayPlan( - plan: RuntimeEventModelReplayPlan, - budget: ProviderImageBudget, - historyCompactCheckpoint: HistoryCompactCheckpoint | undefined, - providerReasoningReplayEventIds: ReadonlySet, - ): Promise { - type ToolCallItem = Extract; - type ToolResultItem = Extract; - type ThinkingItem = Extract; - type TextItem = Extract; - type ReplayReasoning = { - part?: ReasoningPart; - providerOptions?: NonNullable; - }; - const out: ModelMessage[] = []; - const push = (message: ModelMessage, eventIds: readonly string[]) => { - out.push(message); - this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); - }; - let bufferedCalls: ToolCallItem[] = []; - const results = new Map(); - const downgradedApplyPatchCalls = new Map(); - const replayFactsByStep = new Map< - string, - Array<{ readonly text: string; readonly eventIds: readonly string[] }> - >(); - const reasoningByStep = new Map(); - const textByStep = new Map(); - const pendingStepOrder = new Set(); - const rememberPendingStep = (stepId: string) => { - pendingStepOrder.add(stepId); - }; - - const replaySupport = this.modelAdapter.runtimeEventReplaySupport(); - const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { - if (item.signature) { - return replaySupport.signedThinking - ? { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: { anthropic: { signature: item.signature } }, - }, - } - : undefined; - } - const anthropic = item.providerOptions?.anthropic; - if ( - anthropic && - typeof anthropic === 'object' && - !Array.isArray(anthropic) && - typeof (anthropic as { redactedData?: unknown }).redactedData === 'string' - ) { - return replaySupport.signedThinking - ? { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: item.providerOptions, - }, - } - : undefined; - } - if ( - typeof replaySupport.responsesReasoning === 'object' && - replaySupport.responsesReasoning.kind === 'plaintext-item' - ) { - const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); - if (decoded.kind === 'missing') return undefined; - if (decoded.kind === 'unsupported-version') return undefined; - if (decoded.kind === 'malformed') { - if ( - decoded.profile !== undefined && - decoded.profile !== replaySupport.responsesReasoning.profile - ) { - return undefined; - } - throw new Error('Malformed durable plaintext Responses reasoning state'); - } - const state = decoded.state; - if (state.profile !== replaySupport.responsesReasoning.profile) { - return undefined; - } - return { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: replayPlaintextResponsesProviderOptions({ - providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, - state, - text: item.text, - }), - }, - }; - } - if (replaySupport.responsesReasoning === 'plaintext-content') { - if (item.text.length === 0) return undefined; - return { part: { type: 'reasoning' as const, text: item.text } }; - } - if (replaySupport.responsesReasoning === 'encrypted-content') { - const openai = item.providerOptions?.openai; - if (openai && typeof openai === 'object' && !Array.isArray(openai)) { - const { itemId, reasoningEncryptedContent } = openai as { - itemId?: unknown; - reasoningEncryptedContent?: unknown; - }; - if ( - typeof itemId === 'string' && - itemId.length > 0 && - typeof reasoningEncryptedContent === 'string' && - reasoningEncryptedContent.length > 0 - ) { - return { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: { - openai: { - itemId, - reasoningEncryptedContent, - }, - }, - }, - }; - } - } - } - if (!replaySupport.unsignedThinking) return undefined; - const reasoningField = openAiChatReasoningFieldFromProviderOptions(item.providerOptions); - if (!reasoningField) return undefined; - return { - providerOptions: { - openaiCompatible: { [reasoningField]: item.text }, - } as NonNullable, - }; - }; - // Tool results are emitted only when their tool_call claims them here. A - // result whose call never appears in the plan (sliced-away call, corrupt - // ledger) is INTENTIONALLY dropped at the end: a standalone tool message - // with no preceding tool_use in an assistant message is an Anthropic 400. - // The old item-by-item materializer emitted such orphans; do not "fix" this - // back — the plan flags them as `unmatched_tool_result` (a non-blocking - // diagnostic precisely so this drop path is reachable; see - // hasBlockingReplayDiagnostics). - const materializeReplayToolResult = async ( - result: ToolResultItem, - toolName: string, - ): Promise => { - const output = result.modelProjection - ? await this.materializeDurableToolResultProjection( - budget, - result.modelProjection, - `runtime-event:${result.eventId}:tool-result`, - ) - : await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - ); - if (toolName !== 'apply_patch') return output; - return result.isError ? nativeApplyPatchFailureOutput(output) : output; - }; - const pushClientToolResults = async (calls: readonly ToolCallItem[]) => { - for (const call of calls) { - const result = results.get(call.toolCallId); - if (!result || result.providerExecuted === true) continue; - results.delete(call.toolCallId); - push( - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: result.toolCallId, - toolName: result.toolName, - output: await materializeReplayToolResult(result, call.toolName), - }, - ], - }, - [result.eventId], - ); - } - }; - // Emit one assistant message for a step, preserving the distinct client- - // and provider-executed tool chronologies described above. - const emitStep = async ( - reasoning: readonly ThinkingItem[] | undefined, - text: TextItem | undefined, - calls: readonly ToolCallItem[], - ) => { - const content: unknown[] = []; - const eventIds = [ - ...(reasoning ?? []).map((item) => item.eventId), - ...(text ? [text.eventId] : []), - ...calls.map((call) => call.eventId), - ...(text?.stepId - ? (replayFactsByStep.get(text.stepId)?.flatMap((fact) => fact.eventIds) ?? []) - : []), - ]; - const replayReasoning = reasoning - ?.map(reasoningReplay) - .filter((item): item is ReplayReasoning => item !== undefined); - for (const item of replayReasoning ?? []) { - if (item.part) content.push(item.part); - } - // Provider-owned tools execute before the grounded assistant text in the - // same provider step. Preserve that chronology for Responses item - // references and Anthropic server_tool_use/result replay. Client tools - // stay after text because their execution begins only after this step. - for (const call of calls) { - if (call.providerExecuted !== true) continue; - content.push({ - type: 'tool-call', - toolCallId: call.toolCallId, - toolName: call.toolName, - input: call.input, - ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), - providerExecuted: true, - }); - const result = results.get(call.toolCallId); - if (!result || result.providerExecuted !== true) continue; - results.delete(call.toolCallId); - eventIds.push(result.eventId); - content.push({ - type: 'tool-result', - toolCallId: result.toolCallId, - toolName: result.toolName, - output: await materializeReplayToolResult(result, call.toolName), - }); - } - if (text && text.content.length > 0) { - content.push({ - type: 'text', - text: text.content, - ...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}), - }); - } - const replayFacts = text?.stepId ? replayFactsByStep.get(text.stepId) : undefined; - if (replayFacts) { - for (const replayFact of replayFacts) { - content.push({ type: 'text', text: replayFact.text }); - } - replayFactsByStep.delete(text!.stepId!); - } - for (const call of calls) { - if (call.providerExecuted === true) continue; - content.push({ - type: 'tool-call', - toolCallId: call.toolCallId, - toolName: call.toolName, - input: call.input, - ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), - ...(call.providerExecuted !== undefined - ? { providerExecuted: call.providerExecuted } - : {}), - }); - } - const replayProviderOptions = replayReasoning?.find( - (item) => item.providerOptions !== undefined, - )?.providerOptions; - if (content.length > 0 || replayProviderOptions) { - push( - { - role: 'assistant', - content, - ...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}), - } as ModelMessage, - eventIds, - ); - } - await pushClientToolResults(calls); - }; - // Emit tool calls no assistant text closed: a thinking + tool step with no - // text (its empty closer is skipped from the plan), a pure-tool step, or a - // legacy per-turn tool block. Group consecutive calls by stepId so each step - // stays one assistant message, and claim the step's parked reasoning by - // stepId — this is how the common Anthropic interleaved-thinking step shape - // (reasoning + tool call, no text) gets its reasoning merged ahead of its - // calls. Calls without a stepId group together (legacy shape, no reasoning). - const emitGroupedCalls = async (calls: readonly ToolCallItem[]) => { - let group: ToolCallItem[] = []; - const emitGroup = async () => { - if (group.length === 0) return; - const stepId = group[0]!.stepId; - const reasoning = stepId !== undefined ? reasoningByStep.get(stepId) : undefined; - const text = stepId !== undefined ? textByStep.get(stepId) : undefined; - if (stepId !== undefined) { - reasoningByStep.delete(stepId); - textByStep.delete(stepId); - pendingStepOrder.delete(stepId); - } - await emitStep(reasoning, text, group); - group = []; - }; - for (const call of calls) { - if (group.length > 0 && group[0]!.stepId !== call.stepId) await emitGroup(); - group.push(call); - } - await emitGroup(); - }; - const flushLooseCalls = async () => { - if (bufferedCalls.length === 0) return; - const calls = bufferedCalls; - bufferedCalls = []; - await emitGroupedCalls(calls); - }; - const flushPendingStep = async (stepId: string) => { - const text = textByStep.get(stepId); - const reasoning = reasoningByStep.get(stepId); - textByStep.delete(stepId); - reasoningByStep.delete(stepId); - pendingStepOrder.delete(stepId); - await emitStep(reasoning, text, []); - }; - const flushPendingStepsBefore = async (stepId: string | undefined) => { - const pendingStepIds = [...pendingStepOrder]; - const lastPendingStepId = pendingStepIds.at(-1); - const earlierStepIds = - stepId !== undefined && lastPendingStepId === stepId - ? pendingStepIds.slice(0, -1) - : pendingStepIds; - if (earlierStepIds.length === 0) return; - await flushLooseCalls(); - for (const pendingStepId of earlierStepIds) await flushPendingStep(pendingStepId); - }; - const flushPendingSteps = async () => { - await flushLooseCalls(); - for (const stepId of [...pendingStepOrder]) await flushPendingStep(stepId); - }; - - for (const item of admitProviderReasoningReplayItems( - plan.items, - providerReasoningReplayEventIds, - )) { - switch (item.kind) { - case 'tool_call': - await flushPendingStepsBefore(item.stepId); - if (item.toolName !== 'apply_patch') { - bufferedCalls.push(item); - break; - } - { - const replayInput = normalizeApplyPatchReplayInput( - this.applyPatchProfile, - item.toolCallId, - item.input, - ); - if (replayInput !== null) { - bufferedCalls.push({ - ...item, - input: replayInput, - ...(replayInput !== item.input ? { providerOptions: undefined } : {}), - }); - } else { - downgradedApplyPatchCalls.set(item.toolCallId, item); - } - } - break; - case 'tool_result': { - const downgradedCall = downgradedApplyPatchCalls.get(item.toolCallId); - if (!downgradedCall) { - results.set(item.toolCallId, item); - break; - } - downgradedApplyPatchCalls.delete(item.toolCallId); - const replayFact = item.modelProjection - ? durableApplyPatchReplayFactText( - downgradedCall.input, - item.modelProjection, - item.isError, - ) - : applyPatchReplayFactText(downgradedCall.input, item.output, item.isError); - if (!replayFact) break; - if (downgradedCall.stepId) { - const stepFacts = replayFactsByStep.get(downgradedCall.stepId) ?? []; - replayFactsByStep.set(downgradedCall.stepId, [ - ...stepFacts, - { - text: replayFact, - eventIds: [downgradedCall.eventId, item.eventId], - }, - ]); - if (!textByStep.has(downgradedCall.stepId)) { - textByStep.set(downgradedCall.stepId, { - kind: 'text', - role: 'assistant', - content: '', - stepId: downgradedCall.stepId, - eventId: downgradedCall.eventId, - ts: downgradedCall.ts, - }); - } - rememberPendingStep(downgradedCall.stepId); - } else { - await flushPendingSteps(); - push({ role: 'assistant', content: [{ type: 'text', text: replayFact }] }, [ - downgradedCall.eventId, - item.eventId, - ]); - } - break; - } - case 'thinking': - if (item.stepId !== undefined) { - const stepReasoning = reasoningByStep.get(item.stepId) ?? []; - stepReasoning.push(item); - reasoningByStep.set(item.stepId, stepReasoning); - rememberPendingStep(item.stepId); - } else { - // Legacy standalone reasoning (pure-reasoning turn): emit on its own. - await flushPendingSteps(); - const replayReasoning = reasoningReplay(item); - if (replayReasoning) { - push( - { - role: 'assistant', - content: replayReasoning.part ? [replayReasoning.part] : [], - ...(replayReasoning.providerOptions - ? { providerOptions: replayReasoning.providerOptions } - : {}), - } as ModelMessage, - [item.eventId], - ); - } - } - break; - case 'text': - if (item.role !== 'assistant') { - await flushPendingSteps(); - push(await this.materializeRuntimeReplayItem(budget, item), [item.eventId]); - break; - } - if (item.stepId !== undefined) { - const stepId = item.stepId; - const thisCalls = bufferedCalls.filter((call) => call.stepId === stepId); - const otherCalls = bufferedCalls.filter((call) => call.stepId !== stepId); - bufferedCalls = []; - // Earlier steps' unclosed calls flush first (with their own parked - // reasoning, if any) so step order is preserved. - if (otherCalls.length > 0) await emitGroupedCalls(otherCalls); - if (thisCalls.length > 0) { - await emitStep(reasoningByStep.get(stepId), item, thisCalls); - reasoningByStep.delete(stepId); - pendingStepOrder.delete(stepId); - } else { - // Runtime-owned settlement persists assistant facts before the - // matching tool calls. Hold the step closer until those calls - // arrive; a terminal text-only step flushes below. - textByStep.set(stepId, item); - rememberPendingStep(stepId); - } - } else { - // Legacy per-turn assistant text: standalone after any tool block. - await flushPendingSteps(); - push( - { - role: 'assistant', - content: item.content, - ...(item.providerOptions !== undefined - ? { providerOptions: item.providerOptions } - : {}), - }, - [item.eventId], - ); - } - break; - } - } - await flushPendingSteps(); - return this.prependProviderHistoryCompactMessage(out, historyCompactCheckpoint); - } - - private async materializeRuntimeReplayTextOnly( - budget: ProviderImageBudget, - plan: RuntimeEventModelReplayPlan, - historyCompactCheckpoint?: HistoryCompactCheckpoint, - ): Promise { - const messages: ModelMessage[] = []; - for (const item of plan.items) { - if (item.kind === 'text') - this.pushMemoryIndexedMessage( - messages, - await this.materializeRuntimeReplayItem(budget, item), - [item.eventId], - ); - } - return this.prependProviderHistoryCompactMessage(messages, historyCompactCheckpoint); - } - - private prependProviderHistoryCompactMessage( - messages: ModelMessage[], - checkpoint: HistoryCompactCheckpoint | undefined, - ): ModelMessage[] { - if (!checkpoint || !isProviderHistoryCompactCheckpoint(checkpoint)) return messages; - const providerMessage = historyCompactCheckpointToModelMessage(checkpoint); - this.memoryReplayMessageEvents.set(providerMessage, [ - `history-compact:${checkpoint.checkpointId}`, - ]); - return [providerMessage, ...messages]; - } - - private pushMemoryIndexedMessage( - messages: ModelMessage[], - message: ModelMessage, - eventIds: readonly string[], - ): void { - messages.push(message); - this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); - } - - private memoryEventMessagePositions( - messages: readonly ModelMessage[], - ): Readonly> | undefined { - const positions: Record = {}; - for (const [position, message] of messages.entries()) { - for (const eventId of this.memoryReplayMessageEvents.get(message) ?? []) { - (positions[eventId] ??= []).push(position); - } - } - return Object.keys(positions).length > 0 ? positions : undefined; - } - - private async materializeRuntimeReplayItem( - budget: ProviderImageBudget, - item: Extract, - ): Promise { - if (item.role === 'user') { - if (item.steering) { - // Already envelope-wrapped by the plan; carry the structured identity - // so injection dedupe recognizes the replayed message. - return { - role: 'user', - content: item.content, - providerOptions: steeringProviderOptions(item.steering.eventId), - }; - } - return { - role: 'user', - content: await this.appendImageParts( - budget, - item.content, - item.attachments, - `runtime-event:${item.eventId}`, - ), - } as ModelMessage; - } - return { - role: item.role, - content: item.content, - ...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}), - }; - } - - private async materializePriorMessages( - budget: ProviderImageBudget, - stored: readonly StoredMessage[], - steeringSidecar?: ReadonlyMap, - ): Promise { - const out: ModelMessage[] = []; - for (const m of stored) { - if (m.type === 'user') { - // Degraded projections lose the RuntimeEvent steering marker; the - // sidecar (keyed by the projection's stable message ids) restores the - // canonical envelope + structured identity so a fallback-gated turn - // still presents steering exactly once, in its one provider form. - const sidecar = steeringSidecar?.get(m.id); - if (sidecar) { - out.push( - steeringModelMessage( - sidecar.eventId, - await this.appendImageParts( - budget, - buildSteeringEnvelope(formatTextWithInlineRefs(m.text, m)), - m.attachments, - `steering:${sidecar.eventId}`, - ), - ), - ); - continue; - } - out.push({ - role: 'user', - content: await this.appendImageParts( - budget, - formatTextWithInlineRefs(m.text, m), - m.attachments, - ), - } as ModelMessage); - } - // A thinking/tool-only step projects an assistant row with empty text; - // replaying it as an empty text block is a hard 400 on Anthropic-protocol - // providers. - else if (m.type === 'assistant' && m.text.length > 0) - out.push({ - role: 'assistant', - content: m.text, - ...(m.providerOptions !== undefined - ? { - providerOptions: m.providerOptions as NonNullable, - } - : {}), - } as ModelMessage); - // empty assistant / tool_call / tool_result / permission_decision / token_usage / system_note skipped - } - return out; - } - - /** A decision key deduplicates re-materialization; no key charges each occurrence. */ - private chargeImageBudget( - budget: ProviderImageBudget, - bytes: number, - decisionKey?: string, - ): boolean { - if (decisionKey !== undefined) { - const cached = budget.decisions.get(decisionKey); - if (cached !== undefined) return cached; - } - const keep = - budget.used + bytes <= - (this.input.maxProviderImageRequestBytes ?? MAX_PROVIDER_IMAGE_REQUEST_BYTES); - if (keep) budget.used += bytes; - if (decisionKey !== undefined) budget.decisions.set(decisionKey, keep); - return keep; - } - - /** - * Render provider-visible content for a user message: keep the given - * (already-formatted) text, and append image attachments as provider image - * parts only for explicitly vision-capable models. Non-image attachments stay - * as placeholder refs in the text. Shared by the current turn, RuntimeEvent - * replay, and the stored-message fallback so all paths present images identically. - */ - private async appendImageParts( - budget: ProviderImageBudget, - textContent: string, - attachments?: AttachmentRef[], - decisionKeyPrefix?: string, - ): Promise { - const images = attachments?.filter((a) => a.kind === 'image') ?? []; - if (images.length === 0) { - return textContent; - } - if (this.input.supportsVision !== true) { - // `textContent` already carries each attachment's stable Read argument. - // Native provider image delivery is unavailable here, but that does not - // establish whether the model can process the image through a tool. - return textContent; - } - if (!this.input.readAttachmentBytes) { - return textContent; - } - const parts: Array< - | { type: 'text'; text: string } - | { - type: 'file'; - data: { type: 'data'; data: Uint8Array }; - mediaType: string; - } - > = [{ type: 'text', text: textContent }]; - let omittedByBudget = 0; - for (const [index, image] of images.entries()) { - const read = await this.input.readAttachmentBytes(image.ref); - if (!read.ok) { - parts.push({ - type: 'text', - text: `Image attachment "${image.name}" could not be loaded: ${read.reason}.`, - }); - continue; - } - const decisionKey = - decisionKeyPrefix === undefined ? undefined : `${decisionKeyPrefix}:image:${index}`; - if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { - omittedByBudget += 1; - continue; - } - parts.push({ - type: 'file', - data: { type: 'data', data: read.bytes }, - mediaType: image.mimeType, - }); - } - if (omittedByBudget > 0) { - parts.push({ - type: 'text', - text: `[${omittedByBudget} image attachment(s) omitted: the per-request image budget was exceeded. Earlier images were sent; ask the user to send fewer or smaller images.]`, - }); - } - return parts; - } - - private async materializeToolResultOutput( - budget: ProviderImageBudget, - output: unknown, - isError: boolean, - decisionKey: string, - ): Promise { - if (isError || !isImageToolResult(output)) return toolResultOutput(output, isError); - if (this.input.supportsVision !== true) { - return toolResultText('Image was read, but the selected model does not support image input.'); - } - if (!this.input.readAttachmentBytes) { - return toolResultText('Image was read, but its stored bytes are unavailable.'); - } - if (budget && budget.decisions.get(decisionKey) === false) { - return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); - } - let read: Awaited>; - try { - read = await this.input.readAttachmentBytes(output.ref); - } catch { - return toolResultText('Image could not be loaded from artifact storage: read_failed.'); - } - if (!read.ok) { - return toolResultText(`Image could not be loaded from artifact storage: ${read.reason}.`); - } - if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { - return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); - } - return { - type: 'content', - value: [ - { type: 'text', text: 'Image read successfully.' }, - { - type: 'file', - data: { - type: 'data', - data: Buffer.from(read.bytes).toString('base64'), - }, - mediaType: output.mimeType, - }, - ], - }; - } - - private async materializeDurableToolResultProjection( - budget: ProviderImageBudget, - projection: DurableToolResultProjection, - decisionKey: string, - ): Promise { - if (projection.kind !== 'content') return durableProjectionToToolResultOutput(projection); - const value: Extract['value'] = []; - for (const [index, part] of projection.parts.entries()) { - if (part.kind === 'text') { - value.push({ type: 'text', text: part.text }); - continue; - } - if (this.input.supportsVision !== true) { - value.push({ - type: 'text', - text: 'Image was read, but the selected model does not support image input.', - }); - continue; - } - if (!this.input.readAttachmentBytes) { - value.push({ - type: 'text', - text: 'Image was read, but its stored bytes are unavailable.', - }); - continue; - } - let read: Awaited>; - try { - read = await this.input.readAttachmentBytes(part.ref); - } catch { - value.push({ - type: 'text', - text: 'Image could not be loaded from artifact storage: read_failed.', - }); - continue; - } - if (!read.ok) { - value.push({ - type: 'text', - text: `Image could not be loaded from artifact storage: ${read.reason}.`, - }); - continue; - } - if (!this.chargeImageBudget(budget, read.bytes.length, `${decisionKey}:artifact:${index}`)) { - value.push({ type: 'text', text: PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE }); - continue; - } - value.push({ - type: 'file', - data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') }, - mediaType: part.mediaType, - }); - } - return { type: 'content', value }; - } - - private async buildCurrentUserContent( - budget: ProviderImageBudget, - text: string, - attachments?: AttachmentRef[], - directoryReferences?: DirectoryReference[], - quotes?: QuoteRef[], - runtimeEventId?: string, - ): Promise { - return await this.appendImageParts( - budget, - formatTextWithInlineRefs(text, { - ...(attachments !== undefined ? { attachments } : {}), - ...(directoryReferences !== undefined ? { directoryReferences } : {}), - ...(quotes !== undefined ? { quotes } : {}), - }), - attachments, - runtimeEventId === undefined ? undefined : `runtime-event:${runtimeEventId}`, - ); - } - - private async resolveSystemPrompt(scope: TurnScope): Promise { - const turnId = scope.turnId; - if (typeof this.input.systemPrompt === 'function') { - return await this.input.systemPrompt({ - sessionId: this.sessionId, - turnId, - cwd: this.input.header.cwd, - emitSkillCatalogTrace: (message, data) => - scope.runTrace?.emit('skill', 'skill_catalog_built', message, data), - }); - } - return this.input.systemPrompt; - } - - private async *drain(queue: AsyncEventQueue): AsyncIterable { - try { - for await (const ev of queue) { - yield ev; - // Generator backpressure IS the consumer's ack: this line runs only - // when the consumer's loop body finished for `ev` and pulled the next - // event, so `consumedCount` counts fully PROCESSED events. AgentRun - // persists each mapped event before continuing, so an acked event is - // either durable or deliberately skipped (partials, non-terminal - // errors) — exactly the set a durable read can ever return. - queue.ackConsumed(); - } - } finally { - // The consumer abandoned or finished the stream; wake any seq-ack waiter - // so it observes `consumerDetached` instead of blocking forever. - queue.noteConsumerDetached(); - } - } - - /** - * Retire one turn's scope. Nothing is reset for reuse — the scope is dropped, - * so a sibling turn still running on this backend is untouched. Deregistering - * before `endTurn` also makes a late tool settlement resolve to "gone" rather - * than to whichever turn started next. - */ - private async cleanupAfterTurn(scope: TurnScope): Promise { - this.activeTurns.delete(scope); - this.modelAdapter.endContinuation(scope.turnId); - await scope.toolRuntime.endTurn(scope.aborted ? 'aborted' : 'completed'); - } - - /** - * Drain the caller's pending steering at a step boundary. Each message is - * echoed as a `steering_message` event (so the ledger + transcript render the - * interjection in place) and accumulated as an envelope-wrapped user message - * for injection into subsequent provider requests. - * - * Persist-before-include invariant: the initial user message is durable - * before the backend is invoked, and a steered message must hold the same - * line — the provider must never start executing a directive the ledger does - * not carry. The seq-ack boundary provides that without a second write path: - * the consumer's pull is the ack, and AgentRun persists each mapped event - * before continuing (see drain()), so once everything enqueued up to the - * steering event is consumed, the event is durable. If the consumer detaches - * (the persist path failed or the turn is being torn down) before that, the - * message is nacked and NOT included in any request; an abort after the push - * waits for that same convergence — durable ⇒ ack (history owns it), detach - * ⇒ nack — and only then throws so the dying request is never sent. - */ - private async drainSteeringInto( - scope: TurnScope, - input: BackendSendInput, - queue: AsyncEventQueue, - ): Promise { - const turnId = scope.turnId; - const abortSignal = scope.abortController.signal; - const pull = input.pullSteering; - if (!pull) return; - const leases = pull(); - if (leases.length === 0) return; - // Binary settlement: every pulled lease settles exactly once, decided - // ONLY by the persistence fact — durably consumed ⇒ ack + injection set; - // provably never persisted (never pushed, or the consumer detached - // without consuming it) ⇒ nack. An abort does NOT settle a pushed lease: - // it only stops new pushes and the dying request; the wait continues - // until the teardown converges it (the flow drains after terminal events - // or detaches on failure), because nacking a durably appended event - // would put the same directive in the account twice — once via history - // replay, once via the reclaimed queue. - const undelivered = [...leases]; - try { - for (const lease of leases) { - if (scope.aborted || abortSignal?.aborted) { - // Never pushed: settles as undelivered. - throw Object.assign(new Error('aborted before steering was pushed'), { - name: 'AbortError', - }); - } - if (queue.consumerDetached) { - throw new Error('steering message was not durably consumed: event consumer detached'); - } - // Materialize provider content before publishing the durable event. - // After consumption there must be no fallible gap before ack/injection. - const eventId = this.newId(); - const providerContent = await this.appendImageParts( - scope.imageBudget, - buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)), - lease.content.attachments, - `steering:${eventId}`, - ); - if (scope.aborted || abortSignal?.aborted) { - throw Object.assign(new Error('aborted before steering was pushed'), { - name: 'AbortError', - }); - } - if (queue.consumerDetached) { - throw new Error('steering message was not durably consumed: event consumer detached'); - } - await queue.pushAndWaitUntilConsumed({ - type: 'steering_message', - id: eventId, - turnId, - ts: this.now(), - messageId: lease.messageId, - content: lease.content, - ...(lease.submittedContentDigest - ? { submittedContentDigest: lease.submittedContentDigest } - : {}), - } satisfies SessionEvent); - // The mapped RuntimeEvent inherits this session event's id, so the - // injected message and its future ledger replay share one identity. - scope.injectedSteeringMessages.push(steeringModelMessage(eventId, providerContent)); - input.ackSteering?.([lease.id]); - undelivered.shift(); - if (scope.aborted || abortSignal?.aborted) { - // Settled (the ledger owns the message; the next turn replays it), - // but the send is dying: stop before any request is built with it. - throw Object.assign(new Error('aborted after steering was durable'), { - name: 'AbortError', - }); - } - } - } catch (error) { - if (undelivered.length > 0) { - input.nackSteering?.(undelivered.map((lease) => lease.id)); - } - throw error; - } - } -} - -/** - * Steering identities for degraded StoredMessage projections, keyed by every - * stable id the projection may have used for the message (event id, - * providerEventId, storedMessageId), so the sidecar restore is exact. - */ -function buildSteeringSidecar(events: readonly RuntimeEvent[]): Map { - const sidecar = new Map(); - for (const event of events) { - if (event.partial === true) continue; - if (event.content?.kind !== 'text' || event.content.steering !== true) continue; - const identity = { eventId: event.id }; - sidecar.set(event.id, identity); - if (event.refs?.providerEventId) sidecar.set(event.refs.providerEventId, identity); - if (event.refs?.storedMessageId) sidecar.set(event.refs.storedMessageId, identity); - } - return sidecar; -} - -function isPlanToolResult(output: unknown): output is PlanToolResult { - if (!output || typeof output !== 'object') return false; - return [ - 'plan_submitted', - 'plan_progress_updated', - 'plan_execution_completed', - 'plan_execution_cancelled', - ].includes(String((output as { kind?: unknown }).kind)); -} - -function isAgentGraphYieldToolResult(output: unknown): output is YieldAgentGraphToolResult { - if (output === null || typeof output !== 'object' || Array.isArray(output)) return false; - const result = output as Record; - return ( - Object.keys(result).length === 4 && - result.kind === 'agent_graph_yielded' && - typeof result.pendingWorkCount === 'number' && - Number.isSafeInteger(result.pendingWorkCount) && - result.pendingWorkCount > 0 && - typeof result.liveOperatorCount === 'number' && - Number.isSafeInteger(result.liveOperatorCount) && - result.liveOperatorCount >= 0 && - typeof result.reason === 'string' && - result.reason.length > 0 && - result.reason.length <= 4_000 && - result.reason.trim() === result.reason - ); -} - -export function repairMakaToolCall(input: { - toolCall: RepairableAiSdkToolCall; - availableToolNames: readonly string[]; - error: unknown; - /** Schema lookup for the tool that was called, when the caller has one. */ - toolParameters?: (toolName: string) => unknown; - /** - * Category lookup for the same tool. - * - * Computer Use declares one flat wire object standing in for a per-action - * union, so its schema shape alone names every field of every action. - */ - toolCategoryHint?: (toolName: string) => string | undefined; -}): RepairableAiSdkToolCall | null { - const requestedName = input.toolCall.toolName; - if (requestedName === INVALID_TOOL_NAME) return null; - - const lowerRequestedName = requestedName.toLowerCase(); - const exactLowercaseMatch = input.availableToolNames.find( - (name) => name.toLowerCase() === lowerRequestedName, - ); - if (exactLowercaseMatch && exactLowercaseMatch !== requestedName) { - return { ...input.toolCall, toolName: exactLowercaseMatch }; - } - - return { - ...input.toolCall, - toolName: INVALID_TOOL_NAME, - input: JSON.stringify({ - tool: requestedName, - error: describeUnrepairableToolCall(input), - ...(isProviderSandboxBoundaryAttempt(input.toolCall) ? { sandboxBoundaryAttempt: true } : {}), - }), - }; -} - -/** - * What the model is told about a call that could not be repaired. - * - * Two different failures arrive here. A name that matches nothing: the caller - * is holding the list of names that would have worked and used to drop it, - * leaving the model with its own wrong name and a validator's complaint — the - * same dead end `tool-availability` avoids by naming what is available. - * Arguments the tool's schema rejected: the schema knows which fields the call - * takes, so say them rather than let the model re-send the shape just refused. - */ -function describeUnrepairableToolCall(input: { - toolCall: RepairableAiSdkToolCall; - availableToolNames: readonly string[]; - error: unknown; - toolParameters?: (toolName: string) => unknown; - toolCategoryHint?: (toolName: string) => string | undefined; -}): string { - const requestedName = input.toolCall.toolName; - const known = input.availableToolNames.includes(requestedName); - if (!known) { - const available = input.availableToolNames.join(', '); - const detail = formatSyntheticToolErrorText(input.error); - return available ? `${detail} Available tools: ${available}.` : detail; - } - return formatToolArgsViolationText({ - toolName: requestedName, - parameters: input.toolParameters?.(requestedName), - categoryHint: input.toolCategoryHint?.(requestedName), - args: parseToolCallInput(input.toolCall.input), - error: input.error, - }); -} - -function parseToolCallInput(raw: unknown): unknown { - if (typeof raw !== 'string') return raw; - try { - return JSON.parse(raw); - } catch { - return undefined; - } -} - -function isProviderSandboxBoundaryAttempt(toolCall: { toolName: string; input: unknown }): boolean { - const toolName = toolCall.toolName.toLowerCase(); - if (toolName === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME) return true; - if (toolName !== 'bash') return false; - const parsed = parseToolCallInput(toolCall.input); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; - const boundaryIntent = (parsed as Record).boundary_intent; - return boundaryIntent !== undefined && boundaryIntent !== 'current'; -} - -function buildInvalidMakaTool(): MakaTool< - { tool?: string; error?: string; sandboxBoundaryAttempt?: true }, - never -> { - return { - name: INVALID_TOOL_NAME, - description: - 'Internal repair target for malformed or unknown tool calls. Do not call directly.', - parameters: z.object({ - tool: z.string().optional(), - error: z.string().optional(), - sandboxBoundaryAttempt: z.literal(true).optional(), - }), - impl: ({ tool, error, sandboxBoundaryAttempt }) => { - const requested = tool ? ` "${tool}"` : ''; - const message = `模型请求了不可用或格式错误的工具${requested}:${error || 'tool call could not be parsed'}`; - if (sandboxBoundaryAttempt) { - throw new SandboxCommandError({ - domain: 'command', - stage: 'validation', - reason: 'invalid_boundary_declaration', - recoverable: true, - message, - }); - } - throw new Error(message); - }, - }; -} - -function priorReplayFailureTrace(replay: { - gate: string; - diagnostics: readonly { code: string }[]; -}): { gate: string; diagnosticCodes: string[] } { - return { - gate: replay.gate, - diagnosticCodes: [...new Set(replay.diagnostics.map((diagnostic) => diagnostic.code))], - }; -} - -class ContinuationReplayEmptyError extends Error { - readonly code = 'continuation_replay_empty'; - - constructor( - readonly replayGate: string, - readonly diagnosticCodes: readonly string[], - ) { - super(`Continuation replay is empty after ${replayGate}`); - this.name = 'ContinuationReplayEmptyError'; - } -} - -function mergeActiveToolResultPruneDiagnosticPatches( - left: ActiveToolResultPruneDiagnosticPatch, - right: ActiveToolResultPruneDiagnosticPatch, -): ActiveToolResultPruneDiagnosticPatch { - return { - ...sumOptionalCounts('activePrunedToolResults', left, right), - ...sumOptionalCounts('activeSupersededToolResults', left, right), - ...sumOptionalCounts('activeDuplicateToolResults', left, right), - ...sumOptionalCounts('activeArchiveFailures', left, right), - ...sumOptionalCounts('activeEstimatedTokensSaved', left, right), - }; -} - -function mergeNormalizedUsage( - current: NormalizedAiSdkUsage | undefined, - next: NormalizedAiSdkUsage, -): NormalizedAiSdkUsage { - if (!current) return next; - const cacheMissInputSource = - current.cacheMissInputSource === 'explicit' || next.cacheMissInputSource === 'explicit' - ? 'explicit' - : 'derived'; - const cacheHitInputTokens = current.cacheHitInputTokens + next.cacheHitInputTokens; - return { - inputTokens: current.inputTokens + next.inputTokens, - outputTokens: current.outputTokens + next.outputTokens, - cacheHitInputTokens, - cacheMissInputTokens: current.cacheMissInputTokens + next.cacheMissInputTokens, - cacheMissInputSource, - cacheWriteInputTokens: current.cacheWriteInputTokens + next.cacheWriteInputTokens, - reasoningTokens: current.reasoningTokens + next.reasoningTokens, - totalTokens: current.totalTokens + next.totalTokens, - ...(next.rawFinishReason !== undefined ? { rawFinishReason: next.rawFinishReason } : {}), - cachedInputTokens: cacheHitInputTokens, - }; -} - -function sumOptionalCounts( - key: K, - left: ActiveToolResultPruneDiagnosticPatch, - right: ActiveToolResultPruneDiagnosticPatch, -): Pick | Record { - const total = (left[key] ?? 0) + (right[key] ?? 0); - return total > 0 ? ({ [key]: total } as Pick) : {}; -} - -function contextBudgetWithRequestProjectionDiagnostics( - base: ContextBudgetDiagnostic | undefined, - patch: ActiveToolResultPruneDiagnosticPatch, - compactionPatch: Partial | undefined, -): ContextBudgetDiagnostic | undefined { - const prunePatch = hasActiveToolResultPruneDiagnosticPatch(patch) ? patch : undefined; - const mergedPatch = mergeContextBudgetDiagnosticPatches(prunePatch, compactionPatch); - if (!mergedPatch) return base; - return mergeContextBudgetDiagnostic(base ?? minimalContextBudgetDiagnostic(), mergedPatch); -} - -function buildHistoryCompactCheckpointFailOpenContext( - checkpoint: HistoryCompactCheckpoint, - priorRuntimeContext: readonly RuntimeEvent[], - policy: ContextBudgetPolicy, - retainedCandidates: readonly RuntimeEvent[], -): { events: RuntimeEvent[]; checkpoint?: HistoryCompactCheckpoint } { - const charsPerToken = policy.charsPerToken ?? 4; - const compactableEvents = priorRuntimeContext.filter( - (event) => estimateRuntimeEventsTokens([event], charsPerToken) > 0, - ); - const match = matchHistoryCompactCheckpointPrefix(checkpoint, compactableEvents); - if (match.reason) return { events: [...retainedCandidates] }; - const coveredIds = new Set(match.coveredRuntimeEvents.map((event) => event.id)); - const candidates = retainedCandidates.filter((event) => !coveredIds.has(event.id)); - const turnOrder: string[] = []; - const byTurn = new Map(); - for (const event of candidates) { - const group = byTurn.get(event.turnId); - if (group) group.push(event); - else { - turnOrder.push(event.turnId); - byTurn.set(event.turnId, [event]); - } - } - // Replay is structural: the checkpoint plus everything after its boundary. - // No size-based selection stands between them and dispatch; whether the - // result fits is the provider's answer (#4559). - const selectedGroups: RuntimeEvent[][] = turnOrder.map((turnId) => byTurn.get(turnId) ?? []); - const replayTail = selectedGroups.flat(); - const replayEvents = projectHistoryCompactCheckpointReplay( - checkpoint, - match.coveredRuntimeEvents, - replayTail, - ); - return { events: replayEvents, checkpoint }; -} - -function projectMemoryConversationPrefix( - messages: readonly ModelMessage[], - eventMessagePositions?: Readonly>, -): { - messages: ModelMessage[]; - eventMessagePositions?: Readonly>; -} { - // Context visibility and durable evidence authority are separate boundaries. - // Keep the exact source prefix so the auxiliary request preserves referents - // and provider-cache shape. The Evidence Index and trusted admission layer - // independently restrict durable citations to user-authored RuntimeEvents. - return { - messages: [...messages], - ...(eventMessagePositions ? { eventMessagePositions } : {}), - }; -} - -function lastNonCompactRuntimeEvent(events: readonly RuntimeEvent[]): RuntimeEvent | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]!; - if (!event.id.startsWith('history-compact:')) return event; - } - return undefined; -} - -function memoryExtractionModelHeader( - header: SessionHeader, -): MemoryExtractionSourceSnapshot['sourceHeader'] { - return { - ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), - llmConnectionSlug: header.llmConnectionSlug, - model: header.model, - ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), - }; } diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts new file mode 100644 index 0000000000..c30e70d0f9 --- /dev/null +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -0,0 +1,797 @@ +/* + * 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 type { AttachmentRef, DirectoryReference, QuoteRef, StorageRef } from '@maka/core/events'; +import { + MAX_PROVIDER_IMAGE_REQUEST_BYTES, + PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE, + type AttachmentByteReader, +} from '@maka/core/attachments'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import type { ProviderImageBudget } from './ai-sdk-compaction.js'; +import { + applyPatchReplayFactText, + normalizeApplyPatchReplayInput, + type ApplyPatchProfile, +} from './apply-patch-profile.js'; +import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import { + historyCompactCheckpointToModelMessage, + isProviderHistoryCompactCheckpoint, + type HistoryCompactCheckpoint, +} from './history-compact-checkpoint.js'; +import { + admitProviderReasoningReplayItems, + buildRuntimeEventReplayTimeline, + formatTextWithInlineRefs, + steeringProviderOptions, + type RuntimeEventModelReplayItem, + type RuntimeEventModelReplayPlan, + type RuntimeEventReplayToolExchange, + type RuntimeEventReplayToolResultItem, +} from './model-history.js'; +import type { ModelAdapter } from './model-adapter.js'; +import type { + ModelMessage, + ReasoningPart, + ToolResultOutput, + UserContent, +} from './model-protocol.js'; +import { openAiChatReasoningFieldFromProviderOptions } from './openai-chat-reasoning-transport.js'; +import { + decodePlaintextResponsesReasoningState, + replayPlaintextResponsesProviderOptions, +} from './responses-reasoning-state.js'; +import { toolResultOutput } from './tool-result-output.js'; + +export interface AiSdkMessageProjectionInput { + modelAdapter: ModelAdapter; + applyPatchProfile: ApplyPatchProfile | null; + supportsVision?: boolean; + readAttachmentBytes?: AttachmentByteReader; + maxProviderImageRequestBytes?: number; +} + +function isImageToolResult( + value: unknown, +): value is { kind: 'image'; mimeType: string; ref: StorageRef } { + if (!value || typeof value !== 'object') return false; + const image = value as { kind?: unknown; mimeType?: unknown; ref?: unknown }; + return ( + image.kind === 'image' && + typeof image.mimeType === 'string' && + image.ref !== null && + typeof image.ref === 'object' + ); +} + +function toolResultText(text: string): ToolResultOutput { + return { type: 'content', value: [{ type: 'text', text }] }; +} + +function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutput { + const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; + const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; + const message = + output.type === 'text' || output.type === 'error-text' + ? output.value + : typeof record?.output === 'string' + ? record.output + : typeof record?.text === 'string' + ? record.text + : typeof record?.error === 'string' + ? record.error + : undefined; + return { + type: 'json', + value: { status: 'failed', ...(message ? { output: message } : {}) }, + }; +} + +function durableApplyPatchReplayFactText( + input: unknown, + projection: DurableToolResultProjection, + isError: boolean, +): string | null { + if (projection.kind === 'json') { + const fact = applyPatchReplayFactText(input, projection, isError); + if (fact) return fact; + } + const output = durableProjectionToToolResultOutput(projection); + switch (output.type) { + case 'text': + case 'error-text': + return output.value; + case 'json': + case 'error-json': + return JSON.stringify(output.value); + case 'content': { + const text = output.value + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join('\n'); + return text || null; + } + case 'execution-denied': + return output.reason + ? `ApplyPatch execution denied: ${output.reason}` + : 'ApplyPatch execution denied.'; + } +} + +/** + * Projects canonical Runtime history and current user input into provider + * messages. It owns no execution state; its only mutable data is the weak + * event index attached to the messages it creates. + */ +export class AiSdkMessageProjection { + private readonly memoryReplayMessageEvents = new WeakMap(); + + constructor(private readonly input: AiSdkMessageProjectionInput) {} + + canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean { + const support = this.input.modelAdapter.runtimeEventReplaySupport(); + for (const item of plan.items) { + if (item.kind === 'tool_call' && !support.toolCalls) return false; + if (item.kind === 'tool_result' && !support.toolResults) return false; + if ( + (item.kind === 'tool_call' || item.kind === 'tool_result') && + item.providerExecuted === true && + !support.providerExecutedTools + ) { + return false; + } + if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false; + } + return true; + } + + /** + * Per-item counterpart to {@link canReplayProviderNative}: drop only the + * items the adapter cannot represent so one unsupported provider-executed + * pair does not cost unrelated client tool history (#2972). Call and result + * items fall together — a call without its result is a dangling wire item, + * and provider-executed pairs are flagged on both items by the plan. + */ + dropUnsupportedReplayItems(plan: RuntimeEventModelReplayPlan): RuntimeEventModelReplayPlan { + const support = this.input.modelAdapter.runtimeEventReplaySupport(); + return { + ...plan, + items: plan.items.filter((item) => { + if (item.kind === 'tool_call' || item.kind === 'tool_result') { + if (!support.toolCalls || !support.toolResults) return false; + if (item.providerExecuted === true && !support.providerExecutedTools) return false; + } + return true; + }), + }; + } + + /** + * Materialize a replay plan into provider messages, grouping each assistant + * step's reasoning + text + tool calls into ONE assistant message (Anthropic + * requires the signed thinking block to lead the tool-use assistant message). + * + * The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking, + * text (the per-step AssistantMessage flushes at `finish-step`, after the + * step's tool events). Model text carries the step id and closes the step. + * Client tools replay as `[reasoning, text, tool-call…]` followed by tool + * messages; provider-executed tools replay as + * `[reasoning, tool-call, tool-result, text]`, preserving provider chronology + * for item references and grounded text. Steps with no text closer — a + * thinking + tool step (its empty text closer is skipped from the plan as + * `empty_text_skipped`) or a pure-tool step — flush grouped by stepId, + * claiming any parked reasoning for that step. Legacy per-turn items (no step + * id) keep the older shape: tool calls form a tool-only assistant, + * text/thinking become standalone messages. + */ + async materializeRuntimeReplayPlan( + plan: RuntimeEventModelReplayPlan, + budget: ProviderImageBudget, + historyCompactCheckpoint: HistoryCompactCheckpoint | undefined, + providerReasoningReplayEventIds: ReadonlySet, + ): Promise { + type ThinkingItem = Extract; + type TextItem = Extract; + type ReplayReasoning = { + part?: ReasoningPart; + providerOptions?: NonNullable; + }; + const out: ModelMessage[] = []; + const push = (message: ModelMessage, eventIds: readonly string[]) => { + out.push(message); + this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); + }; + const replaySupport = this.input.modelAdapter.runtimeEventReplaySupport(); + const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { + if (item.signature) { + return replaySupport.signedThinking + ? { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: { anthropic: { signature: item.signature } }, + }, + } + : undefined; + } + const anthropic = item.providerOptions?.anthropic; + if ( + anthropic && + typeof anthropic === 'object' && + !Array.isArray(anthropic) && + typeof (anthropic as { redactedData?: unknown }).redactedData === 'string' + ) { + return replaySupport.signedThinking + ? { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: item.providerOptions, + }, + } + : undefined; + } + if ( + typeof replaySupport.responsesReasoning === 'object' && + replaySupport.responsesReasoning.kind === 'plaintext-item' + ) { + const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); + if (decoded.kind === 'missing') return undefined; + if (decoded.kind === 'unsupported-version') return undefined; + if (decoded.kind === 'malformed') { + if ( + decoded.profile !== undefined && + decoded.profile !== replaySupport.responsesReasoning.profile + ) { + return undefined; + } + throw new Error('Malformed durable plaintext Responses reasoning state'); + } + const state = decoded.state; + if (state.profile !== replaySupport.responsesReasoning.profile) { + return undefined; + } + return { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: replayPlaintextResponsesProviderOptions({ + providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, + state, + text: item.text, + }), + }, + }; + } + if (replaySupport.responsesReasoning === 'plaintext-content') { + if (item.text.length === 0) return undefined; + return { part: { type: 'reasoning' as const, text: item.text } }; + } + if (replaySupport.responsesReasoning === 'encrypted-content') { + const openai = item.providerOptions?.openai; + if (openai && typeof openai === 'object' && !Array.isArray(openai)) { + const { itemId, reasoningEncryptedContent } = openai as { + itemId?: unknown; + reasoningEncryptedContent?: unknown; + }; + if ( + typeof itemId === 'string' && + itemId.length > 0 && + typeof reasoningEncryptedContent === 'string' && + reasoningEncryptedContent.length > 0 + ) { + return { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: { + openai: { + itemId, + reasoningEncryptedContent, + }, + }, + }, + }; + } + } + } + if (!replaySupport.unsignedThinking) return undefined; + const reasoningField = openAiChatReasoningFieldFromProviderOptions(item.providerOptions); + if (!reasoningField) return undefined; + return { + providerOptions: { + openaiCompatible: { [reasoningField]: item.text }, + } as NonNullable, + }; + }; + // Tool results are emitted only when their tool_call claims them here. A + // result whose call never appears in the plan (sliced-away call, corrupt + // ledger) is INTENTIONALLY dropped at the end: a standalone tool message + // with no preceding tool_use in an assistant message is an Anthropic 400. + // The old item-by-item materializer emitted such orphans; do not "fix" this + // back — the plan flags them as `unmatched_tool_result` (a non-blocking + // diagnostic precisely so this drop path is reachable; see + // hasBlockingReplayDiagnostics). + const materializeReplayToolResult = async ( + result: RuntimeEventReplayToolResultItem, + toolName: string, + ): Promise => { + const output = result.modelProjection + ? await this.materializeDurableToolResultProjection( + budget, + result.modelProjection, + `runtime-event:${result.eventId}:tool-result`, + ) + : await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + ); + if (toolName !== 'apply_patch') return output; + return result.isError ? nativeApplyPatchFailureOutput(output) : output; + }; + const pushClientToolResults = async (exchanges: readonly RuntimeEventReplayToolExchange[]) => { + for (const { call, result } of exchanges) { + if (!result || result.providerExecuted === true) continue; + push( + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result, call.toolName), + }, + ], + }, + [result.eventId], + ); + } + }; + // Emit one assistant message for a step, preserving the distinct client- + // and provider-executed tool chronologies described above. + const emitStep = async ( + reasoning: readonly ThinkingItem[] | undefined, + text: TextItem | undefined, + exchanges: readonly RuntimeEventReplayToolExchange[], + replayFacts: ReadonlyArray<{ readonly text: string; readonly eventIds: readonly string[] }>, + ) => { + const calls = exchanges.map(({ call }) => call); + const content: unknown[] = []; + const eventIds = [ + ...(reasoning ?? []).map((item) => item.eventId), + ...(text ? [text.eventId] : []), + ...calls.map((call) => call.eventId), + ...replayFacts.flatMap((fact) => fact.eventIds), + ]; + const replayReasoning = reasoning + ?.map(reasoningReplay) + .filter((item): item is ReplayReasoning => item !== undefined); + for (const item of replayReasoning ?? []) { + if (item.part) content.push(item.part); + } + // Provider-owned tools execute before the grounded assistant text in the + // same provider step. Preserve that chronology for Responses item + // references and Anthropic server_tool_use/result replay. Client tools + // stay after text because their execution begins only after this step. + for (const { call, result } of exchanges) { + if (call.providerExecuted !== true) continue; + content.push({ + type: 'tool-call', + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + providerExecuted: true, + }); + if (!result || result.providerExecuted !== true) continue; + eventIds.push(result.eventId); + content.push({ + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result, call.toolName), + }); + } + if (text && text.content.length > 0) { + content.push({ + type: 'text', + text: text.content, + ...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}), + }); + } + for (const replayFact of replayFacts) { + content.push({ type: 'text', text: replayFact.text }); + } + for (const call of calls) { + if (call.providerExecuted === true) continue; + content.push({ + type: 'tool-call', + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + ...(call.providerExecuted !== undefined + ? { providerExecuted: call.providerExecuted } + : {}), + }); + } + const replayProviderOptions = replayReasoning?.find( + (item) => item.providerOptions !== undefined, + )?.providerOptions; + if (content.length > 0 || replayProviderOptions) { + push( + { + role: 'assistant', + content, + ...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}), + } as ModelMessage, + eventIds, + ); + } + await pushClientToolResults(exchanges); + }; + const admittedItems = admitProviderReasoningReplayItems( + plan.items, + providerReasoningReplayEventIds, + ); + for (const entry of buildRuntimeEventReplayTimeline(admittedItems)) { + if (entry.kind === 'thinking') { + const replayReasoning = reasoningReplay(entry.item); + if (replayReasoning) { + push( + { + role: 'assistant', + content: replayReasoning.part ? [replayReasoning.part] : [], + ...(replayReasoning.providerOptions + ? { providerOptions: replayReasoning.providerOptions } + : {}), + } as ModelMessage, + [entry.item.eventId], + ); + } + continue; + } + if (entry.kind === 'text') { + push(await this.materializeRuntimeReplayItem(budget, entry.item), [entry.item.eventId]); + continue; + } + + const exchanges: RuntimeEventReplayToolExchange[] = []; + const replayFacts: Array<{ readonly text: string; readonly eventIds: readonly string[] }> = + []; + for (const { call, result } of entry.calls) { + if (call.toolName !== 'apply_patch') { + exchanges.push({ call, ...(result ? { result } : {}) }); + continue; + } + const replayInput = normalizeApplyPatchReplayInput( + this.input.applyPatchProfile, + call.toolCallId, + call.input, + ); + if (replayInput !== null) { + exchanges.push({ + call: { + ...call, + input: replayInput, + ...(replayInput !== call.input ? { providerOptions: undefined } : {}), + }, + ...(result ? { result } : {}), + }); + continue; + } + if (!result) continue; + const replayFact = result.modelProjection + ? durableApplyPatchReplayFactText(call.input, result.modelProjection, result.isError) + : applyPatchReplayFactText(call.input, result.output, result.isError); + if (!replayFact) continue; + replayFacts.push({ text: replayFact, eventIds: [call.eventId, result.eventId] }); + } + await emitStep(entry.reasoning, entry.text, exchanges, replayFacts); + } + return this.prependProviderHistoryCompactMessage(out, historyCompactCheckpoint); + } + + async materializeRuntimeReplayTextOnly( + budget: ProviderImageBudget, + plan: RuntimeEventModelReplayPlan, + historyCompactCheckpoint?: HistoryCompactCheckpoint, + ): Promise { + const messages: ModelMessage[] = []; + for (const item of plan.items) { + if (item.kind === 'text') + this.pushMemoryIndexedMessage( + messages, + await this.materializeRuntimeReplayItem(budget, item), + [item.eventId], + ); + } + return this.prependProviderHistoryCompactMessage(messages, historyCompactCheckpoint); + } + + private prependProviderHistoryCompactMessage( + messages: ModelMessage[], + checkpoint: HistoryCompactCheckpoint | undefined, + ): ModelMessage[] { + if (!checkpoint || !isProviderHistoryCompactCheckpoint(checkpoint)) return messages; + const providerMessage = historyCompactCheckpointToModelMessage(checkpoint); + this.memoryReplayMessageEvents.set(providerMessage, [ + `history-compact:${checkpoint.checkpointId}`, + ]); + return [providerMessage, ...messages]; + } + + private pushMemoryIndexedMessage( + messages: ModelMessage[], + message: ModelMessage, + eventIds: readonly string[], + ): void { + messages.push(message); + this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); + } + + memoryEventMessagePositions( + messages: readonly ModelMessage[], + ): Readonly> | undefined { + const positions: Record = {}; + for (const [position, message] of messages.entries()) { + for (const eventId of this.memoryReplayMessageEvents.get(message) ?? []) { + (positions[eventId] ??= []).push(position); + } + } + return Object.keys(positions).length > 0 ? positions : undefined; + } + + private async materializeRuntimeReplayItem( + budget: ProviderImageBudget, + item: Extract, + ): Promise { + if (item.role === 'user') { + if (item.steering) { + // Already envelope-wrapped by the plan; carry the structured identity + // so injection dedupe recognizes the replayed message. + return { + role: 'user', + content: item.content, + providerOptions: steeringProviderOptions(item.steering.eventId), + }; + } + return { + role: 'user', + content: await this.appendImageParts( + budget, + item.content, + item.attachments, + `runtime-event:${item.eventId}`, + ), + } as ModelMessage; + } + return { + role: item.role, + content: item.content, + ...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}), + }; + } + + /** A decision key deduplicates re-materialization; no key charges each occurrence. */ + private chargeImageBudget( + budget: ProviderImageBudget, + bytes: number, + decisionKey?: string, + ): boolean { + if (decisionKey !== undefined) { + const cached = budget.decisions.get(decisionKey); + if (cached !== undefined) return cached; + } + const keep = + budget.used + bytes <= + (this.input.maxProviderImageRequestBytes ?? MAX_PROVIDER_IMAGE_REQUEST_BYTES); + if (keep) budget.used += bytes; + if (decisionKey !== undefined) budget.decisions.set(decisionKey, keep); + return keep; + } + + /** + * Render provider-visible content for a user message: keep the given + * (already-formatted) text, and append image attachments as provider image + * parts only for explicitly vision-capable models. Non-image attachments stay + * as placeholder refs in the text. Shared by the current turn and RuntimeEvent replay. + */ + async appendImageParts( + budget: ProviderImageBudget, + textContent: string, + attachments?: AttachmentRef[], + decisionKeyPrefix?: string, + ): Promise { + const images = attachments?.filter((a) => a.kind === 'image') ?? []; + if (images.length === 0) { + return textContent; + } + if (this.input.supportsVision !== true) { + // `textContent` already carries each attachment's stable Read argument. + // Native provider image delivery is unavailable here, but that does not + // establish whether the model can process the image through a tool. + return textContent; + } + if (!this.input.readAttachmentBytes) { + return textContent; + } + const parts: Array< + | { type: 'text'; text: string } + | { + type: 'file'; + data: { type: 'data'; data: Uint8Array }; + mediaType: string; + } + > = [{ type: 'text', text: textContent }]; + let omittedByBudget = 0; + for (const [index, image] of images.entries()) { + const read = await this.input.readAttachmentBytes(image.ref); + if (!read.ok) { + parts.push({ + type: 'text', + text: `Image attachment "${image.name}" could not be loaded: ${read.reason}.`, + }); + continue; + } + const decisionKey = + decisionKeyPrefix === undefined ? undefined : `${decisionKeyPrefix}:image:${index}`; + if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { + omittedByBudget += 1; + continue; + } + parts.push({ + type: 'file', + data: { type: 'data', data: read.bytes }, + mediaType: image.mimeType, + }); + } + if (omittedByBudget > 0) { + parts.push({ + type: 'text', + text: `[${omittedByBudget} image attachment(s) omitted: the per-request image budget was exceeded. Earlier images were sent; ask the user to send fewer or smaller images.]`, + }); + } + return parts; + } + + private async materializeToolResultOutput( + budget: ProviderImageBudget, + output: unknown, + isError: boolean, + decisionKey: string, + ): Promise { + if (isError || !isImageToolResult(output)) return toolResultOutput(output, isError); + if (this.input.supportsVision !== true) { + return toolResultText('Image was read, but the selected model does not support image input.'); + } + if (!this.input.readAttachmentBytes) { + return toolResultText('Image was read, but its stored bytes are unavailable.'); + } + if (budget && budget.decisions.get(decisionKey) === false) { + return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); + } + let read: Awaited>; + try { + read = await this.input.readAttachmentBytes(output.ref); + } catch { + return toolResultText('Image could not be loaded from artifact storage: read_failed.'); + } + if (!read.ok) { + return toolResultText(`Image could not be loaded from artifact storage: ${read.reason}.`); + } + if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { + return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); + } + return { + type: 'content', + value: [ + { type: 'text', text: 'Image read successfully.' }, + { + type: 'file', + data: { + type: 'data', + data: Buffer.from(read.bytes).toString('base64'), + }, + mediaType: output.mimeType, + }, + ], + }; + } + + private async materializeDurableToolResultProjection( + budget: ProviderImageBudget, + projection: DurableToolResultProjection, + decisionKey: string, + ): Promise { + if (projection.kind !== 'content') return durableProjectionToToolResultOutput(projection); + const value: Extract['value'] = []; + for (const [index, part] of projection.parts.entries()) { + if (part.kind === 'text') { + value.push({ type: 'text', text: part.text }); + continue; + } + if (this.input.supportsVision !== true) { + value.push({ + type: 'text', + text: 'Image was read, but the selected model does not support image input.', + }); + continue; + } + if (!this.input.readAttachmentBytes) { + value.push({ + type: 'text', + text: 'Image was read, but its stored bytes are unavailable.', + }); + continue; + } + let read: Awaited>; + try { + read = await this.input.readAttachmentBytes(part.ref); + } catch { + value.push({ + type: 'text', + text: 'Image could not be loaded from artifact storage: read_failed.', + }); + continue; + } + if (!read.ok) { + value.push({ + type: 'text', + text: `Image could not be loaded from artifact storage: ${read.reason}.`, + }); + continue; + } + if (!this.chargeImageBudget(budget, read.bytes.length, `${decisionKey}:artifact:${index}`)) { + value.push({ type: 'text', text: PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE }); + continue; + } + value.push({ + type: 'file', + data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') }, + mediaType: part.mediaType, + }); + } + return { type: 'content', value }; + } + + async buildCurrentUserContent( + budget: ProviderImageBudget, + text: string, + attachments?: AttachmentRef[], + directoryReferences?: DirectoryReference[], + quotes?: QuoteRef[], + runtimeEventId?: string, + ): Promise { + return await this.appendImageParts( + budget, + formatTextWithInlineRefs(text, { + ...(attachments !== undefined ? { attachments } : {}), + ...(directoryReferences !== undefined ? { directoryReferences } : {}), + ...(quotes !== undefined ? { quotes } : {}), + }), + attachments, + runtimeEventId === undefined ? undefined : `runtime-event:${runtimeEventId}`, + ); + } +} diff --git a/packages/runtime/src/ai-sdk-tool-repair.ts b/packages/runtime/src/ai-sdk-tool-repair.ts new file mode 100644 index 0000000000..932ffec744 --- /dev/null +++ b/packages/runtime/src/ai-sdk-tool-repair.ts @@ -0,0 +1,152 @@ +/* + * 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 { z } from 'zod'; + +import type { RepairableAiSdkToolCall } from './model-adapter.js'; +import { SandboxCommandError } from './sandbox/errors.js'; +import { REQUEST_SANDBOX_BOUNDARY_TOOL_NAME } from './sandbox-boundary-tool.js'; +import { + formatSyntheticToolErrorText, + formatToolArgsViolationText, + type MakaTool, +} from './tool-runtime.js'; + +export const INVALID_TOOL_NAME = 'invalid'; + +export function repairMakaToolCall(input: { + toolCall: RepairableAiSdkToolCall; + availableToolNames: readonly string[]; + error: unknown; + /** Schema lookup for the tool that was called, when the caller has one. */ + toolParameters?: (toolName: string) => unknown; + /** + * Category lookup for the same tool. + * + * Computer Use declares one flat wire object standing in for a per-action + * union, so its schema shape alone names every field of every action. + */ + toolCategoryHint?: (toolName: string) => string | undefined; +}): RepairableAiSdkToolCall | null { + const requestedName = input.toolCall.toolName; + if (requestedName === INVALID_TOOL_NAME) return null; + + const lowerRequestedName = requestedName.toLowerCase(); + const exactLowercaseMatch = input.availableToolNames.find( + (name) => name.toLowerCase() === lowerRequestedName, + ); + if (exactLowercaseMatch && exactLowercaseMatch !== requestedName) { + return { ...input.toolCall, toolName: exactLowercaseMatch }; + } + + return { + ...input.toolCall, + toolName: INVALID_TOOL_NAME, + input: JSON.stringify({ + tool: requestedName, + error: describeUnrepairableToolCall(input), + ...(isProviderSandboxBoundaryAttempt(input.toolCall) ? { sandboxBoundaryAttempt: true } : {}), + }), + }; +} + +/** + * What the model is told about a call that could not be repaired. + * + * Two different failures arrive here. A name that matches nothing: the caller + * is holding the list of names that would have worked and used to drop it, + * leaving the model with its own wrong name and a validator's complaint — the + * same dead end `tool-availability` avoids by naming what is available. + * Arguments the tool's schema rejected: the schema knows which fields the call + * takes, so say them rather than let the model re-send the shape just refused. + */ +function describeUnrepairableToolCall(input: { + toolCall: RepairableAiSdkToolCall; + availableToolNames: readonly string[]; + error: unknown; + toolParameters?: (toolName: string) => unknown; + toolCategoryHint?: (toolName: string) => string | undefined; +}): string { + const requestedName = input.toolCall.toolName; + const known = input.availableToolNames.includes(requestedName); + if (!known) { + const available = input.availableToolNames.join(', '); + const detail = formatSyntheticToolErrorText(input.error); + return available ? `${detail} Available tools: ${available}.` : detail; + } + return formatToolArgsViolationText({ + toolName: requestedName, + parameters: input.toolParameters?.(requestedName), + categoryHint: input.toolCategoryHint?.(requestedName), + args: parseToolCallInput(input.toolCall.input), + error: input.error, + }); +} + +function parseToolCallInput(raw: unknown): unknown { + if (typeof raw !== 'string') return raw; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +export function isProviderSandboxBoundaryAttempt(toolCall: { + toolName: string; + input: unknown; +}): boolean { + const toolName = toolCall.toolName.toLowerCase(); + if (toolName === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME) return true; + if (toolName !== 'bash') return false; + const parsed = parseToolCallInput(toolCall.input); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + const boundaryIntent = (parsed as Record).boundary_intent; + return boundaryIntent !== undefined && boundaryIntent !== 'current'; +} + +export function buildInvalidMakaTool(): MakaTool< + { tool?: string; error?: string; sandboxBoundaryAttempt?: true }, + never +> { + return { + name: INVALID_TOOL_NAME, + description: + 'Internal repair target for malformed or unknown tool calls. Do not call directly.', + parameters: z.object({ + tool: z.string().optional(), + error: z.string().optional(), + sandboxBoundaryAttempt: z.literal(true).optional(), + }), + impl: ({ tool, error, sandboxBoundaryAttempt }) => { + const requested = tool ? ` "${tool}"` : ''; + const message = `模型请求了不可用或格式错误的工具${requested}:${error || 'tool call could not be parsed'}`; + if (sandboxBoundaryAttempt) { + throw new SandboxCommandError({ + domain: 'command', + stage: 'validation', + reason: 'invalid_boundary_declaration', + recoverable: true, + message, + }); + } + throw new Error(message); + }, + }; +} diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts new file mode 100644 index 0000000000..c501a66ac7 --- /dev/null +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -0,0 +1,3228 @@ +/* + * 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. + */ + +/** + * One AI SDK turn: request projection, provider steps, retries, tool + * settlement, steering, terminal events, and per-turn cleanup. Session-level + * construction and cross-turn routing remain in AiSdkBackend. + */ + +import type { + AbortEvent, + CompleteEvent, + ErrorEvent, + ProviderRetryEvent, + ProviderRetryReason, + SessionEvent, + TextCompleteEvent, + TextDeltaEvent, + ThinkingCompleteEvent, + ThinkingDeltaEvent, + TokenUsageEvent, + ToolResultContent, + ToolResultEvent, + ToolStartEvent, +} from '@maka/core/events'; +import type { + AssistantMessage, + AssistantStepContentKind, + AssistantThinkingPart, + SessionHeader, + SystemNoteMessage, + TokenUsageMessage, +} from '@maka/core/session'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { UserQuestionResponse } from '@maka/core/user-question'; +import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; +import { + resolveEffectiveOrchestration, + type EffectiveOrchestration, +} from '@maka/core/orchestration'; +import type { ContextBudgetDiagnostic, LlmCallRecord } from '@maka/core/usage-stats/types'; +import { stripUndefinedDeep } from '@maka/core/tool-args-identity'; +import type { PlanToolResult } from './plan-tools.js'; +import { + YIELD_AGENT_GRAPH_TOOL_NAME, + type YieldAgentGraphToolResult, +} from './stream-graph-supervisor-tools.js'; +import type { + ModelFinishReason, + ModelMessage, + ModelStepOutcome, + ModelToolSet, + NormalizedUsage, + ModelFailureKind, + ToolCallPart, +} from './model-protocol.js'; +import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019.js'; +import Ajv2020 from 'ajv/dist/2020.js'; +import { z } from 'zod'; + +import { AsyncEventQueue } from './async-queue.js'; +import { AdmissionLimiter } from './admission-limiter.js'; +import { + type CodeModeExecutionResult, + DEFAULT_CODE_MODE_EXECUTION_POLICY, + executeCodeCell, +} from './code-mode.js'; +import { + StreamWatchdog, + formatStreamWatchdogError, + type StreamWatchdogPhase, +} from './stream-watchdog.js'; +import { + ToolRuntime, + isRuntimeCommitBoundaryError, + type MakaTool, + type MakaToolContext, + type DurableSessionEventSink, +} from './tool-runtime.js'; +import { + ModelAdapter, + type NormalizedAiSdkUsage, + type ModelStreamResult, + type RepairableAiSdkToolCall, +} from './model-adapter.js'; +import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; +import { nonCanonicalContentOrder } from './runtime-event-read-model.js'; +import { + composeRequestProjection, + type DispatchRequestShape, + type RequestProjectionContext, +} from './request-projection.js'; +import { + decodePlaintextResponsesReasoningState, + responsesReasoningItemId, +} from './responses-reasoning-state.js'; +import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result-prune.js'; +import { finitePositive } from './context-budget-helpers.js'; +import type { + AutomaticMemoryCompactionDecision, + AutomaticMemoryCompactionDispatch, + ProviderImageBudget, +} from './ai-sdk-compaction.js'; +import { + contextDiagnosticsCompactionOf, + type ContextDiagnosticsCompaction, +} from './context-diagnostics.js'; +import { + AiSdkCompaction, + hasActiveToolResultPruneDiagnosticPatch, + hasBlockingReplayDiagnostics, +} from './ai-sdk-compaction.js'; +import { RunTrace } from './run-trace.js'; +import { + REQUEST_SANDBOX_BOUNDARY_TOOL_NAME, + SANDBOX_BOUNDARY_DENIED_FOR_TURN, + SANDBOX_BOUNDARY_FINALIZATION_PROMPT, +} from './sandbox-boundary-tool.js'; +import { + buildRuntimeEventModelReplayPlan, + buildSteeringEnvelope, + collectToolActivityTurnIds, + compatibleProviderReasoningReplayEventIds, + formatTextWithInlineRefs, + steeringMessagesMissingFromBase, + steeringModelMessage, + type RuntimeEventModelReplayPlan, + type RuntimeEventReplayFallbackGate, +} from './model-history.js'; +import { toolSchemaCharsForDiagnostics } from './request-shape.js'; +import { ProviderRequestTelemetry } from './provider-request-telemetry.js'; +import { AiSdkMessageProjection } from './ai-sdk-message-projection.js'; +import { ToolAvailabilityRuntime, type ToolAvailabilityPlan } from './tool-availability.js'; +import { renderSwarmModePrompt } from './swarm-mode.js'; +import { renderGraphModePrompt } from './graph-mode.js'; +import type { MemoryExtractionSourceSnapshot } from './memory-extraction.js'; +import { modelUsesNativeOpenAiResponses } from './model-runtime.js'; +import { + applyRuntimeEventContextBudget, + buildContextBudgetDiagnosticShell, + mergeContextBudgetDiagnostic, + mergeContextBudgetDiagnosticPatches, + minimalContextBudgetDiagnostic, + shouldAppendContextCompactedNote, + shouldAppendContextCompactionFailedOpenNote, +} from './context-budget.js'; +import { isHistoryCompactContentEvent } from './history-compaction.js'; +import { + canContinueHistoryCompactCheckpointForModel, + isProviderHistoryCompactCheckpoint, + matchHistoryCompactCheckpointPrefix, + projectHistoryCompactCheckpointReplay, + type HistoryCompactCheckpoint, +} from './history-compact-checkpoint.js'; +import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; +import type { AiSdkBackendInput } from './ai-sdk-backend.js'; +import { + INVALID_TOOL_NAME, + isProviderSandboxBoundaryAttempt, + repairMakaToolCall, +} from './ai-sdk-tool-repair.js'; + +export interface AiSdkSessionState { + contextProviderDroppingReported: boolean; + cumulativeUsageCheckpoint?: NormalizedAiSdkUsage; +} + +export interface AiSdkTurnDependencies { + backend: AiSdkBackendInput; + modelAdapter: ModelAdapter; + messageProjection: AiSdkMessageProjection; + providerTelemetry: ProviderRequestTelemetry; + compaction: AiSdkCompaction; + toolAvailabilityRuntime: ToolAvailabilityRuntime; + codeCellAdmission: AdmissionLimiter; + resolvedProviderOptions: Record; + session: AiSdkSessionState; + newId: () => string; + now: () => number; + maxSteps?: number; + providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; + createToolRuntime: (turn: AiSdkTurn) => ToolRuntime; +} + +type PriorReplayResult = { + status: 'ready'; + messages: ModelMessage[]; + gate: RuntimeEventReplayFallbackGate; + diagnostics: RuntimeEventModelReplayPlan['diagnostics']; + runtimeEventCount?: number; + contextBudget?: ContextBudgetDiagnostic; + latestHistoryCompactCheckpoint?: HistoryCompactCheckpoint; +}; + +const CHILD_STEP_BUDGET_FINALIZATION_PROMPT = [ + '', + 'This is the final budgeted step for this child-agent turn.', + 'Do not call tools. Return the best concise final answer now using evidence already gathered.', + 'Clearly separate verified findings from inference and explicitly name any remaining gaps.', + '', +].join('\n'); + +function providerToolResultContent( + toolName: string, + output: unknown, + input?: unknown, +): ToolResultContent { + if (output === undefined) { + return { + kind: 'text', + text: `${toolName} completed without a structured result.`, + }; + } + if (toolName !== 'WebSearch') { + return { kind: 'json', value: output }; + } + const queryFromInput = providerWebSearchQuery(input); + if (Array.isArray(output)) { + const rows: Array<{ + title: string; + url: string; + snippet: string; + source: string; + }> = []; + for (const result of output) { + if ( + !result || + typeof result !== 'object' || + (result as { type?: unknown }).type !== 'web_search_result' || + typeof (result as { url?: unknown }).url !== 'string' + ) { + continue; + } + const item = result as { + url: string; + title?: unknown; + pageAge?: unknown; + }; + try { + const parsed = new URL(item.url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; + rows.push({ + title: typeof item.title === 'string' && item.title.trim() ? item.title : parsed.hostname, + url: parsed.toString(), + snippet: typeof item.pageAge === 'string' ? item.pageAge : '', + source: parsed.hostname, + }); + } catch { + // Provider source rows are untrusted; malformed URLs are dropped. + } + } + return { + kind: 'web_search', + provider: 'model', + query: queryFromInput, + rows, + }; + } + if (!output || typeof output !== 'object') return { kind: 'json', value: output }; + const providerError = output as { type?: unknown; errorCode?: unknown }; + if ( + providerError.type === 'web_search_tool_result_error' || + typeof providerError.errorCode === 'string' + ) { + return { + kind: 'web_search_error', + ok: false, + provider: 'model', + ...(queryFromInput ? { query: queryFromInput } : {}), + reason: 'provider_error', + message: + typeof providerError.errorCode === 'string' + ? `Provider web search failed: ${providerError.errorCode}` + : 'Provider web search failed.', + }; + } + const action = (output as { action?: unknown }).action; + const sources = (output as { sources?: unknown }).sources; + let query = queryFromInput; + if (action && typeof action === 'object') { + const value = action as { + type?: unknown; + query?: unknown; + queries?: unknown; + }; + if (Array.isArray(value.queries)) { + query = value.queries.filter((item): item is string => typeof item === 'string').join(' | '); + } else if (typeof value.query === 'string') { + query = value.query; + } + } + const rows: Array<{ + title: string; + url: string; + snippet: string; + source: string; + }> = []; + if (Array.isArray(sources)) { + for (const source of sources) { + if ( + !source || + typeof source !== 'object' || + (source as { type?: unknown }).type !== 'url' || + typeof (source as { url?: unknown }).url !== 'string' + ) { + continue; + } + const url = (source as { url: string }).url; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; + rows.push({ + title: parsed.hostname, + url: parsed.toString(), + snippet: '', + source: parsed.hostname, + }); + } catch { + // Provider source rows are untrusted; malformed URLs are dropped. + } + } + } + return { kind: 'web_search', provider: 'model', query, rows }; +} + +function providerWebSearchQuery(input: unknown): string { + let value = input; + if (typeof input === 'string') { + try { + value = JSON.parse(input); + } catch { + return ''; + } + } + if (!value || typeof value !== 'object') return ''; + const query = (value as { query?: unknown }).query; + return typeof query === 'string' ? query : ''; +} + +function mergeTextProviderOptions( + current: NonNullable | undefined, + next: NonNullable, + textOffset: number, +): NonNullable { + const shifted = structuredClone(next); + const shiftedOpenAi = shifted.openai; + if (shiftedOpenAi && typeof shiftedOpenAi === 'object' && !Array.isArray(shiftedOpenAi)) { + const annotations = (shiftedOpenAi as { annotations?: unknown }).annotations; + if (Array.isArray(annotations) && textOffset > 0) { + (shiftedOpenAi as { annotations: unknown[] }).annotations = annotations.map((annotation) => { + if (!annotation || typeof annotation !== 'object' || Array.isArray(annotation)) { + return annotation; + } + const value = { ...annotation } as Record; + if (typeof value.startIndex === 'number') value.startIndex += textOffset; + if (typeof value.endIndex === 'number') value.endIndex += textOffset; + if (typeof value.start_index === 'number') value.start_index += textOffset; + if (typeof value.end_index === 'number') value.end_index += textOffset; + return value; + }); + } + } + if (!current) return shifted; + + const merged = { ...structuredClone(current), ...shifted }; + const currentOpenAi = current.openai; + if ( + currentOpenAi && + typeof currentOpenAi === 'object' && + !Array.isArray(currentOpenAi) && + shiftedOpenAi && + typeof shiftedOpenAi === 'object' && + !Array.isArray(shiftedOpenAi) + ) { + const left = currentOpenAi as Record; + const right = shiftedOpenAi as Record; + const openai: Record = { ...left, ...right }; + const leftAnnotations = Array.isArray(left.annotations) ? left.annotations : []; + const rightAnnotations = Array.isArray(right.annotations) ? right.annotations : []; + if (leftAnnotations.length > 0 || rightAnnotations.length > 0) { + openai.annotations = [...leftAnnotations, ...rightAnnotations]; + } + if ( + typeof left.itemId === 'string' && + typeof right.itemId === 'string' && + left.itemId !== right.itemId + ) { + delete openai.itemId; + } + merged.openai = openai as NonNullable[string]; + } + return merged; +} + +function projectToolModePlan( + plan: ToolAvailabilityPlan, + toolMode: ToolMode, + execTool: MakaTool, +): ToolAvailabilityPlan { + if (toolMode === 'direct') return plan; + const withExec = (names: readonly string[]): string[] => + [...new Set([...names, execTool.name])].sort((a, b) => a.localeCompare(b)); + const invalid = plan.providerTools.filter((tool) => tool.name === INVALID_TOOL_NAME); + const visible = [ + ...plan.providerTools.filter((tool) => tool.name !== INVALID_TOOL_NAME), + execTool, + ].sort((a, b) => a.name.localeCompare(b.name)); + return { + ...plan, + providerTools: [...visible, ...invalid], + activeTools: withExec(plan.activeTools), + ...(plan.projectActiveTools + ? { + projectActiveTools: (options) => ({ + activeTools: withExec(plan.projectActiveTools?.(options).activeTools ?? []), + }), + } + : {}), + currentRepairToolNames: () => withExec(plan.currentRepairToolNames()), + diagnostics: (activeTools, visibleToolSchemaChars) => { + const baseActive = activeTools.filter((name) => name !== execTool.name); + const baseChars = toolSchemaCharsForDiagnostics(plan.providerTools, baseActive); + const diagnostic = plan.diagnostics(baseActive, baseChars); + if (!diagnostic) return undefined; + const execSchemaChars = Math.max(0, visibleToolSchemaChars - baseChars); + return { + ...diagnostic, + visibleToolCount: (diagnostic.visibleToolCount ?? baseActive.length) + 1, + fullToolCount: + (diagnostic.fullToolCount ?? baseActive.length + (diagnostic.hiddenToolCount ?? 0)) + 1, + visibleToolSchemaChars, + fullToolSchemaChars: + (diagnostic.fullToolSchemaChars ?? + baseChars + (diagnostic.toolSchemaCharReduction ?? 0)) + execSchemaChars, + }; + }, + }; +} + +function nestableToolSnapshot( + providerTools: readonly MakaTool[], + activeToolNames: readonly string[], +): ReadonlyMap { + const active = new Set(activeToolNames); + return new Map( + providerTools + .filter( + (tool) => + active.has(tool.name) && + tool.name !== INVALID_TOOL_NAME && + tool.name !== 'exec' && + tool.providerTool === undefined && + tool.nesting !== 'direct_only', + ) + .map((tool) => [tool.name, tool] as const), + ); +} + +const codeModeJsonSchemaOptions = { + allErrors: true, + strict: false, + validateFormats: false, +} as const; +const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); +const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); +const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); +const codeModeCompiledSchemas = new WeakMap(); + +async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { + const parameters = tool.parameters as { + safeParseAsync?: ( + value: unknown, + ) => Promise<{ success: true; data: unknown } | { success: false; error: unknown }>; + safeParse?: ( + value: unknown, + ) => { success: true; data: unknown } | { success: false; error: unknown }; + validate?: ( + value: unknown, + ) => + | { success: true; value: unknown } + | { success: false; error: unknown } + | Promise<{ success: true; value: unknown } | { success: false; error: unknown }>; + jsonSchema?: unknown; + }; + const parserResult = parameters.safeParseAsync + ? await parameters.safeParseAsync(input) + : parameters.safeParse?.(input); + if (parserResult) { + if (parserResult.success) return parserResult.data; + throw invalidCodeModeToolArguments(tool.name, parserResult.error); + } + + if (parameters.validate) { + const validationResult = await parameters.validate(input); + if (validationResult.success) return validationResult.value; + throw invalidCodeModeToolArguments(tool.name, validationResult.error); + } + + const schema = await parameters.jsonSchema; + const validator = compileCodeModeJsonSchema(schema ?? tool.parameters); + if (!validator || validator(input)) return input; + throw invalidCodeModeToolArguments(tool.name, validator.errors); +} + +function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; + const cached = codeModeCompiledSchemas.get(schema); + if (cached) return cached; + const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; + const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; + const validator = dialect.includes('draft-07') + ? codeModeDraft7Validator + : dialect.includes('2019-09') + ? codeModeDraft2019Validator + : codeModeDraft2020Validator; + const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') + ? { ...schema, $schema: dialect.replace('https://', 'http://') } + : schema; + const compiled = validator.compile(schemaForCompile as AnySchema); + codeModeCompiledSchemas.set(schema, compiled); + return compiled; +} + +function invalidCodeModeToolArguments(toolName: string, error: unknown): Error { + return new Error(`Invalid arguments for tool "${toolName}": ${schemaErrorSummary(error)}`); +} + +function schemaErrorSummary(error: unknown): string { + if (error && typeof error === 'object' && Array.isArray((error as { issues?: unknown }).issues)) { + const issues = (error as { issues: Array<{ path?: unknown; message?: unknown }> }).issues; + return issues + .slice(0, 5) + .map((issue) => { + const path = Array.isArray(issue.path) ? issue.path.join('.') : ''; + const message = typeof issue.message === 'string' ? issue.message : 'invalid value'; + return path ? `${path}: ${message}` : message; + }) + .join('; ') + .slice(0, 1000); + } + if (Array.isArray(error)) { + return (error as ErrorObject[]) + .slice(0, 5) + .map((issue) => { + const path = issue.instancePath || issue.schemaPath; + return `${path || 'input'} ${issue.message ?? 'is invalid'}`; + }) + .join('; ') + .slice(0, 1000); + } + return 'input does not match the declared schema'; +} + +function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined { + const joined = fragments + .map((fragment) => fragment?.trim()) + .filter((fragment): fragment is string => Boolean(fragment)) + .join('\n\n'); + return joined.length > 0 ? joined : undefined; +} + +const MAX_WAITING_CODE_MODE_CELLS = 1; + +const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; +const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; +const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; +// A mid-stream cut after partial thinking seals one transcript fragment per +// retry. A gateway that systematically kills long thinking streams (the +// 2026-08-28 incident shape) would otherwise spend the full attempt budget +// accumulating fragments before failing anyway, so fail fast after one. +const MAX_SEALED_THINKING_RETRIES_PER_STEP = 1; +const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; +const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; +const PROVIDER_RETRY_JITTER_FACTOR = 0.25; + +function providerRetryDelayMs(failedAttempt: number, retryAfterMs?: number): number { + if (retryAfterMs !== undefined) return retryAfterMs; + const base = Math.min( + PROVIDER_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, failedAttempt - 1), + PROVIDER_RETRY_MAX_DELAY_MS, + ); + return Math.ceil(base + Math.random() * PROVIDER_RETRY_JITTER_FACTOR * base); +} + +function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason { + switch (kind) { + case 'network': + case 'provider_unavailable': + case 'rate_limit': + case 'timeout': + return kind; + case 'provider_capacity': + return 'provider_capacity'; + default: + return 'unknown'; + } +} + +function isIncompleteProviderFinishReason(reason: ModelFinishReason | undefined): boolean { + return reason === undefined || reason === 'other' || reason === 'unknown'; +} + +/** + * The mutable state of ONE `send()`. + * + * Identity is readonly and captured at dispatch: a tool that executes minutes + * later commits against the run that actually issued it, never against whatever + * run happens to be current when it finishes. The remaining fields are the + * turn's own stream/abort bookkeeping, isolated so an overlapping turn on the + * same backend cannot observe or clear them. + * + * Each turn owns its ToolRuntime for the same reason: gating, the loop gate, + * the subagent and child-run limiters, durable attempts, and step admission are + * all per-turn facts. + */ + +export class AiSdkTurn { + readonly abortController = new AbortController(); + readonly activeTools = new Map(); + aborted = false; + loopStopRequested = false; + loopStopReason: CompleteEvent['stopReason'] | undefined; + watchdog: StreamWatchdog | null = null; + runTrace: RunTrace | null = null; + readonly imageBudget: ProviderImageBudget = { used: 0, decisions: new Map() }; + injectedSteeringMessages: ModelMessage[] = []; + memoryExtractRequested = false; + memorySourceMessages: readonly ModelMessage[] | undefined; + memorySourceEventMessagePositions: Readonly> | undefined; + memorySourceSystemPrompt: string | undefined; + memorySourceTools: ModelToolSet | undefined; + memorySourceActiveTools: readonly string[] | undefined; + finalAssistantText: string | undefined; + codeModeTools: ReadonlyMap | undefined; + readonly turnId: string; + readonly runId: string | undefined; + readonly orchestration: EffectiveOrchestration; + readonly toolRuntime: ToolRuntime; + + constructor( + private readonly deps: AiSdkTurnDependencies, + private readonly request: BackendSendInput, + ) { + this.turnId = request.turnId; + this.runId = request.runId; + this.orchestration = + request.orchestration ?? + resolveEffectiveOrchestration(deps.backend.header.orchestrationMode, undefined); + this.toolRuntime = deps.createToolRuntime(this); + } + + async *run(): AsyncIterable { + yield* this.runWithinScope(this.request); + } + + /** Release resources after the backend removes this turn from its active index. */ + async close(): Promise { + this.deps.modelAdapter.endContinuation(this.turnId); + await this.toolRuntime.endTurn(this.aborted ? 'aborted' : 'completed'); + } + + requestStop(reason: 'user_stop' | 'redirect', mode: 'immediate' | 'after_step'): void { + if (mode === 'after_step') { + this.loopStopRequested = true; + this.runTrace?.abortRequested(reason); + return; + } + this.aborted = true; + this.abortController.abort(); + this.runTrace?.abortRequested(reason); + } + + async endAbortedTools(): Promise { + await this.toolRuntime.endTurn('aborted'); + } + + async respondToSandboxBoundary(decision: SandboxBoundaryResponse): Promise { + return await this.toolRuntime.respondToSandboxBoundaryResponse(decision); + } + + respondToUserQuestion(response: UserQuestionResponse): boolean { + return this.toolRuntime.respondToUserQuestion(response); + } + + memorySourceSnapshot( + boundary: + | { readonly trigger: 'remember'; readonly toolCallId: string } + | { readonly trigger: 'extract'; readonly terminalEventId: string }, + ): MemoryExtractionSourceSnapshot | undefined { + if ( + !this.runId || + !this.memorySourceMessages || + !this.memorySourceTools || + !this.memorySourceActiveTools + ) { + return undefined; + } + const sourceMessages = + boundary.trigger === 'extract' && this.finalAssistantText + ? [ + ...this.memorySourceMessages, + { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: this.finalAssistantText }], + } as ModelMessage, + ] + : this.memorySourceMessages; + const memoryProjection = projectMemoryConversationPrefix( + sourceMessages, + this.memorySourceEventMessagePositions, + ); + return { + ...boundary, + sourceHeader: memoryExtractionModelHeader(this.deps.backend.header), + ...(this.memorySourceSystemPrompt + ? { sourceSystemPrompt: this.memorySourceSystemPrompt } + : {}), + sourceMessages: structuredClone(memoryProjection.messages), + ...(memoryProjection.eventMessagePositions + ? { + sourceEventMessagePositions: structuredClone(memoryProjection.eventMessagePositions), + } + : {}), + sourceTools: { ...this.memorySourceTools }, + sourceActiveTools: [...this.memorySourceActiveTools], + sourceProviderOptions: structuredClone(this.deps.resolvedProviderOptions), + ...(this.deps.modelAdapter.maxOutputTokens() !== undefined + ? { sourceMaxOutputTokens: this.deps.modelAdapter.maxOutputTokens() } + : {}), + ...(resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ) !== undefined + ? { + sourceContextWindowTokens: resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ), + } + : {}), + sessionId: this.deps.backend.sessionId, + runId: this.runId, + turnId: this.turnId, + workspaceKey: this.deps.backend.header.workspaceRoot, + }; + } + + private dispatchAutomaticMemoryCompaction(dispatch: AutomaticMemoryCompactionDispatch): void { + const capabilities = this.deps.backend.memoryExtraction; + const boundary = dispatch.checkpoint.memoryExtractionBoundary; + if ( + !capabilities || + !this.runId || + !boundary || + modelUsesNativeOpenAiResponses(this.deps.backend.connection, this.deps.backend.modelId) + ) { + return; + } + try { + capabilities.extract({ + trigger: 'compaction', + sourceHeader: memoryExtractionModelHeader(this.deps.backend.header), + // Compaction messages are rebuilt from the durable RuntimeEvent prefix + // inside the background lane, avoiding a full Memory projection here. + sourceMessages: [], + rebuildSourceContextFromCompactionCheckpoint: true, + sourceTools: {}, + sourceActiveTools: [], + ...(this.deps.modelAdapter.maxOutputTokens() !== undefined + ? { sourceMaxOutputTokens: this.deps.modelAdapter.maxOutputTokens() } + : {}), + ...(resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ) !== undefined + ? { + sourceContextWindowTokens: resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ), + } + : {}), + sessionId: this.deps.backend.sessionId, + runId: this.runId, + turnId: this.turnId, + workspaceKey: this.deps.backend.header.workspaceRoot, + compactionCheckpointId: dispatch.checkpoint.checkpointId, + compactionBoundaryEventId: boundary.runtimeEventId, + }); + } catch { + // Automatic memory extraction is fail-open and must never perturb the caller. + } + } + + private automaticMemoryCompactionSupported(): boolean { + return ( + this.deps.backend.memoryExtraction !== undefined && + !modelUsesNativeOpenAiResponses(this.deps.backend.connection, this.deps.backend.modelId) + ); + } + + private automaticMemoryCompactionDecision(): AutomaticMemoryCompactionDecision { + const capabilities = this.deps.backend.memoryExtraction; + if (!capabilities) return { disposition: 'eligible', dispatch: false }; + if (this.deps.backend.header.subagentParent || this.deps.backend.header.isArchived) { + return { disposition: 'policy_denied', dispatch: false }; + } + const gate = capabilities.automaticGate?.() ?? { + allowed: false as const, + reason: 'unavailable' as const, + }; + if (gate.allowed) return { disposition: 'eligible', dispatch: true }; + return gate.reason === 'unavailable' + ? { disposition: 'eligible', dispatch: false } + : { disposition: 'policy_denied', dispatch: false }; + } + + private createCodeModeExecTool(eventSink: DurableSessionEventSink): MakaTool<{ code: string }> { + return { + name: 'exec', + description: [ + 'Execute a bounded orchestration cell over the active tools.', + 'Use tools.(args), await dependent calls, and Promise.all for independent calls.', + 'The sandbox has no process, filesystem, network, timer, eval, import, or cross-cell state.', + 'Terminate by returning a JSON-serializable value. Failures return a structured diagnostic.', + ].join(' '), + parameters: z.object({ code: z.string() }), + executionSemantics: 'exclusive_step', + nesting: 'direct_only', + recoveryMode: 'never_auto_retry', + impl: (args, context) => this.executeCodeModeCell(eventSink, args.code, context), + }; + } + + // -------------------------------------------------------------------------- + // manual history compaction + // -------------------------------------------------------------------------- + + private async *runWithinScope(input: BackendSendInput): AsyncIterable { + const turnId = input.turnId; + const maxSteps = input.maxSteps ?? this.deps.maxSteps; + const toolRuntime = this.toolRuntime; + const turnAbortController = this.abortController; + + const midTurnState = this.deps.compaction.buildMidTurnCapacityCompactState(input); + const queue = new AsyncEventQueue(); + const codeModeExecTool = this.createCodeModeExecTool(queue); + + // One AssistantMessage is flushed per provider step (not per turn), so the + // ledger records the text↔tool timeline at step granularity and each step's + // Anthropic thinking signature stays paired with its own thinking text. The + // turn's first step reuses this id; every later step rotates to a fresh one + // at its step boundary (see the stream loop below). + let currentStepMessageId = this.deps.newId(); + let stepText = ''; + let stepTextProviderOptions: NonNullable | undefined; + let stepTextPartStartOffset = 0; + let stepThinkingParts: AssistantThinkingPart[] = []; + let stepThinkingPartsById = new Map(); + let stepContentOrder: AssistantStepContentKind[] = []; + const recordStepContent = (kind: AssistantStepContentKind): void => { + if (!stepContentOrder.includes(kind)) stepContentOrder.push(kind); + }; + // Flush the current step's AssistantMessage (text + thinking) and the paired + // terminal thinking/text events, then clear the per-step accumulators. + // Persist when the step produced text OR reasoning — a thinking-only step + // (Anthropic's signed/omitted reasoning has empty text) still round-trips its + // signed block; a pure-tool step (no text, no thinking) writes nothing, so + // tool-only steps leave no placeholder assistant row. thinking_complete + // precedes text_complete so the read-model attaches this step's reasoning to + // this step's assistant row. Hoisted to turn scope so both the streaming + // path and the abort/error handler can flush a partial step. + const resetStep = (): void => { + stepText = ''; + stepTextProviderOptions = undefined; + stepTextPartStartOffset = 0; + stepThinkingParts = []; + stepThinkingPartsById = new Map(); + stepContentOrder = []; + }; + const flushStep = async (): Promise => { + const hasThinking = stepThinkingParts.length > 0; + if (stepText.length === 0 && !hasThinking) { + resetStep(); + return; + } + const stepId = currentStepMessageId; + const thinkingText = stepThinkingParts.map((part) => part.text).join(''); + const contentOrder = nonCanonicalContentOrder(stepContentOrder); + const msg: AssistantMessage = { + type: 'assistant', + id: stepId, + turnId, + ts: this.deps.now(), + text: stepText, + ...(stepTextProviderOptions !== undefined + ? { providerOptions: stepTextProviderOptions } + : {}), + ...(contentOrder ? { contentOrder } : {}), + modelId: this.deps.backend.modelId, + ...(hasThinking + ? { + thinking: { + text: thinkingText, + ...(stepThinkingParts.length === 1 && stepThinkingParts[0]!.signature !== undefined + ? { signature: stepThinkingParts[0]!.signature } + : {}), + ...(stepThinkingParts.length === 1 && + stepThinkingParts[0]!.providerOptions !== undefined + ? { providerOptions: stepThinkingParts[0]!.providerOptions } + : {}), + ...(stepThinkingParts.length > 1 ? { parts: stepThinkingParts } : {}), + }, + } + : {}), + }; + await this.deps.backend.appendMessage(msg); + if (hasThinking) { + for (const part of stepThinkingParts) { + queue.push({ + type: 'thinking_complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + messageId: stepId, + text: part.text, + ...(part.signature !== undefined ? { signature: part.signature } : {}), + // No sanitiser here, unlike the tool call below: these options are + // not the provider's object. `translateChunk` rebuilds reasoning + // metadata from two named string fields, so an omitted provider + // field cannot arrive as an explicit `undefined` and break the + // canonical encoding. Passing the provider's object through + // instead would need the same `stripUndefinedDeep` a tool call has. + ...(part.providerOptions !== undefined + ? { providerOptions: part.providerOptions } + : {}), + } satisfies ThinkingCompleteEvent); + } + } + queue.push({ + type: 'text_complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + messageId: stepId, + text: stepText, + ...(stepTextProviderOptions !== undefined + ? { providerOptions: stepTextProviderOptions } + : {}), + } satisfies TextCompleteEvent); + this.finalAssistantText = stepText.length > 0 ? stepText : undefined; + resetStep(); + }; + let tokenUsage: NormalizedAiSdkUsage | undefined; + let tokenUsageCostUsd: number | undefined; + // Per-send sum of every COMPLETED step's usage, merged at each finish-step + // boundary. When the send aborts (mid-turn exhaust, user stop, stream + // error) the SDK's cumulative `usage` promise may not resolve, but this sum is + // real provider-reported evidence for the steps that did finish — IF every + // completed step produced a usable sample. One unusable sample makes the + // sum a partial cost, and LlmCallRecord has no partial marker, so the flag + // fails the whole fallback closed (#972: incomplete usage is no usage). + let completedStepUsage: NormalizedAiSdkUsage | undefined; + let sawUnusableStepUsage = false; + // Input tokens from the last completed step — the actual prompt token count + // of the final API request. Used to compute contextRemaining for the TUI + // statusline ctx segment (#1067): contextRemaining = contextWindow - this. + // result.usage.inputTokens is cumulative across steps and would produce + // misleading >100% percentages, so the per-step value is captured here. + let lastStepInputTokens: number | undefined; + /** Tool count of the request that produced `lastStepInputTokens`. */ + let lastStepActiveToolCount: number | undefined; + // Output tokens of the same step: with the input they are the baseline the + // next request is judged from (everything the model produced is re-sent). + let lastStepOutputTokens: number | undefined; + let streamStatus: LlmCallRecord['status'] = 'success'; + let streamErrorClass: string | undefined; + let runtimeSteps = 0; + let toolAvailabilityForTelemetry: ReturnType = undefined; + let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined; + let contextCompactedNoteWritten = false; + let contextCompactionFailedOpenNoteWritten = false; + let contextWindowOverrunNoteWritten = false; + let contextReportedWindowNoteWritten = false; + let contextOverflowAfterCompactionNoteWritten = false; + let contextWindowSuggestionNoteWritten = false; + // Request index (0-based) at which the active prune last rewrote the + // request. A step Maka pruned is not append-only, so usage may legitimately + // shrink. + let pruneAppliedAtStep: number | undefined; + const trace = new RunTrace({ + sessionId: this.deps.backend.sessionId, + turnId, + connectionSlug: this.deps.backend.connection.slug, + providerId: this.deps.backend.connection.providerType, + modelId: this.deps.backend.modelId, + newId: this.deps.newId, + now: this.deps.now, + record: this.deps.backend.recordRunTrace, + }); + this.runTrace = trace; + trace.turnStarted({ + orchestrationMode: this.orchestration.mode, + orchestrationSource: this.orchestration.source, + agentSwarmAuthorization: this.orchestration.agentSwarmAuthorization, + }); + if (this.deps.backend.planTraceContext) { + trace.emit('plan', 'plan_context_resolved', 'Plan context resolved', { + ...this.deps.backend.planTraceContext, + }); + if (this.deps.backend.planTraceContext.executionId) { + trace.emit('plan', 'plan_execution_started', 'Plan execution turn started', { + ...this.deps.backend.planTraceContext, + }); + } + } + const providerRequestTracker = this.deps.providerTelemetry.createTracker({ + turnId, + callKind: 'main', + modelId: this.deps.backend.modelId, + runId: this.runId, + }); + const providerRequestTraceId = providerRequestTracker?.traceId; + + // --- Resolve model (API key already attached at construct time) --- + let model: unknown; + try { + model = this.deps.modelAdapter.resolveModel(); + trace.modelResolved(); + } catch (err) { + trace.modelResolveFailed(err); + queue.push(this.makeErrorEvent(turnId, err)); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'error', + } satisfies CompleteEvent); + queue.close(); + yield* this.drain(queue); + return; + } + + // --- Build the provider-visible schema set. Tool execution stays in Runtime. --- + // One immutable runtime owns the bound search catalog and cached index. + // Mutable activation belongs to this turn. + const requiredOrchestrationTools = + this.orchestration.mode === 'swarm' + ? new Set([ + 'agent_list', + 'update_agent_graph', + 'yield_agent_graph', + 'agent_swarm_status', + 'agent_output', + ]) + : this.orchestration.mode === 'graph' + ? new Set([ + 'agent_list', + 'view_agent_graph', + 'update_agent_graph', + 'yield_agent_graph', + 'agent_swarm_status', + 'agent_output', + ]) + : new Set(); + const requestedToolMode: unknown = + input.toolMode === undefined ? DEFAULT_TOOL_MODE : input.toolMode; + if (!isToolMode(requestedToolMode)) { + throw new Error(`Invalid tool mode: ${String(requestedToolMode)}`); + } + const toolMode = requestedToolMode; + if (toolMode === 'code_mode' && this.deps.backend.tools.some((tool) => tool.name === 'exec')) { + throw new Error('Tool name "exec" is reserved for Code Mode.'); + } + const plan = projectToolModePlan( + this.deps.toolAvailabilityRuntime.prepare(this.activeTools, requiredOrchestrationTools), + toolMode, + codeModeExecTool, + ); + const providerTools = plan.providerTools; + let activeToolResultPruneDiagnosticPatch: ActiveToolResultPruneDiagnosticPatch = {}; + let midTurnCompactDiagnosticPatch: Partial | undefined; + // Tool names the repair path matches a mis-cased call against — follows the + // current step's snapshot so a tool activated mid-turn is repairable on the + // step it becomes active, not routed to `invalid`. + const boundaryAwareToolNames = (names: readonly string[]): string[] => { + if (toolRuntime.shouldFinalizeSandboxBoundary()) return []; + return toolRuntime.hasSandboxBoundaryDenial() + ? names.filter((name) => name !== REQUEST_SANDBOX_BOUNDARY_TOOL_NAME) + : [...names]; + }; + const currentRepairToolNames = () => boundaryAwareToolNames(plan.currentRepairToolNames()); + if (plan.gating) { + toolRuntime.setGating(plan.gating); + } + + const modelTools: ModelToolSet = {}; + for (const t of providerTools) { + modelTools[t.name] = t.providerTool + ? { kind: 'provider', providerTool: t.providerTool } + : { + kind: 'function', + description: t.description, + inputSchema: t.parameters, + }; + } + + // Resolve the stable Provider envelope before automatic Compaction freezes + // its source. The same value is reused by the primary request; Memory does + // not resolve or mutate Agent configuration after the checkpoint commits. + let systemPrompt: string | undefined; + try { + systemPrompt = joinPromptFragments([ + await this.resolveSystemPrompt(), + this.orchestration?.mode === 'swarm' ? renderSwarmModePrompt() : undefined, + this.orchestration?.mode === 'graph' ? renderGraphModePrompt() : undefined, + ]); + } catch (err) { + trace.modelStreamFailed(this.deps.modelAdapter.classifyError(err), err); + queue.push(this.makeErrorEvent(turnId, err)); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'error', + } satisfies CompleteEvent); + queue.close(); + yield* this.drain(queue); + return; + } + + // --- Build messages from RuntimeEvent history and its compatibility projection. --- + const priorReplayResult = await this.buildPriorMessages(input); + if (this.aborted) { + queue.push({ + type: 'abort', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + reason: 'user_stop', + } satisfies AbortEvent); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'user_stop', + } satisfies CompleteEvent); + queue.close(); + yield* this.drain(queue); + return; + } + const priorReplay = priorReplayResult; + if (input.continuation && priorReplay.messages.length === 0) { + const replay = priorReplayFailureTrace(priorReplay); + const error = new ContinuationReplayEmptyError(replay.gate, replay.diagnosticCodes); + trace.modelStreamFailed(error.code, error, replay); + queue.push(this.makeErrorEvent(turnId, error)); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'error', + } satisfies CompleteEvent); + queue.close(); + yield* this.drain(queue); + return; + } + if (midTurnState) { + // Roll-forward seed: the latest durable checkpoint (loaded or written at + // turn start) so a mid-turn summary only re-reads the newly folded span. + const checkpoint = priorReplay.latestHistoryCompactCheckpoint; + midTurnState.previousCheckpoint = + checkpoint && + canContinueHistoryCompactCheckpointForModel( + checkpoint, + this.deps.backend.connection, + this.deps.backend.header.llmConnectionId, + this.deps.backend.modelId, + ) + ? checkpoint + : undefined; + } + /** + * The fold THIS request's prompt was built under (#2323). + * + * Called once per physical dispatch rather than once per send, because the + * boundary moves between dispatches of the same send: mid-turn capacity + * compaction advances it before a later step, and overflow recovery + * advances it before it resends the request the provider just rejected. + * Sealed from session state at settlement it would be whichever fold + * arrived last — not the one the sealed prompt was actually made of. + * + * Mid-turn state is the single rolling authority whenever the turn has one: + * it is seeded just above from the pre-turn checkpoint and is what both of + * those folds write to. A turn without that seam can only have been built + * under the pre-turn checkpoint. + */ + const requestHistoryCompactBoundary = (): ContextDiagnosticsCompaction | undefined => { + const checkpoint = midTurnState + ? midTurnState.previousCheckpoint + : priorReplay.latestHistoryCompactCheckpoint; + return checkpoint ? contextDiagnosticsCompactionOf(checkpoint) : undefined; + }; + + // --- Background pump: streamText → stream → normalize → queue --- + const pumpDone: Promise = (async () => { + const watchdogState: { current: StreamWatchdog | null } = { + current: null, + }; + let providerRequestAbortController = new AbortController(); + const watchdogTimeoutState: { + current: { + readonly phase: StreamWatchdogPhase; + readonly error: Error; + } | null; + } = { current: null }; + const currentWatchdogTimeout = () => watchdogTimeoutState.current; + const consumeWatchdogTimeout = () => { + const timeout = watchdogTimeoutState.current; + watchdogTimeoutState.current = null; + return timeout; + }; + let lastCompletedStepHadToolResult = false; + let terminalProviderErrorReason: string | undefined; + try { + const startWatchdog = (): void => { + watchdogState.current?.stop(); + const next = new StreamWatchdog({ + now: this.deps.now, + connectTimeoutMs: this.deps.backend.streamConnectTimeoutMs, + idleTimeoutMs: this.deps.backend.streamIdleTimeoutMs, + ...this.deps.backend.streamWatchdogTimer, + onTimeout: (timeout) => { + const error = new Error(formatStreamWatchdogError(timeout)); + watchdogTimeoutState.current = { phase: timeout.phase, error }; + providerRequestAbortController.abort(error); + }, + }); + watchdogState.current = next; + this.watchdog = next; + next.start(); + }; + const activeTools = plan.activeTools; + const currentUserContent = input.continuation + ? undefined + : await this.deps.messageProjection.buildCurrentUserContent( + this.imageBudget, + input.text, + input.attachments, + input.directoryReferences, + input.quotes, + input.headAnchorRuntimeEvent?.id, + ); + const messages = + currentUserContent === undefined + ? [...priorReplay.messages] + : [ + ...priorReplay.messages, + { + role: 'user' as const, + content: currentUserContent, + } as ModelMessage, + ]; + const loadDurableTurnEvents = async (): Promise => { + const loadTurnRuntimeEvents = this.deps.backend.loadTurnRuntimeEvents; + if (!loadTurnRuntimeEvents) { + throw new Error('durable current-run reader is required for tool continuation'); + } + await queue.waitUntilConsumedThroughCurrent(); + return (await loadTurnRuntimeEvents(turnId)).filter((event) => event.turnId === turnId); + }; + const loadDurableTurnProjection = async (): Promise => { + const turnEvents = await loadDurableTurnEvents(); + const projectionCheckpoint = midTurnState?.projectionCheckpoint; + const rawProjectionEvents = projectionCheckpoint + ? [ + ...midTurnState.priorContentEvents, + ...turnEvents.filter(isHistoryCompactContentEvent), + ] + : turnEvents; + let replayEvents = rawProjectionEvents; + if (projectionCheckpoint) { + const checkpointMatch = matchHistoryCompactCheckpointPrefix( + projectionCheckpoint, + rawProjectionEvents, + ); + if (checkpointMatch.reason) { + throw new Error(`durable checkpoint projection mismatch: ${checkpointMatch.reason}`); + } + replayEvents = projectHistoryCompactCheckpointReplay( + projectionCheckpoint, + checkpointMatch.coveredRuntimeEvents, + checkpointMatch.successorRuntimeEvents, + ); + // The checkpoint was capacity-validated before it was persisted. + // Do not re-run that gate against a later, larger successor tail: + // the active-step shaper must see that growth so it can roll the + // checkpoint forward instead of resurrecting raw history. + } + // The current Turn is model-visible history like any other, so it is + // folded through the same reducer before it becomes messages. Without + // this, a result archived at step N is rebuilt in full at step N+1 and + // the ledger's account of what the model sees stops being true. + const foldedReplayEvents = + await this.deps.compaction.foldEffectiveModelHistory(replayEvents); + const replayPlan = buildRuntimeEventModelReplayPlan(foldedReplayEvents, { + toolActivityTurnIds: collectToolActivityTurnIds([ + ...(input.runtimeContext ?? []), + ...turnEvents, + ]), + }); + if ( + hasBlockingReplayDiagnostics(replayPlan) || + (replayPlan.hasProviderNativeSemantics && + !this.deps.messageProjection.canReplayProviderNative(replayPlan)) + ) { + throw new Error('durable current-run projection is not replayable'); + } + const currentTurnMessages = + await this.deps.messageProjection.materializeRuntimeReplayPlan( + replayPlan, + this.imageBudget, + projectionCheckpoint, + compatibleProviderReasoningReplayEventIds( + replayEvents, + input.runtimeContextInvocations, + this.deps.backend.providerStateIdentity, + this.deps.backend.modelId, + this.runId, + ), + ); + return projectionCheckpoint + ? currentTurnMessages + : [...priorReplay.messages, ...currentTurnMessages]; + }; + // Tool Availability describes the provider-visible (active) subset. A + // group loaded this turn expands that subset on later requests, so the + // terminal trace is refined against the final active set below. + contextBudgetForTelemetry = priorReplay.contextBudget; + const computeToolAvailability = (active: readonly string[]) => { + const toolSchemaChars = toolSchemaCharsForDiagnostics(providerTools, active); + return plan.diagnostics(active, toolSchemaChars); + }; + toolAvailabilityForTelemetry = computeToolAvailability(activeTools); + trace.modelStreamStarted(activeTools, { + ...(toolAvailabilityForTelemetry !== undefined + ? { toolAvailability: toolAvailabilityForTelemetry } + : {}), + ...(priorReplay.contextBudget ? { contextBudget: priorReplay.contextBudget } : {}), + }); + + const onMidTurnDiagnosticPatch = (patch: Partial): void => { + midTurnCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( + midTurnCompactDiagnosticPatch, + patch, + ); + }; + const midTurnCapacityHook = this.deps.compaction.buildMidTurnCapacityCompactProjection( + turnId, + midTurnState, + queue, + providerTools, + onMidTurnDiagnosticPatch, + this, + this.automaticMemoryCompactionSupported() + ? () => this.automaticMemoryCompactionDecision() + : undefined, + this.automaticMemoryCompactionSupported() + ? (dispatch) => this.dispatchAutomaticMemoryCompaction(dispatch) + : undefined, + turnAbortController.signal, + ); + // When mid-turn capacity compaction is active, the prune must also cover + // the newest completed step; see collectPrunableCompletedStepToolCallIds. + const activeToolResultPruneIncludesNewestStep = midTurnState !== undefined; + const activeToolResultPruneHook = this.deps.compaction.buildActiveToolResultPruneProjection( + turnId, + activeToolResultPruneIncludesNewestStep, + (patch) => { + pruneAppliedAtStep = runtimeSteps; + activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( + activeToolResultPruneDiagnosticPatch, + patch, + ); + }, + ); + const shapedProjection = composeRequestProjection( + plan.projectActiveTools, + midTurnCapacityHook, + activeToolResultPruneHook, + ); + // Hooks shape; nothing measures the final payload. Whether it fits is + // the provider's answer (#4559). + const requestProjection = shapedProjection; + + const completedProviderSteps: RequestProjectionContext['completedSteps'][number][] = []; + let requestMessages: ModelMessage[] = messages; + // The compaction module runs at most once per send. This tracks the + // reactive entry; the proactive one sets the same flag on the mid-turn + // state, and each consults the other, so a send that already folded + // reports the oversized message instead of folding again (#4559). + let overflowRetryUsed = false; + let result: ModelStreamResult; + let providerOutcome: ModelStepOutcome; + let finishReason: ModelFinishReason = 'stop'; + let terminalProviderError: unknown; + agentLoop: for (;;) { + await this.drainSteeringInto(input, queue); + if (this.deps.backend.loadTurnRuntimeEvents) { + requestMessages = await loadDurableTurnProjection(); + } else { + const missingSteering = steeringMessagesMissingFromBase( + this.injectedSteeringMessages, + requestMessages, + ); + if (missingSteering.length > 0) + requestMessages = [...requestMessages, ...missingSteering]; + } + // 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.deps.backend.header.collaborationMode === 'agent' && + maxSteps !== undefined && + maxSteps > 1 && + runtimeSteps === maxSteps - 1 && + completedProviderSteps.length > 0; + const sandboxBoundaryFinalizationStep = + toolRuntime.shouldFinalizeSandboxBoundary() || + (toolRuntime.hasSandboxBoundaryDenial() && + maxSteps !== undefined && + runtimeSteps === maxSteps - 1); + if (sandboxBoundaryFinalizationStep) { + toolRuntime.forceSandboxBoundaryFinalization(); + } + 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; + let idleWatchdogRetryCount = 0; + let incompleteStreamRetryCount = 0; + let sealedThinkingRetryCount = 0; + const returnedToolCalls: ToolCallPart[] = []; + let providerToolActivityCount = 0; + const providerToolInputs = new Map(); + let providerStepUsage: NormalizedUsage | undefined; + for (;;) { + providerRequestAbortController = new AbortController(); + watchdogTimeoutState.current = null; + startWatchdog(); + // Monotonic facts for this physical request. The step accumulators + // are cleared after flushStep(), so they cannot decide whether a + // later stream failure is safe to retry. + let attemptSawText = false; + let attemptSawThinking = false; + let attemptSawToolActivity = false; + let attemptSawContinuationMetadata = false; + let attemptReachedStepBoundary = false; + const attemptHasNoObservableOutput = () => + !attemptSawText && + !attemptSawThinking && + !attemptSawToolActivity && + !attemptSawContinuationMetadata && + !attemptReachedStepBoundary; + // Thinking is the only output that can be sealed into its own + // message before a retry: flushStep() closes the fragment under + // the current message id and the retry streams into a fresh one, + // so the user never sees spliced or duplicated content. Text, + // tool activity, continuation metadata, and step boundaries stay + // non-recoverable for the reasons each of them is tracked. + const attemptCanRecoverWithSealedThinking = () => + !attemptSawText && + !attemptSawToolActivity && + !attemptSawContinuationMetadata && + !attemptReachedStepBoundary; + this.memorySourceMessages = [...attemptMessages]; + this.memorySourceEventMessagePositions = + this.deps.messageProjection.memoryEventMessagePositions(attemptMessages); + this.memorySourceSystemPrompt = requestSystemPrompt; + this.memorySourceTools = modelTools; + this.memorySourceActiveTools = [...activeToolsForRequest]; + this.finalAssistantText = undefined; + // Keep a denied boundary request as a Code Mode trap: the provider + // no longer sees it as a direct tool, but a nested retry must still + // reach ToolRuntime's denial latch instead of becoming an endlessly + // variable unknown-tool error inside `exec`. + const codeModeActiveTools = + toolRuntime.hasSandboxBoundaryDenial() && activeToolsForRequest.includes('exec') + ? [...activeToolsForRequest, REQUEST_SANDBOX_BOUNDARY_TOOL_NAME] + : activeToolsForRequest; + this.codeModeTools = + toolMode === 'code_mode' + ? nestableToolSnapshot(providerTools, codeModeActiveTools) + : undefined; + const requestWatchdog = watchdogState.current; + // Read here, beside the messages it describes: `attemptMessages` is + // rebuilt in place by overflow recovery, and the boundary it folded + // under must travel with that rebuild, not with the step. + const historyCompactBoundary = requestHistoryCompactBoundary(); + result = await this.deps.modelAdapter.startStream({ + model, + messages: attemptMessages, + tools: modelTools, + activeTools: activeToolsForRequest, + onStreamActivity: () => requestWatchdog?.markActivity(), + repairToolCall: async ({ + toolCall, + error, + }: { + toolCall: RepairableAiSdkToolCall; + error: unknown; + }) => { + return repairMakaToolCall({ + toolCall, + availableToolNames: currentRepairToolNames(), + toolParameters: (name) => + providerTools.find((candidate) => candidate.name === name)?.parameters, + toolCategoryHint: (name) => + providerTools.find((candidate) => candidate.name === name)?.categoryHint, + error, + }); + }, + system: requestSystemPrompt, + abortSignal: AbortSignal.any([ + turnAbortController.signal, + providerRequestAbortController.signal, + ]), + ...(providerRequestTracker ? { providerRequestTracker } : {}), + ...(historyCompactBoundary ? { historyCompactBoundary } : {}), + continuationKey: this.turnId, + }); + + for await (const event of result.events) { + if (this.aborted) break; + if (event.kind === 'error') { + // Settlement owns the failure; stop before any synthesized + // trailer and consume the one authoritative outcome below. + break; + } + const incompleteFinish = + (event.kind === 'finish' || event.kind === 'step-finish') && + isIncompleteProviderFinishReason(event.finishReason); + if ((event.kind === 'finish' || event.kind === 'step-finish') && !incompleteFinish) { + attemptReachedStepBoundary = true; + } + if (event.kind === 'step-finish') { + // AI SDK can synthesize `finish-step(other)` when the provider + // stream reaches EOF without a terminal frame. That is not a + // completed model step and must not consume the step budget or + // checkpoint imaginary usage before the safe retry below. + if (!incompleteFinish) { + // Step boundary: AI SDK 7 delimits steps with `finish-step` + // (and `step-finish` for legacy replay fixtures); the adapter + // reduces both to this event. A duplicate boundary is harmless: + // the second flush no-ops (accumulators already cleared) and one + // extra id rotation just discards an unused id. + runtimeSteps += 1; + const stepUsage = event.usage; + providerStepUsage = stepUsage; + if (!stepUsage) sawUnusableStepUsage = true; + // Silent eviction / rewrite check (#4559): this step only + // appended (no fold, no prune, no image omission) yet the + // provider counted no more input tokens than for the previous + // request. Not-greater, not strictly-fewer: a provider that + // truncates to a fixed window (Ollama's `num_ctx`) reports the + // same total on every later request while Maka keeps + // appending, so a plateau is the signal, and an equal count + // after an append is already impossible without provider-side + // eviction or rewriting. Input against input: the previous + // reply's reasoning may not be resent, so input + output is + // not the floor of the next input on every wire. + const completedRequestIndex = runtimeSteps - 1; + // A finalization step resolves an empty tool set, so its + // request legitimately drops several thousand schema tokens + // with no fold, prune or image omission. Maka shaped that + // request; the provider did not drop anything. + const toolSchemaShrank = + lastStepActiveToolCount !== undefined && + activeToolsForRequest.length < lastStepActiveToolCount; + // Across the send boundary the comparison is the same one, + // against the last request a provider accepted before this + // send. A provider that truncates to a fixed window reports + // the same input on every later request while the user keeps + // adding turns, and a send of one or two steps never sees + // that from the inside: the live evidence plateaus at 3,716 + // input tokens across eight turns with nothing reported + // (#4623). The first request of a send therefore compares + // against the persisted anchor, which is route-validated + // where it is read; a fold before that request would explain + // a smaller input by itself, so it disables the comparison. + const acrossSends = completedRequestIndex === 0; + const priorInput = acrossSends + ? midTurnState?.compactionAppliedThisSend === true + ? undefined + : midTurnState?.priorAcceptedInputTokens + : lastStepInputTokens; + if ( + !this.deps.session.contextProviderDroppingReported && + !toolSchemaShrank && + midTurnState && + priorInput !== undefined && + midTurnState.replacedStepNumber !== completedRequestIndex && + pruneAppliedAtStep !== completedRequestIndex && + midTurnState.omittedImageToolResults.size === 0 && + stepUsage !== undefined && + Number.isFinite(stepUsage.inputTokens) && + stepUsage.inputTokens > 0 && + // Across sends the test is equality, not "did not grow". + // Inside a send Maka knows it only appended, so any + // shortfall is the provider's. Across the boundary it does + // not: a manual compaction leaves the pre-compaction anchor + // behind, a turn can carry a smaller tool set, and a user + // can edit or branch history. All three shrink the input + // legitimately, and none of them lands on exactly the same + // count. A provider truncating to a fixed window does, on + // every later request. + (acrossSends + ? stepUsage.inputTokens === priorInput + : stepUsage.inputTokens <= priorInput) + ) { + this.deps.session.contextProviderDroppingReported = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_provider_dropping', + data: { inputTokens: stepUsage.inputTokens, priorInputTokens: priorInput }, + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + // Fail closed: reset on every step boundary so a missing final + // step's usage does not leave a stale value from an earlier step. + // The reply needed more room than the declared window had + // left after this request's own input. Both halves are the + // provider's numbers, read after the fact: the reserve that + // should have kept them apart was measured from a smaller + // previous reply. Say so once per send; the next request + // folds anyway because the baseline now exceeds the window. + if ( + !contextWindowOverrunNoteWritten && + midTurnState?.capacity !== undefined && + stepUsage !== undefined && + Number.isFinite(stepUsage.inputTokens) && + stepUsage.inputTokens > 0 && + Number.isFinite(stepUsage.outputTokens) && + stepUsage.outputTokens > 0 && + stepUsage.inputTokens + stepUsage.outputTokens > midTurnState.capacity + ) { + contextWindowOverrunNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_window_overrun', + data: { + usedTokens: stepUsage.inputTokens + stepUsage.outputTokens, + declaredContextWindow: midTurnState.capacity, + }, + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + // Nothing declared, and the provider accepted a request past + // the window this model reports. Every other signal in this + // design stays dark there: no rejection to recover from, no + // plateau to read, and no declaration to arm the proactive + // threshold, so the session degrades quietly and + // indefinitely (#4634). Report the two real numbers and + // leave the decision with the user: a reported window is a + // hint, and Maka still declares nothing on their behalf. + // + // Once per crossing, not once per send. On these providers + // usage keeps growing past the line (305K → 322K observed), + // so the note fires on the transition: the previous accepted + // total was still inside the reported window and this one is + // not. The baseline carries that previous total across + // sessions through the persisted anchor, so a resumed + // session does not repeat a crossing it already reported. + if ( + !contextReportedWindowNoteWritten && + midTurnState !== undefined && + midTurnState.capacity === undefined && + stepUsage !== undefined && + Number.isFinite(stepUsage.inputTokens) && + stepUsage.inputTokens > 0 && + Number.isFinite(stepUsage.outputTokens) + ) { + const reported = resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ); + const used = stepUsage.inputTokens + Math.max(0, stepUsage.outputTokens); + // `baselineTokens` still describes the request before this + // one: the capacity hook sets it from the previous step, or + // from the persisted anchor on a send's first request. + const previousTotal = midTurnState.baselineTokens; + const crossedNow = + reported !== undefined && + used > reported && + (previousTotal === undefined || previousTotal <= reported); + if (reported !== undefined && crossedNow) { + contextReportedWindowNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_reported_window_exceeded', + data: { usedTokens: used, reportedContextWindow: reported }, + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + } + lastStepInputTokens = stepUsage?.inputTokens; + lastStepOutputTokens = stepUsage?.outputTokens; + lastStepActiveToolCount = activeToolsForRequest.length; + // A `finishReason: length` is deliberately not a trigger. The + // reply may have been cut because the provider ran out of + // window room, or because the provider's own output cap is + // lower than the one Maka sends. Those are indistinguishable + // from outside, and an indistinguishable signal must not + // drive an action; the cut reply is visible to the user + // either way (#4559). + if (stepUsage) { + completedStepUsage = mergeNormalizedUsage(completedStepUsage, stepUsage); + this.deps.session.cumulativeUsageCheckpoint = mergeNormalizedUsage( + this.deps.session.cumulativeUsageCheckpoint, + stepUsage, + ); + await this.deps.backend.recordUsageCheckpoint?.({ + ...this.deps.session.cumulativeUsageCheckpoint, + costUsd: this.deps.providerTelemetry.normalizedUsageCostUsd( + this.deps.session.cumulativeUsageCheckpoint, + ), + }); + } + } + } + if (event.kind === 'text-start') { + if (stepText.length > 0 && event.providerItemBoundary === true) { + await flushStep(); + currentStepMessageId = this.deps.newId(); + } + stepTextPartStartOffset = stepText.length; + } else if (event.kind === 'text') { + if (event.text.length > 0) recordStepContent('text'); + stepText += event.text; + if (event.text.length > 0) attemptSawText = true; + queue.push({ + type: 'text_delta', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + messageId: currentStepMessageId, + text: event.text, + } satisfies TextDeltaEvent); + } else if (event.kind === 'text-end') { + if (event.providerOptions !== undefined) { + attemptSawContinuationMetadata = true; + stepTextProviderOptions = mergeTextProviderOptions( + stepTextProviderOptions, + stripUndefinedDeep(event.providerOptions) as NonNullable< + ModelMessage['providerOptions'] + >, + stepTextPartStartOffset, + ); + } + if (event.providerItemBoundary === true) { + await flushStep(); + currentStepMessageId = this.deps.newId(); + } + } else if (event.kind === 'thinking-start') { + if (event.providerOptions !== undefined) { + attemptSawContinuationMetadata = true; + } + const part: AssistantThinkingPart = { + text: '', + ...(event.providerOptions !== undefined + ? { providerOptions: event.providerOptions } + : {}), + }; + stepThinkingParts.push(part); + if (event.reasoningPartId) { + stepThinkingPartsById.set(event.reasoningPartId, part); + } + } else if (event.kind === 'thinking') { + if (event.text.length > 0) recordStepContent('thinking'); + if (event.text.length > 0) attemptSawThinking = true; + if (event.providerOptions !== undefined) { + if (event.providerOptionsOrigin !== 'maka_transport') { + attemptSawContinuationMetadata = true; + } + } + const partId = + event.reasoningPartId ?? responsesReasoningItemId(event.providerOptions); + let part: AssistantThinkingPart | undefined; + if (typeof partId === 'string' && partId.length > 0) { + part = stepThinkingPartsById.get(partId); + if ( + part && + event.providerOptions === undefined && + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' + ) { + // The SDK does not suppress a stray delta after + // output_item.done. Keep it out of the finalized item or + // its durable summary boundaries will no longer match. + part = { text: '' }; + stepThinkingParts.push(part); + stepThinkingPartsById.set(partId, part); + } + if (!part) { + part = { text: '' }; + stepThinkingParts.push(part); + stepThinkingPartsById.set(partId, part); + } + } else { + part = stepThinkingParts.at(-1); + if ( + part && + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' + ) { + // An invalid next item has no usable stream id. Do not + // append its deltas to the finalized item: partial-error + // flush must keep that item's durable boundaries valid. + part = undefined; + } + } + if (!part) { + part = { text: '' }; + stepThinkingParts.push(part); + } + const nextPartText = part.text + event.text; + if ( + event.reasoningSummaryText !== undefined && + event.reasoningSummaryText !== nextPartText + ) { + throw new Error( + 'Streamed plaintext Responses reasoning does not match final provider summary', + ); + } + part.text = nextPartText; + if (event.providerOptions !== undefined) { + part.providerOptions = event.providerOptions; + } + queue.push({ + type: 'thinking_delta', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + messageId: currentStepMessageId, + text: event.text, + } satisfies ThinkingDeltaEvent); + } else if (event.kind === 'thinking-signature') { + attemptSawContinuationMetadata = true; + let part = event.reasoningPartId + ? stepThinkingPartsById.get(event.reasoningPartId) + : stepThinkingParts.at(-1); + if (!part) { + part = { text: '' }; + stepThinkingParts.push(part); + if (event.reasoningPartId) { + stepThinkingPartsById.set(event.reasoningPartId, part); + } + } + part.signature = event.signature; + } else if (event.kind === 'provider-tool-input') { + // The provider has started its own tool. Even without a + // final tool-call/result event, retrying can repeat external + // work that the Runtime cannot observe or reconcile. + attemptSawToolActivity = true; + } else if (event.kind === 'tool-call') { + attemptSawToolActivity = true; + recordStepContent('tools'); + if (event.toolCall.providerExecuted) { + providerToolActivityCount += 1; + providerToolInputs.set(event.toolCall.toolCallId, event.toolCall.input); + queue.push({ + type: 'tool_start', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + toolUseId: event.toolCall.toolCallId, + toolName: event.toolCall.toolName, + args: event.toolCall.input, + providerExecuted: true, + activityKind: 'websearch', + displayName: 'Web search', + stepId: currentStepMessageId, + ...(event.toolCall.providerOptions !== undefined + ? { + providerOptions: stripUndefinedDeep(event.toolCall.providerOptions), + } + : {}), + } satisfies ToolStartEvent); + } else { + returnedToolCalls.push(event.toolCall); + } + } else if (event.kind === 'provider-tool-result') { + attemptSawToolActivity = true; + providerToolActivityCount += 1; + const providerOutput = stripUndefinedDeep(event.output); + queue.push({ + type: 'tool_result', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + toolUseId: event.toolCallId, + providerExecuted: true, + ...(providerOutput !== undefined ? { providerOutput } : {}), + isError: event.isError === true, + content: providerToolResultContent( + event.toolName, + providerOutput, + providerToolInputs.get(event.toolCallId), + ), + } satisfies ToolResultEvent); + providerToolInputs.delete(event.toolCallId); + } else if (event.kind === 'step-finish' && !incompleteFinish) { + // The step's text/thinking deltas are all in (the stream is + // drained in order), so flush this step's AssistantMessage and + // rotate to a fresh id for the next step. Tool settlement + // below receives this step's pre-rotation id, so durable replay + // can regroup calls with this reasoning/text. + await flushStep(); + if (midTurnState) { + // Durability clock: step N's thinking/text completion events + // are enqueued by flushStep just above, so only after this + // boundary can a seq-ack wait for step N mean anything. Wake + // waiters AFTER the increment or they would re-check a stale + // count and sleep. + midTurnState.flushedSteps += 1; + queue.wake(); + } + } + } + watchdogState.current?.stop(); + // This timeout belongs to the physical request that just settled. + // Consume it before recovery/flush work: a later persistence error + // must not be reported as the already-handled watchdog timeout. + const settledWatchdogTimeout = consumeWatchdogTimeout(); + providerOutcome = await result.outcome; + const incompleteStreamTerminal = providerOutcome.kind === 'truncated'; + const incompleteStreamHasNoObservableOutput = + incompleteStreamTerminal && + !attemptSawText && + !attemptSawThinking && + !attemptSawToolActivity && + !attemptSawContinuationMetadata; + const attemptFailure = + settledWatchdogTimeout?.error ?? + (providerOutcome.kind === 'completed' ? undefined : providerOutcome.failure); + + if (attemptFailure && !this.aborted) { + const failure = + settledWatchdogTimeout || providerOutcome.kind === 'completed' + ? this.deps.modelAdapter.normalizeFailure(attemptFailure) + : providerOutcome.failure; + if (this.loopStopRequested) { + terminalProviderError = settledWatchdogTimeout?.error ?? failure; + terminalProviderErrorReason = + lastCompletedStepHadToolResult && failure.kind === 'timeout' + ? 'model_after_tool_timeout' + : undefined; + break agentLoop; + } + // A retry is a fresh provider request that would run at least one + // more step; with the send-level budget already spent there is + // nothing left to grant it, so the error is terminal. + const stepBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps; + const recovered = + stepBudgetRemains && attemptHasNoObservableOutput() + ? await this.deps.compaction.recoverFromOverflowError({ + error: attemptFailure, + retryAlreadyUsed: + overflowRetryUsed || (midTurnState?.compactionAttemptedThisSend ?? false), + midTurnState, + turnId, + stepNumber: runtimeSteps, + currentMessages: attemptMessages, + activeTools: activeToolsForRequest, + queue, + onDiagnosticPatch: onMidTurnDiagnosticPatch, + origin: this, + ...(this.automaticMemoryCompactionSupported() + ? { + memoryCompactionDecision: () => + this.automaticMemoryCompactionDecision(), + onMemoryCompaction: (dispatch: AutomaticMemoryCompactionDispatch) => + this.dispatchAutomaticMemoryCompaction(dispatch), + } + : {}), + abortSignal: turnAbortController.signal, + }) + : undefined; + if (recovered) { + overflowRetryUsed = true; + // Recovery rebuilds the request from the durable ledger, whose + // tool results intentionally retain their full bodies. Re-enter + // the active-result projection before dispatch so an archived + // result cannot reappear in provider context on the retry. + const recoveredProjection = activeToolResultPruneHook + ? await activeToolResultPruneHook({ + completedSteps: completedProviderSteps, + stepNumber: runtimeSteps, + model, + messages: recovered.messages, + activeTools: activeToolsForRequest, + resolveDispatch, + }) + : undefined; + attemptMessages = recoveredProjection?.messages ?? recovered.messages; + continue; + } + // Window suggestion (#4559): the provider rejected a request and + // no recovery is left — the one fold is spent, or there was no + // seam. The baseline is a proven-fit total (input + output of an + // accepted request), so it is a number the user can declare; the + // trigger is `>=`, so declaring exactly it folds before this + // point next time. Once per send, and only when the turn is + // about to surface the error rather than continue. + const acceptedTotal = midTurnState?.lastAcceptedTotalTokens; + if ( + !contextWindowSuggestionNoteWritten && + failure.kind === 'context_overflow' && + midTurnState && + acceptedTotal !== undefined && + (midTurnState.capacity === undefined || acceptedTotal < midTurnState.capacity) + ) { + contextWindowSuggestionNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_window_suggestion', + data: { + suggestedContextWindow: acceptedTotal, + ...(midTurnState.capacity !== undefined + ? { declaredContextWindow: midTurnState.capacity } + : {}), + }, + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + // A folded projection was selected in this send and the provider + // still rejects the request. That is worth saying, because the + // usual remedy has already been applied; it is NOT proof that the + // new message alone is the cause, since what remains also carries + // the system prompt, the tool schemas, the summary and the recent + // tail. A fold that failed open is deliberately excluded: that + // request went out with its full raw history, so nothing about + // its size can be concluded (#4559). + if ( + !contextOverflowAfterCompactionNoteWritten && + failure.kind === 'context_overflow' && + midTurnState?.compactionAppliedThisSend === true + ) { + contextOverflowAfterCompactionNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_overflow_after_compaction', + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + const idleWatchdogRecovery = + settledWatchdogTimeout?.phase === 'idle' && + idleWatchdogRetryCount < MAX_IDLE_WATCHDOG_RETRIES_PER_STEP && + attemptCanRecoverWithSealedThinking(); + const incompleteStreamRecovery = + incompleteStreamTerminal && + incompleteStreamRetryCount < MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP && + incompleteStreamHasNoObservableOutput; + // Same seal-and-retry contract as the watchdog path, entered when + // the failure arrives as a retryable provider/network error + // instead of a local idle timeout. `!idleWatchdogRecovery` keeps + // every watchdog-shaped outcome on its existing path, and + // `!attemptHasNoObservableOutput()` keeps no-output retries on + // the plain budget so this one is spent only on sealed fragments. + const sealedThinkingRecovery = + !idleWatchdogRecovery && + failure.retryable && + sealedThinkingRetryCount < MAX_SEALED_THINKING_RETRIES_PER_STEP && + attemptCanRecoverWithSealedThinking() && + !attemptHasNoObservableOutput(); + if ( + (failure.retryable || idleWatchdogRecovery || incompleteStreamRecovery) && + failure.kind !== 'context_overflow' && + providerAttempt < MAX_PROVIDER_ATTEMPTS_PER_STEP && + stepBudgetRemains && + (attemptHasNoObservableOutput() || + idleWatchdogRecovery || + incompleteStreamRecovery || + sealedThinkingRecovery) + ) { + if (idleWatchdogRecovery) idleWatchdogRetryCount += 1; + if (sealedThinkingRecovery) sealedThinkingRetryCount += 1; + if (incompleteStreamRecovery) incompleteStreamRetryCount += 1; + if ( + (idleWatchdogRecovery || sealedThinkingRecovery) && + stepThinkingParts.length > 0 + ) { + await flushStep(); + currentStepMessageId = this.deps.newId(); + } + // The failed request did not return authoritative usage. Keep + // effectiveness recoverable, but fail final metering closed. + sawUnusableStepUsage = true; + const delayMs = providerRetryDelayMs(providerAttempt, failure.retryAfterMs); + const nextAttempt = providerAttempt + 1; + const maxAttempts = + idleWatchdogRecovery || incompleteStreamRecovery || sealedThinkingRecovery + ? nextAttempt + : MAX_PROVIDER_ATTEMPTS_PER_STEP; + const reason = providerRetryReason(failure.kind); + queue.push({ + type: 'provider_retry', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + phase: 'scheduled', + attempt: nextAttempt, + maxAttempts, + delayMs, + remainingMs: delayMs, + reason, + } satisfies ProviderRetryEvent); + await this.deps.providerRetrySleep(delayMs, turnAbortController.signal); + providerAttempt = nextAttempt; + queue.push({ + type: 'provider_retry', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + phase: 'started', + attempt: providerAttempt, + maxAttempts, + reason, + } satisfies ProviderRetryEvent); + continue; + } + // Unrecoverable (not context-length, latch spent, no seam, or no + // safe fold): surface the real provider error via the terminal + // handler after settling any authoritative usage — never a + // fabricated success. + terminalProviderError = settledWatchdogTimeout?.error ?? failure; + terminalProviderErrorReason = + lastCompletedStepHadToolResult && failure.kind === 'timeout' + ? 'model_after_tool_timeout' + : undefined; + break agentLoop; + } + break; + } + + // If the stream loop exited because stop() flipped this.aborted while a + // provider kept yielding after abort instead of throwing, route to the + // abort handling below. Without this, the post-stream success path would + // persist a partial assistant turn and emit a false end_turn completion. + if (this.aborted) { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + } + + // 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; + await flushStep(); + + if (providerOutcome.kind !== 'completed') throw providerOutcome.failure; + finishReason = providerOutcome.finishReason; + await queue.waitUntilConsumedThroughCurrent(); + + if (returnedToolCalls.length > 0) { + const continuationBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps; + if (continuationBudgetRemains && !this.deps.backend.loadTurnRuntimeEvents) { + throw new Error('durable current-run reader is required for tool continuation'); + } + if (this.deps.backend.loadTurnRuntimeEvents) { + // Queue consumption alone does not prove that the latest assistant + // facts remain readable. Fail before any external tool side effect + // when the authoritative ledger became unavailable after the step. + await loadDurableTurnEvents(); + } + const toolsByName = new Map(providerTools.map((tool) => [tool.name, tool])); + const settlementOutcomes = await Promise.allSettled( + returnedToolCalls.map(async (toolCall) => { + if (toolCall.providerExecuted) { + throw new Error( + `Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`, + ); + } + const sandboxBoundaryAttempt = isProviderSandboxBoundaryAttempt(toolCall); + const deniedBoundaryRequest = + toolRuntime.hasSandboxBoundaryDenial() && + toolCall.toolName.toLowerCase() === REQUEST_SANDBOX_BOUNDARY_TOOL_NAME; + if (deniedBoundaryRequest) { + toolRuntime.forceSandboxBoundaryFinalization(); + } + const blockedToolCall = sandboxBoundaryFinalizationStep || deniedBoundaryRequest; + const requestedTool = blockedToolCall + ? undefined + : toolsByName.get(toolCall.toolName); + const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME); + if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable'); + const unavailableError = sandboxBoundaryFinalizationStep + ? 'Sandbox boundary finalization does not permit tool execution.' + : deniedBoundaryRequest + ? SANDBOX_BOUNDARY_DENIED_FOR_TURN + : 'returned tool is unavailable'; + return await toolRuntime.settleToolCall({ + tool, + turnId, + stepId: providerStepId, + toolCallId: toolCall.toolCallId, + // Provider metadata is persisted verbatim into an immutable + // RuntimeEvent, and a field the response did not carry + // arrives as an explicit `undefined` — which JSON drops, so + // the event no longer reads back as it was written and the + // store refuses it. One refusal took every tool-calling turn + // with it. + ...(toolCall.providerOptions !== undefined + ? { + providerOptions: stripUndefinedDeep(toolCall.providerOptions), + } + : {}), + input: + requestedTool !== undefined + ? toolCall.input + : { + tool: toolCall.toolName, + error: unavailableError, + ...(sandboxBoundaryAttempt ? { sandboxBoundaryAttempt: true } : {}), + }, + abortSignal: turnAbortController.signal, + eventSink: queue, + }); + }), + ); + const rejectedSettlement = settlementOutcomes.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', + ); + if (rejectedSettlement) throw rejectedSettlement.reason; + const settlements = settlementOutcomes.map((outcome) => { + // A rejected settlement was handled above, so preserving the + // original array shape also preserves tool-call identity by index. + if (outcome.status === 'rejected') throw outcome.reason; + return outcome.value; + }); + for (let index = 0; index < settlements.length; index += 1) { + const settlement = settlements[index]!; + const toolCall = returnedToolCalls[index]; + if (isPlanToolResult(settlement.result)) { + this.handlePlanToolResult(settlement.result, queue); + } + if ( + returnedToolCalls.length === 1 && + toolCall?.toolName === YIELD_AGENT_GRAPH_TOOL_NAME && + isAgentGraphYieldToolResult(settlement.result) + ) { + this.handleAgentGraphYieldToolResult(settlement.result); + } + } + await queue.waitUntilConsumedThroughCurrent(); + + const continuationWillRun = + (maxSteps === undefined || runtimeSteps < maxSteps) && + !this.loopStopRequested && + !this.aborted; + if (continuationWillRun && providerOutcome.continuation === 'pending') { + const persistedProjection = await loadDurableTurnProjection(); + const responseMessages = persistedOpenAiResponsesStepMessages( + attemptMessages, + persistedProjection, + returnedToolCalls.map((toolCall) => toolCall.toolCallId), + ); + if (responseMessages) { + this.deps.modelAdapter.recordContinuationResponse(this.turnId, responseMessages); + } else { + this.deps.modelAdapter.clearContinuation(this.turnId); + } + } + } + + completedProviderSteps.push({ + toolCalls: returnedToolCalls, + ...(providerStepUsage ? { usage: providerStepUsage } : {}), + }); + lastCompletedStepHadToolResult = returnedToolCalls.length > 0; + const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; + if ( + sandboxBoundaryFinalizationStep || + (stepLimitReached && + (toolRuntime.shouldFinalizeSandboxBoundary() || + toolRuntime.hasSandboxBoundaryDenial())) + ) { + this.loopStopReason = 'permission_handoff'; + this.loopStopRequested = true; + } + const mayTakeAnotherStep = !stepLimitReached && !this.loopStopRequested && !this.aborted; + if (returnedToolCalls.length > 0 && mayTakeAnotherStep) { + currentStepMessageId = this.deps.newId(); + continue agentLoop; + } + // Continuing the turn needs the durable current-run reader, for the + // same reason the tool-call edge above demands it: the next request + // has to carry the assistant output this step just produced, and only + // the ledger projection has it. The no-reader fallback at the top of + // the loop appends steering alone, which would ask the model to + // redirect work it cannot see. Without a reader this edge is skipped + // rather than throwing — the turn still completes and the Host folds + // the message into the next Turn, which is today's behaviour. + if (mayTakeAnotherStep && this.deps.backend.loadTurnRuntimeEvents) { + // Last chance for a steer that landed after this turn's final + // tool-call boundary — including the only boundary a tool-free + // turn has, which precedes the model's first token. Without it the + // message is never pulled at all, and whether Steer works would + // depend on the model happening to call a tool afterwards (#3529). + // A step-limited turn deliberately skips this: its budget is spent, + // and the Host folds the message into the next Turn instead. + const injectedBefore = this.injectedSteeringMessages.length; + await this.drainSteeringInto(input, queue); + // Re-read the stop flags: the drain awaits a durable push, so an + // `after_step` stop or an abort can land while it is in flight, and + // `mayTakeAnotherStep` is stale by now. Stop wins — the message is + // already durable, so the Host folds it into the next Turn. + if ( + this.injectedSteeringMessages.length > injectedBefore && + !this.loopStopRequested && + !this.aborted + ) { + currentStepMessageId = this.deps.newId(); + continue agentLoop; + } + } + break agentLoop; + } + + // Refine Tool Availability against the final active set. Deferred + // loading may add tools, while boundary convergence may remove them; + // comparing membership avoids missing a same-size swap. + const finalActiveTools = currentRepairToolNames(); + if ( + finalActiveTools.length !== activeTools.length || + finalActiveTools.some((name, index) => name !== activeTools[index]) + ) { + toolAvailabilityForTelemetry = computeToolAvailability(finalActiveTools); + } + + // Final usage event. Each adapter result covers one provider request. + // The send-level owner is `completedStepUsage`, which spans every + // Runtime loop step and retry. Recording only the final result would + // silently drop prior requests. An unusable sample in ANY request fails + // the whole record closed (#972). + try { + const attemptTotalUsage = providerOutcome.usage; + tokenUsage = sawUnusableStepUsage ? undefined : (completedStepUsage ?? attemptTotalUsage); + if (tokenUsage) { + tokenUsageCostUsd = this.deps.providerTelemetry.normalizedUsageCostUsd(tokenUsage); + const contextBudgetForUsage = contextBudgetWithRequestProjectionDiagnostics( + contextBudgetForTelemetry, + activeToolResultPruneDiagnosticPatch, + midTurnCompactDiagnosticPatch, + ); + // Persisted alongside the live event so transcript rebuilds from + // stored messages keep the TUI ctx segment instead of degrading to + // `?/` (#4019). Computed once; both writers share it. + const contextRemainingForUsage = (() => { + const contextWindow = resolveSelectedModelContextWindow( + this.deps.backend.connection, + this.deps.backend.modelId, + ); + if (lastStepInputTokens !== undefined && contextWindow !== undefined) { + return Math.max(0, contextWindow - lastStepInputTokens); + } + return undefined; + })(); + // The anchor the NEXT turn judges its first request from — see + // `LastRequestAnchor`. `input` below is the sum across this send's + // steps and anchors nothing; the LAST step's real input and output + // are what the next request re-sends. No usable input count, no + // anchor: the next turn then has no proactive fold until its first + // accepted request. + const anchorInputTokens = finitePositive(lastStepInputTokens); + const anchorOutputTokens = + lastStepOutputTokens !== undefined && Number.isFinite(lastStepOutputTokens) + ? Math.max(0, lastStepOutputTokens) + : undefined; + // 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. + const usageFields = { + input: tokenUsage.inputTokens, + output: tokenUsage.outputTokens, + cacheHitInput: tokenUsage.cacheHitInputTokens, + cacheMissInput: tokenUsage.cacheMissInputTokens, + cacheMissInputSource: tokenUsage.cacheMissInputSource, + cacheWriteInput: tokenUsage.cacheWriteInputTokens, + reasoning: tokenUsage.reasoningTokens, + total: tokenUsage.totalTokens, + ...(tokenUsage.rawFinishReason !== undefined + ? { rawFinishReason: tokenUsage.rawFinishReason } + : {}), + ...(runtimeSteps > 0 ? { runtimeSteps } : {}), + ...(tokenUsage.cachedInputTokens > 0 + ? { cacheRead: tokenUsage.cachedInputTokens } + : {}), + ...(tokenUsage.cacheWriteInputTokens > 0 + ? { cacheCreation: tokenUsage.cacheWriteInputTokens } + : {}), + ...(tokenUsageCostUsd !== undefined ? { costUsd: tokenUsageCostUsd } : {}), + ...(contextBudgetForUsage ? { contextBudget: contextBudgetForUsage } : {}), + ...(contextRemainingForUsage !== undefined + ? { contextRemaining: contextRemainingForUsage } + : {}), + ...(providerRequestTraceId ? { providerRequestTraceId } : {}), + ...(anchorInputTokens !== undefined + ? { + lastRequestAnchor: { + inputTokens: anchorInputTokens, + ...(anchorOutputTokens !== undefined + ? { outputTokens: anchorOutputTokens } + : {}), + modelId: this.deps.backend.modelId, + ...(this.deps.backend.header.llmConnectionId !== undefined + ? { connectionId: this.deps.backend.header.llmConnectionId } + : {}), + }, + } + : {}), + }; + const tu: TokenUsageMessage = { + type: 'token_usage', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + ...usageFields, + }; + await this.deps.backend.appendMessage(tu).catch(() => {}); + if ( + !contextCompactionFailedOpenNoteWritten && + shouldAppendContextCompactionFailedOpenNote(contextBudgetForUsage) + ) { + contextCompactionFailedOpenNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_compaction_failed_open', + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + if ( + !contextCompactedNoteWritten && + shouldAppendContextCompactedNote(contextBudgetForUsage) + ) { + contextCompactedNoteWritten = true; + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + kind: 'context_compacted', + }; + await this.deps.backend.appendMessage(note).catch(() => {}); + } + queue.push({ + type: 'token_usage', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + ...usageFields, + } satisfies TokenUsageEvent); + } + } catch { + // best-effort; ai-sdk usage promise may reject on abort + } + + // Nothing may await between this check and terminal emission: Stop must + // win even when it arrives during post-stream usage persistence. + if (this.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + if (terminalProviderError) throw terminalProviderError; + const stopReason = + this.loopStopReason ?? + (maxSteps !== undefined && finishReason === 'tool-calls' + ? 'step_limit' + : this.mapFinishReason(finishReason)); + trace.modelStreamCompleted(stopReason); + const completeEvent = { + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason, + } satisfies CompleteEvent; + queue.push(completeEvent); + if (this.memoryExtractRequested && this.deps.backend.memoryExtraction) { + const snapshot = this.memorySourceSnapshot({ + trigger: 'extract', + terminalEventId: completeEvent.id, + }); + if (snapshot) { + void queue + .waitUntilConsumedThroughCurrent() + .then(() => this.deps.backend.memoryExtraction?.extract(snapshot)) + .catch(() => undefined); + } + } + } catch (err) { + streamStatus = this.aborted ? 'aborted' : 'error'; + streamErrorClass = this.deps.modelAdapter.classifyError( + currentWatchdogTimeout()?.error ?? err, + ); + // Flush the in-flight step's partial text/thinking before the terminal + // abort/error events. Earlier steps already flushed at their + // `finish-step`; this keeps their and this step's streamed-out output on + // BOTH exits — user stop and provider error / watchdog timeout — so + // partialOutputRetained reflects what the user actually saw. + await flushStep().catch(() => {}); + if (this.aborted) { + queue.push({ + type: 'abort', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + reason: 'user_stop', + } satisfies AbortEvent); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'user_stop', + } satisfies CompleteEvent); + } else { + const terminalError = currentWatchdogTimeout()?.error ?? err; + queue.push(this.makeErrorEvent(turnId, terminalError, terminalProviderErrorReason)); + trace.modelStreamFailed( + streamErrorClass, + terminalError, + priorReplayFailureTrace(priorReplay), + ); + queue.push({ + type: 'complete', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + stopReason: 'error', + } satisfies CompleteEvent); + } + } finally { + watchdogState.current?.stop(); + if (this.watchdog === watchdogState.current) this.watchdog = null; + contextBudgetForTelemetry = contextBudgetWithRequestProjectionDiagnostics( + contextBudgetForTelemetry, + activeToolResultPruneDiagnosticPatch, + midTurnCompactDiagnosticPatch, + ); + // `tokenUsage` still backfills from the completed steps when the send + // ended without a final `usage`: the terminal outcome and the + // `token_usage` SessionEvent below both read it. An unusable sample in + // any step fails it closed rather than posing a partial sum as the + // whole call (#972). + // + // The send-level `recordLlmCall` that used to sit here is gone (#1679). + // It measured the same provider requests the canonical seam now settles + // into `ModelCallAttempt`, one record per physical request instead of + // one aggregate per send, and keeping both would have been two + // independent meters free to disagree. + // + // What does NOT follow it out is the diagnostics that rode on it. The + // exhausted and aborted paths emit no `token_usage` SessionEvent, so + // their compaction decisions and the accumulated usage of the steps that + // did complete had that record as their only durable home. They move to + // the run trace, which carries no cost and meters nothing. + if (!tokenUsage && completedStepUsage && !sawUnusableStepUsage) { + tokenUsage = completedStepUsage; + tokenUsageCostUsd = this.deps.providerTelemetry.normalizedUsageCostUsd(tokenUsage); + } + trace.sendDiagnostics({ + status: streamStatus, + ...(streamErrorClass ? { errorClass: streamErrorClass } : {}), + ...(tokenUsage + ? { + inputTokens: tokenUsage.inputTokens, + outputTokens: tokenUsage.outputTokens, + totalTokens: tokenUsage.totalTokens, + } + : {}), + ...(contextBudgetForTelemetry !== undefined + ? { contextBudget: contextBudgetForTelemetry } + : {}), + ...(toolAvailabilityForTelemetry !== undefined + ? { toolAvailability: toolAvailabilityForTelemetry } + : {}), + }); + queue.close(); + } + })(); + + let drainedNormally = false; + try { + // drain() carries the seq-ack semantics (consumer pull = processed ack); + // every consumer-facing path must go through it. + yield* this.drain(queue); + drainedNormally = true; + } finally { + if (!drainedNormally) turnAbortController.abort(); + await pumpDone.catch(() => {}); + } + } + + private async executeCodeModeCell( + eventSink: DurableSessionEventSink, + code: string, + context: MakaToolContext, + ): Promise { + const snapshot = new Map(this.codeModeTools); + let nestedOutputBytes = 0; + let nestedOutputLimitExceeded = false; + const nestedEventSink: DurableSessionEventSink = { + push: (event) => { + if (event.type === 'tool_output_delta') { + const nextBytes = new TextEncoder().encode(event.chunk).byteLength; + if ( + nestedOutputLimitExceeded || + nestedOutputBytes + nextBytes > DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes + ) { + nestedOutputLimitExceeded = true; + return; + } + nestedOutputBytes += nextBytes; + } + eventSink.push(event); + }, + pushAndWaitUntilConsumed: (event) => eventSink.pushAndWaitUntilConsumed(event), + }; + // A permit is held across the cell's complete lifecycle, not just its + // sandbox run: `executeCodeCell` settles only once the cell's host + // operations have drained, so releasing on settlement covers the drain. + // The sandbox worker cap cannot serve this purpose — on cancellation + // `runCodeMode` releases its worker and rejects at once, by design, while + // host operations started by the cell may still be running with durable + // side effects. Only the Runtime waits for those, so only the Runtime can + // bound them; releasing when the worker is released would let repeated + // cancellation accumulate host work without bound. + // + // One cell may wait; the next is turned away rather than queued, which is + // what the Code Mode adapter did before this moved to the side that owns + // execution. Nothing awaits between reading `waitingCount` and the enqueue + // inside `acquire`, so the pair is atomic. + if (this.deps.codeCellAdmission.waitingCount >= MAX_WAITING_CODE_MODE_CELLS) { + return { + ok: false, + error: { kind: 'limit_exceeded', message: 'Code Mode execution queue is full' }, + toolCalls: [], + } satisfies CodeModeExecutionResult; + } + const permit = await this.deps.codeCellAdmission.acquire(context.abortSignal); + try { + return await executeCodeCell({ + code, + signal: context.abortSignal, + tools: [...snapshot.values()].map((tool) => ({ + name: tool.name, + })), + isFatalToolError: isRuntimeCommitBoundaryError, + callTool: async (name, input, signal) => { + const tool = snapshot.get(name); + if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); + const parsedInput = await validateCodeModeToolInput(tool, input); + const settlement = await this.toolRuntime.settleToolCall({ + tool, + turnId: context.turnId, + toolCallId: `${context.toolCallId}:nested:${this.deps.newId()}`, + input: parsedInput, + abortSignal: signal, + eventSink: nestedEventSink, + origin: 'code_mode', + parentToolCallId: context.toolCallId, + ...(context.operationId ? { parentOperationId: context.operationId } : {}), + maxResultBytes: DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes, + }); + if (settlement.providerError !== undefined) { + throw new Error(settlement.providerError); + } + if (nestedOutputLimitExceeded) { + throw new Error('Code Mode nested output byte limit exceeded'); + } + return settlement.result; + }, + }); + } finally { + permit.release(); + } + } + + private handlePlanToolResult(result: PlanToolResult, queue: AsyncEventQueue): void { + const turnId = this.turnId; + if (result.kind === 'plan_submitted') { + const proposal = result.proposal; + queue.push({ + type: 'plan_submitted', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + planId: proposal.planId, + proposalId: proposal.proposalId, + revision: proposal.revision, + title: proposal.title, + ...(proposal.overview ? { overview: proposal.overview } : {}), + ...(proposal.risks ? { risks: proposal.risks } : {}), + steps: proposal.steps.map((step) => ({ ...step, status: 'pending' })), + }); + this.runTrace?.emit('plan', 'plan_submitted', 'Plan submitted', { + planId: proposal.planId, + proposalId: proposal.proposalId, + revision: proposal.revision, + storeVersion: result.storeVersion, + }); + this.loopStopReason = 'plan_handoff'; + this.loopStopRequested = true; + return; + } + + const traceType = result.kind; + this.runTrace?.emit('plan', traceType, 'Plan execution state changed', { + planId: result.execution.planId, + proposalId: result.execution.proposalId, + executionId: result.execution.executionId, + storeVersion: result.storeVersion, + }); + // Completing or cancelling the execution is a tool boundary, not the end of + // the conversational Turn. The execution prompt tells the model to persist + // final progress before its final response, so let it consume this result + // and produce that response on the next provider step. + } + + private handleAgentGraphYieldToolResult(result: YieldAgentGraphToolResult): void { + this.runTrace?.emit('agent_graph', 'graph_supervisor_yielded', 'Graph supervisor yielded', { + pendingWorkCount: result.pendingWorkCount, + liveOperatorCount: result.liveOperatorCount, + reason: result.reason, + }); + this.loopStopReason = 'graph_yield'; + this.loopStopRequested = true; + } + + private mapFinishReason(reason: ModelFinishReason): CompleteEvent['stopReason'] { + return this.deps.modelAdapter.mapFinishReason(reason); + } + + private makeErrorEvent(turnId: string, err: unknown, reasonOverride?: string): ErrorEvent { + return this.deps.modelAdapter.makeErrorEvent(turnId, err, reasonOverride); + } + + /** Materialize canonical RuntimeEvent history into ai-sdk's message format. */ + private async buildPriorMessages(input: BackendSendInput): Promise { + if (!input.runtimeContext) { + return { + status: 'ready', + messages: [], + gate: 'runtime_replay_text_only', + diagnostics: [], + }; + } + const rawPriorRuntimeContext = input.runtimeContext.filter( + (event) => event.turnId !== input.turnId, + ); + // Everything below reads EFFECTIVE model history: raw events folded through + // the durable projection-transition reducer (#4283). Replay, budgeting and + // compaction share one input, so no RuntimeEvent replay path can resurrect + // content a committed transition removed. + const preparedContextBudget = await this.deps.compaction.prepareContextBudgetPolicy( + rawPriorRuntimeContext, + input.turnId, + ); + const priorRuntimeContext = preparedContextBudget.events; + const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( + priorRuntimeContext, + input.runtimeContextInvocations, + this.deps.backend.providerStateIdentity, + this.deps.backend.modelId, + ); + let contextBudget = preparedContextBudget.policy; + const budgeted = applyRuntimeEventContextBudget(priorRuntimeContext, contextBudget); + let runtimeContext = budgeted?.events ?? priorRuntimeContext; + let contextBudgetDiagnostic = budgeted?.diagnostic; + let projectedHistoryCompactCheckpoint = budgeted?.historyCompactCheckpoint; + if (preparedContextBudget.diagnosticPatch) { + contextBudgetDiagnostic = mergeContextBudgetDiagnostic( + contextBudgetDiagnostic ?? + buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget), + preparedContextBudget.diagnosticPatch, + ); + } + + // No pre-turn estimate gate: the turn's first request is judged by the + // request-projection hook from the previous request's real usage, and by + // the provider when it goes out (#4559). + // The boundary belongs to the RuntimeEvent projection above. + const replayBoundary = (fromRuntimeReplay: boolean) => + fromRuntimeReplay && projectedHistoryCompactCheckpoint + ? { latestHistoryCompactCheckpoint: projectedHistoryCompactCheckpoint } + : {}; + + const plan = buildRuntimeEventModelReplayPlan( + runtimeContext, + // `runtimeContext` may be a budget/history-search slice; the tool-turn + // thinking skip is a whole-history invariant, so seed it from the full + // prior ledger so a sliced-in tool-turn thinking still gets skipped. + { toolActivityTurnIds: collectToolActivityTurnIds(priorRuntimeContext) }, + ); + const hasProviderHistoryCompactCheckpoint = + projectedHistoryCompactCheckpoint !== undefined && + isProviderHistoryCompactCheckpoint(projectedHistoryCompactCheckpoint); + const materializeReplayFallback = (): Promise => + this.deps.messageProjection.materializeRuntimeReplayTextOnly( + this.imageBudget, + plan, + projectedHistoryCompactCheckpoint, + ); + if (plan.items.length === 0 && !hasProviderHistoryCompactCheckpoint) { + return { + status: 'ready', + messages: await materializeReplayFallback(), + gate: 'runtime_replay_text_only', + diagnostics: plan.diagnostics, + runtimeEventCount: runtimeContext.length, + ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), + ...replayBoundary(true), + }; + } + + if (hasBlockingReplayDiagnostics(plan)) { + return { + status: 'ready', + messages: await materializeReplayFallback(), + gate: input.continuation + ? 'runtime_replay_text_only' + : 'runtime_replay_unsupported_semantics', + diagnostics: plan.diagnostics, + runtimeEventCount: runtimeContext.length, + ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), + ...replayBoundary(true), + }; + } + + if (!plan.hasProviderNativeSemantics) { + return { + status: 'ready', + messages: await this.deps.messageProjection.materializeRuntimeReplayPlan( + plan, + this.imageBudget, + projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, + ), + gate: 'runtime_replay_text_only', + diagnostics: plan.diagnostics, + runtimeEventCount: runtimeContext.length, + ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), + ...replayBoundary(true), + }; + } + + if (!this.deps.messageProjection.canReplayProviderNative(plan)) { + // Degrade per item, not per plan: an unsupported provider-executed pair + // must not cost unrelated client tool history (#2972). Thinking items + // stay in the plan; materializeRuntimeReplayPlan degrades unsupported + // reasoning per item via reasoningReplay. + const degradedPlan = this.deps.messageProjection.dropUnsupportedReplayItems(plan); + return { + status: 'ready', + messages: + degradedPlan.items.length > 0 || hasProviderHistoryCompactCheckpoint + ? await this.deps.messageProjection.materializeRuntimeReplayPlan( + degradedPlan, + this.imageBudget, + projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, + ) + : await materializeReplayFallback(), + gate: input.continuation + ? 'runtime_replay_text_only' + : 'runtime_replay_unsupported_semantics', + diagnostics: plan.diagnostics, + runtimeEventCount: runtimeContext.length, + ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), + ...replayBoundary(true), + }; + } + + return { + status: 'ready', + messages: await this.deps.messageProjection.materializeRuntimeReplayPlan( + plan, + this.imageBudget, + projectedHistoryCompactCheckpoint, + providerReasoningReplayEventIds, + ), + gate: 'runtime_replay_provider_native', + diagnostics: plan.diagnostics, + runtimeEventCount: runtimeContext.length, + ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), + ...replayBoundary(true), + }; + } + + private async resolveSystemPrompt(): Promise { + const turnId = this.turnId; + if (typeof this.deps.backend.systemPrompt === 'function') { + return await this.deps.backend.systemPrompt({ + sessionId: this.deps.backend.sessionId, + turnId, + cwd: this.deps.backend.header.cwd, + emitSkillCatalogTrace: (message, data) => + this.runTrace?.emit('skill', 'skill_catalog_built', message, data), + }); + } + return this.deps.backend.systemPrompt; + } + + private async *drain(queue: AsyncEventQueue): AsyncIterable { + try { + for await (const ev of queue) { + yield ev; + // Generator backpressure IS the consumer's ack: this line runs only + // when the consumer's loop body finished for `ev` and pulled the next + // event, so `consumedCount` counts fully PROCESSED events. AgentRun + // persists each mapped event before continuing, so an acked event is + // either durable or deliberately skipped (partials, non-terminal + // errors) — exactly the set a durable read can ever return. + queue.ackConsumed(); + } + } finally { + // The consumer abandoned or finished the stream; wake any seq-ack waiter + // so it observes `consumerDetached` instead of blocking forever. + queue.noteConsumerDetached(); + } + } + + /** + * Drain the caller's pending steering at a step boundary. Each message is + * echoed as a `steering_message` event (so the ledger + transcript render the + * interjection in place) and accumulated as an envelope-wrapped user message + * for injection into subsequent provider requests. + * + * Persist-before-include invariant: the initial user message is durable + * before the backend is invoked, and a steered message must hold the same + * line — the provider must never start executing a directive the ledger does + * not carry. The seq-ack boundary provides that without a second write path: + * the consumer's pull is the ack, and AgentRun persists each mapped event + * before continuing (see drain()), so once everything enqueued up to the + * steering event is consumed, the event is durable. If the consumer detaches + * (the persist path failed or the turn is being torn down) before that, the + * message is nacked and NOT included in any request; an abort after the push + * waits for that same convergence — durable ⇒ ack (history owns it), detach + * ⇒ nack — and only then throws so the dying request is never sent. + */ + private async drainSteeringInto( + input: BackendSendInput, + queue: AsyncEventQueue, + ): Promise { + const turnId = this.turnId; + const abortSignal = this.abortController.signal; + const pull = input.pullSteering; + if (!pull) return; + const leases = pull(); + if (leases.length === 0) return; + // Binary settlement: every pulled lease settles exactly once, decided + // ONLY by the persistence fact — durably consumed ⇒ ack + injection set; + // provably never persisted (never pushed, or the consumer detached + // without consuming it) ⇒ nack. An abort does NOT settle a pushed lease: + // it only stops new pushes and the dying request; the wait continues + // until the teardown converges it (the flow drains after terminal events + // or detaches on failure), because nacking a durably appended event + // would put the same directive in the account twice — once via history + // replay, once via the reclaimed queue. + const undelivered = [...leases]; + try { + for (const lease of leases) { + if (this.aborted || abortSignal?.aborted) { + // Never pushed: settles as undelivered. + throw Object.assign(new Error('aborted before steering was pushed'), { + name: 'AbortError', + }); + } + if (queue.consumerDetached) { + throw new Error('steering message was not durably consumed: event consumer detached'); + } + // Materialize provider content before publishing the durable event. + // After consumption there must be no fallible gap before ack/injection. + const eventId = this.deps.newId(); + const providerContent = await this.deps.messageProjection.appendImageParts( + this.imageBudget, + buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)), + lease.content.attachments, + `steering:${eventId}`, + ); + if (this.aborted || abortSignal?.aborted) { + throw Object.assign(new Error('aborted before steering was pushed'), { + name: 'AbortError', + }); + } + if (queue.consumerDetached) { + throw new Error('steering message was not durably consumed: event consumer detached'); + } + await queue.pushAndWaitUntilConsumed({ + type: 'steering_message', + id: eventId, + turnId, + ts: this.deps.now(), + messageId: lease.messageId, + content: lease.content, + ...(lease.submittedContentDigest + ? { submittedContentDigest: lease.submittedContentDigest } + : {}), + } satisfies SessionEvent); + // The mapped RuntimeEvent inherits this session event's id, so the + // injected message and its future ledger replay share one identity. + this.injectedSteeringMessages.push(steeringModelMessage(eventId, providerContent)); + input.ackSteering?.([lease.id]); + undelivered.shift(); + if (this.aborted || abortSignal?.aborted) { + // Settled (the ledger owns the message; the next turn replays it), + // but the send is dying: stop before any request is built with it. + throw Object.assign(new Error('aborted after steering was durable'), { + name: 'AbortError', + }); + } + } + } catch (error) { + if (undelivered.length > 0) { + input.nackSteering?.(undelivered.map((lease) => lease.id)); + } + throw error; + } + } +} + +function isPlanToolResult(output: unknown): output is PlanToolResult { + if (!output || typeof output !== 'object') return false; + return [ + 'plan_submitted', + 'plan_progress_updated', + 'plan_execution_completed', + 'plan_execution_cancelled', + ].includes(String((output as { kind?: unknown }).kind)); +} + +function isAgentGraphYieldToolResult(output: unknown): output is YieldAgentGraphToolResult { + if (output === null || typeof output !== 'object' || Array.isArray(output)) return false; + const result = output as Record; + return ( + Object.keys(result).length === 4 && + result.kind === 'agent_graph_yielded' && + typeof result.pendingWorkCount === 'number' && + Number.isSafeInteger(result.pendingWorkCount) && + result.pendingWorkCount > 0 && + typeof result.liveOperatorCount === 'number' && + Number.isSafeInteger(result.liveOperatorCount) && + result.liveOperatorCount >= 0 && + typeof result.reason === 'string' && + result.reason.length > 0 && + result.reason.length <= 4_000 && + result.reason.trim() === result.reason + ); +} + +function priorReplayFailureTrace(replay: { + gate: string; + diagnostics: readonly { code: string }[]; +}): { gate: string; diagnosticCodes: string[] } { + return { + gate: replay.gate, + diagnosticCodes: [...new Set(replay.diagnostics.map((diagnostic) => diagnostic.code))], + }; +} + +class ContinuationReplayEmptyError extends Error { + readonly code = 'continuation_replay_empty'; + + constructor( + readonly replayGate: string, + readonly diagnosticCodes: readonly string[], + ) { + super(`Continuation replay is empty after ${replayGate}`); + this.name = 'ContinuationReplayEmptyError'; + } +} + +function mergeActiveToolResultPruneDiagnosticPatches( + left: ActiveToolResultPruneDiagnosticPatch, + right: ActiveToolResultPruneDiagnosticPatch, +): ActiveToolResultPruneDiagnosticPatch { + return { + ...sumOptionalCounts('activePrunedToolResults', left, right), + ...sumOptionalCounts('activeSupersededToolResults', left, right), + ...sumOptionalCounts('activeDuplicateToolResults', left, right), + ...sumOptionalCounts('activeArchiveFailures', left, right), + ...sumOptionalCounts('activeEstimatedTokensSaved', left, right), + }; +} + +function mergeNormalizedUsage( + current: NormalizedAiSdkUsage | undefined, + next: NormalizedAiSdkUsage, +): NormalizedAiSdkUsage { + if (!current) return next; + const cacheMissInputSource = + current.cacheMissInputSource === 'explicit' || next.cacheMissInputSource === 'explicit' + ? 'explicit' + : 'derived'; + const cacheHitInputTokens = current.cacheHitInputTokens + next.cacheHitInputTokens; + return { + inputTokens: current.inputTokens + next.inputTokens, + outputTokens: current.outputTokens + next.outputTokens, + cacheHitInputTokens, + cacheMissInputTokens: current.cacheMissInputTokens + next.cacheMissInputTokens, + cacheMissInputSource, + cacheWriteInputTokens: current.cacheWriteInputTokens + next.cacheWriteInputTokens, + reasoningTokens: current.reasoningTokens + next.reasoningTokens, + totalTokens: current.totalTokens + next.totalTokens, + ...(next.rawFinishReason !== undefined ? { rawFinishReason: next.rawFinishReason } : {}), + cachedInputTokens: cacheHitInputTokens, + }; +} + +function sumOptionalCounts( + key: K, + left: ActiveToolResultPruneDiagnosticPatch, + right: ActiveToolResultPruneDiagnosticPatch, +): Pick | Record { + const total = (left[key] ?? 0) + (right[key] ?? 0); + return total > 0 ? ({ [key]: total } as Pick) : {}; +} + +function contextBudgetWithRequestProjectionDiagnostics( + base: ContextBudgetDiagnostic | undefined, + patch: ActiveToolResultPruneDiagnosticPatch, + compactionPatch: Partial | undefined, +): ContextBudgetDiagnostic | undefined { + const prunePatch = hasActiveToolResultPruneDiagnosticPatch(patch) ? patch : undefined; + const mergedPatch = mergeContextBudgetDiagnosticPatches(prunePatch, compactionPatch); + if (!mergedPatch) return base; + return mergeContextBudgetDiagnostic(base ?? minimalContextBudgetDiagnostic(), mergedPatch); +} + +function projectMemoryConversationPrefix( + messages: readonly ModelMessage[], + eventMessagePositions?: Readonly>, +): { + messages: ModelMessage[]; + eventMessagePositions?: Readonly>; +} { + // Context visibility and durable evidence authority are separate boundaries. + // Keep the exact source prefix so the auxiliary request preserves referents + // and provider-cache shape. The Evidence Index and trusted admission layer + // independently restrict durable citations to user-authored RuntimeEvents. + return { + messages: [...messages], + ...(eventMessagePositions ? { eventMessagePositions } : {}), + }; +} + +function memoryExtractionModelHeader( + header: SessionHeader, +): MemoryExtractionSourceSnapshot['sourceHeader'] { + return { + ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), + llmConnectionSlug: header.llmConnectionSlug, + model: header.model, + ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), + }; +} diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index c26b7c6ad2..d9cfb81278 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -282,7 +282,10 @@ export function digestProviderReplayAdmission(input: { providerStateIdentity: input.targetProviderStateIdentity ?? null, modelId: input.targetModelId, }, - items: input.items, + // The immutable boundary cursor already binds every segment's invocation. + // Keep projection-v2 digests stable while replay uses that identity + // internally to pair provider-local step and tool ids. + items: input.items.map(({ invocationId: _invocationId, ...item }) => item), }); return `sha256:${createHash('sha256').update(json, 'utf8').digest('hex')}`; } diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 9edd63ffcc..07662efd72 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -911,13 +911,8 @@ function cloneAgentRunEvent( // checkpoint whose summary no longer satisfies the COMPLETE predicate — // re-runnable here on structure and truncation (the size floor needs the // summarizer call's usage, which a copy does not have) — must not - // propagate into a fresh session. Unmarked legacy summaries stay copyable - // under the truncation-only load policy and keep their unmarked identity - // in the target. - if ( - sourceCheckpoint.summaryFormat !== undefined && - findCheckpointSummaryDefect(sourceCheckpoint.summary) !== undefined - ) { + // propagate into a fresh session. + if (findCheckpointSummaryDefect(sourceCheckpoint.summary) !== undefined) { throw new Error(`Cannot copy invalid history compact checkpoint ${event.id}`); } const coveredRuntimeEvents = match.coveredRuntimeEvents.map((sourceEvent) => { @@ -942,7 +937,6 @@ function cloneAgentRunEvent( sessionId: references.targetSessionId, coveredRuntimeEvents, summary: sourceCheckpoint.summary, - summaryFormat: sourceCheckpoint.summaryFormat ?? 'legacy_freeform', highWaterName: sourceCheckpoint.highWaterName, highWaterSeq: sourceCheckpoint.highWaterSeq, now: sourceCheckpoint.createdAt, diff --git a/packages/runtime/src/history-compact-checkpoint.ts b/packages/runtime/src/history-compact-checkpoint.ts index 8c3b29e264..e19f64d4f2 100644 --- a/packages/runtime/src/history-compact-checkpoint.ts +++ b/packages/runtime/src/history-compact-checkpoint.ts @@ -108,14 +108,8 @@ interface HistoryCompactCheckpointBase { export interface TextHistoryCompactCheckpoint extends HistoryCompactCheckpointBase { version: 2; summary: string; - /** - * Durable identity of the summary contract the writer honored. Present on - * checkpoints built under the sectioned contract (#3029), which every - * admission seam (load/repair, copy) holds to the complete predicate; - * absent on legacy free-form V2 data, kept loadable under the - * truncation-only compatibility policy. - */ - summaryFormat?: SectionedSummaryFormat; + /** Durable identity of the summary contract the writer honored. */ + summaryFormat: SectionedSummaryFormat; } export interface OpenAiCodexRemoteCompactState { @@ -154,11 +148,6 @@ interface BuildHistoryCompactCheckpointBaseInput { export type BuildTextHistoryCompactCheckpointInput = BuildHistoryCompactCheckpointBaseInput & { summary: string; - /** - * Defaults to the sectioned contract. `legacy_freeform` builds an unmarked - * checkpoint — only for preserving a legacy source summary across copy. - */ - summaryFormat?: SectionedSummaryFormat | 'legacy_freeform'; providerState?: never; }; export type BuildProviderHistoryCompactCheckpointInput = BuildHistoryCompactCheckpointBaseInput & { @@ -220,9 +209,8 @@ export function buildHistoryCompactCheckpoint( // this builder assigns it — and only after re-checking structure and // truncation at every construction seam, including copy. The size floor is // the summarizer's own check: it needs the provider's usage for the call, - // which no construction seam has. A caller with unvalidated free-form text - // must declare `legacy_freeform` instead of minting trust it did not earn. - if (!providerState && input.summaryFormat !== 'legacy_freeform') { + // which no construction seam has. + if (!providerState) { const defect = findCheckpointSummaryDefect(summary!); if (defect) { throw new Error(`History compact checkpoint summary failed validation: ${defect}`); @@ -337,9 +325,7 @@ export function buildHistoryCompactCheckpoint( ...common, version: 2, summary: summary!, - ...(input.summaryFormat === 'legacy_freeform' - ? {} - : { summaryFormat: SECTIONED_SUMMARY_FORMAT }), + summaryFormat: SECTIONED_SUMMARY_FORMAT, }; checkpoint.estimatedTokens = estimateTokens( isProviderHistoryCompactCheckpoint(checkpoint) @@ -495,12 +481,8 @@ export function validateHistoryCompactCheckpointShape( (checkpoint.version === 2 ? typeof checkpoint.summary === 'string' && checkpoint.summary.trim().length > 0 && - // Absent = legacy free-form; a present marker must be one this code - // understands, so an unknown future format fails closed to the - // canonical-events repair path instead of replaying unvalidated. - ((checkpoint as Partial).summaryFormat === undefined || - (checkpoint as Partial).summaryFormat === - SECTIONED_SUMMARY_FORMAT) && + (checkpoint as Partial).summaryFormat === + SECTIONED_SUMMARY_FORMAT && !('providerState' in checkpoint) : !('summary' in checkpoint) && validHistoryCompactProviderState( diff --git a/packages/runtime/src/history-compact-ledger.ts b/packages/runtime/src/history-compact-ledger.ts index 6eb9c0fb65..ab63add144 100644 --- a/packages/runtime/src/history-compact-ledger.ts +++ b/packages/runtime/src/history-compact-ledger.ts @@ -24,10 +24,7 @@ import { validateHistoryCompactCheckpointShape, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; -import { - findCheckpointSummaryDefect, - findCheckpointSummaryTruncationDefect, -} from './history-compact-summary-validation.js'; +import { findCheckpointSummaryDefect } from './history-compact-summary-validation.js'; interface LedgerCheckpointCandidate { checkpoint: HistoryCompactCheckpoint; @@ -40,17 +37,11 @@ interface LedgerCheckpointCandidate { * complete shared predicate (minus the size floor, whose covered-span * estimate is not durable), so a malformed summary that slipped through a * direct recorder or copy seam never becomes authoritative again after - * restart. Unmarked legacy checkpoints predate the sectioned contract, so - * their summaries may omit `## Goal` etc. and remain usable; only a - * truncated fragment — which poisons every subsequent replay with a - * half-finished thought regardless of writer — is quarantined. + * restart. */ function hasLoadableHistoryCompactSummary(checkpoint: HistoryCompactCheckpoint): boolean { if (!isTextHistoryCompactCheckpoint(checkpoint)) return true; - if (checkpoint.summaryFormat !== undefined) { - return findCheckpointSummaryDefect(checkpoint.summary) === undefined; - } - return findCheckpointSummaryTruncationDefect(checkpoint.summary) === undefined; + return findCheckpointSummaryDefect(checkpoint.summary) === undefined; } export async function loadHistoryCompactCheckpointsFromRunLedger( diff --git a/packages/runtime/src/history-compact-summarizer.ts b/packages/runtime/src/history-compact-summarizer.ts index a3d7cfd30f..56765f3afe 100644 --- a/packages/runtime/src/history-compact-summarizer.ts +++ b/packages/runtime/src/history-compact-summarizer.ts @@ -18,7 +18,12 @@ */ import { rawFinishReasonString, type ModelMessage, type ToolCallPart } from './model-protocol.js'; -import { buildRuntimeEventModelReplayPlan } from './model-history.js'; +import { + buildRuntimeEventModelReplayPlan, + buildRuntimeEventReplayTimeline, + type RuntimeEventReplayToolCallItem, + type RuntimeEventReplayToolResultItem, +} from './model-history.js'; import { findCheckpointSummaryDefect, SUMMARY_FORMAT_TEMPLATE, @@ -272,94 +277,63 @@ async function loadAiSdkTextModule(): Promise { type ReplayPlanItems = ReturnType['items']; -interface OpenToolStep { - stepId: string | undefined; - calls: ToolCallPart[]; - callIds: Set; - settledCallIds: Set; - bufferedResults: ModelMessage[]; -} - export function replayPlanItemsToModelMessages(items: ReplayPlanItems): ModelMessage[] { const out: ModelMessage[] = []; - // One assistant step's tool calls share one assistant message and every - // result is deferred to the step boundary: strict OpenAI-compatible - // providers reject an assistant message that arrives while a previous - // assistant message's tool calls are still unanswered, and Runtime history - // can legitimately interleave a step's calls and results - // (call A, call B, result A, call C, result B, result C). Step membership - // follows the stamped stepId when both sides carry one; legacy items - // without a stepId join while the open step still has unsettled calls, - // which is exactly the interleaving case. This mirrors the primary replay - // materializer's step merge; the primary path is untouched. - let openStep: OpenToolStep | undefined; - const flushOpenStep = () => { - if (!openStep) return; - out.push(...openStep.bufferedResults); - openStep = undefined; + const toolCallPart = (item: RuntimeEventReplayToolCallItem): ToolCallPart => ({ + type: 'tool-call', + toolCallId: item.toolCallId, + toolName: item.toolName, + input: item.input, + }); + const pushToolResult = (item: RuntimeEventReplayToolResultItem) => { + out.push({ + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: item.toolCallId, + toolName: item.toolName, + output: effectiveReplayToolResultOutput(item), + }, + ], + }); }; - for (const item of items) { - if (item.kind === 'text') { - flushOpenStep(); - // Split on role so each push matches exactly one ModelMessage arm — no cast. - const textPart = { type: 'text' as const, text: item.content }; - if (item.role === 'user') { - out.push({ role: 'user', content: [textPart] }); - } else { - out.push({ role: 'assistant', content: [textPart] }); - } - } else if (item.kind === 'tool_call') { - const part: ToolCallPart = { - type: 'tool-call', - toolCallId: item.toolCallId, - toolName: item.toolName, - input: item.input, - }; - const joinsOpenStep = - openStep !== undefined && - (openStep.stepId !== undefined && item.stepId !== undefined - ? openStep.stepId === item.stepId - : openStep.settledCallIds.size < openStep.callIds.size); - if (openStep && joinsOpenStep) { - openStep.calls.push(part); - openStep.callIds.add(item.toolCallId); - } else { - flushOpenStep(); - const calls = [part]; - openStep = { - stepId: item.stepId, - calls, - callIds: new Set([item.toolCallId]), - settledCallIds: new Set(), - bufferedResults: [], - }; - out.push({ role: 'assistant', content: calls }); - } - } else if (item.kind === 'tool_result') { - const message: ModelMessage = { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: item.toolCallId, - toolName: item.toolName, - output: effectiveReplayToolResultOutput(item), - }, - ], - }; - if (openStep?.callIds.has(item.toolCallId)) { - openStep.settledCallIds.add(item.toolCallId); - openStep.bufferedResults.push(message); - } else { - // A result for a call outside the open step means that step's block - // is complete; settle it before emitting the foreign result. - flushOpenStep(); - out.push(message); + + for (const entry of buildRuntimeEventReplayTimeline(items)) { + if (entry.kind === 'text') { + const textPart = { type: 'text' as const, text: entry.item.content }; + out.push( + entry.item.role === 'user' + ? { role: 'user', content: [textPart] } + : { role: 'assistant', content: [textPart] }, + ); + continue; + } + if (entry.kind === 'thinking') continue; + + const providerCalls = entry.calls.filter(({ call }) => call.providerExecuted === true); + if (providerCalls.length > 0) { + out.push({ + role: 'assistant', + content: providerCalls.map(({ call }) => toolCallPart(call)), + }); + for (const { result } of providerCalls) { + if (result) pushToolResult(result); } } - // thinking entries are intentionally skipped for summarization; they do - // not interrupt an open tool step. + + const clientCalls = entry.calls.filter(({ call }) => call.providerExecuted !== true); + const lateContent: Array<{ type: 'text'; text: string } | ToolCallPart> = []; + if (entry.text?.content) { + lateContent.push({ type: 'text', text: entry.text.content }); + } + lateContent.push(...clientCalls.map(({ call }) => toolCallPart(call))); + if (lateContent.length > 0) { + out.push({ role: 'assistant', content: lateContent }); + } + for (const { result } of clientCalls) { + if (result) pushToolResult(result); + } } - flushOpenStep(); return out; } diff --git a/packages/runtime/src/history-compact-summary-validation.ts b/packages/runtime/src/history-compact-summary-validation.ts index 5e4136c581..a895ba67a2 100644 --- a/packages/runtime/src/history-compact-summary-validation.ts +++ b/packages/runtime/src/history-compact-summary-validation.ts @@ -27,8 +27,7 @@ import type { MalformedHistoryCompactSummaryReason } from './history-compact-err /** * Durable identity of the sectioned summary contract. Stamped on newly built * text checkpoints so record, load/repair, and copy can hold them to the - * complete predicate, while unmarked legacy V2 free-form summaries stay - * loadable under the truncation-only compatibility policy. + * complete predicate. */ export const SECTIONED_SUMMARY_FORMAT = 'sections_v1' as const; export type SectionedSummaryFormat = typeof SECTIONED_SUMMARY_FORMAT; @@ -84,8 +83,8 @@ const LARGE_FOLD_INPUT_TOKENS = 10_000; const LARGE_FOLD_SUMMARY_TOKENS_FLOOR = 200; // Best-effort signals that a provider stopped mid-thought. Fence state is -// derived by the structural scanner below, so write admission and legacy-load -// quarantine cannot disagree about Markdown fence semantics. A trailing +// derived by the structural scanner below, so structural and truncation +// checks cannot disagree about Markdown fence semantics. A trailing // backtick is deliberately absent: it can be the end of a complete fence. const TRUNCATED_TAIL_PATTERN = /(?:\.{3}|[::,,、;;…((—])\s*$/u; @@ -130,17 +129,6 @@ export function findCheckpointSummaryDefect( return undefined; } -// Legacy checkpoints predate the section contract, so load recovery may only -// quarantine writer-agnostic truncation. It still uses the exact same scan and -// tail predicate as strict write admission above. -export function findCheckpointSummaryTruncationDefect( - summary: string, -): Extract | undefined { - const trimmed = summary.trim(); - if (trimmed.length === 0) return undefined; - return findTruncationDefect(trimmed, scanSummaryStructure(trimmed)); -} - function findTruncationDefect( text: string, scan: SummaryStructureScan, diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 64feb3605e..ea452dbbcf 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -23,11 +23,10 @@ * * Architecture: docs/architecture/llm-compaction-events-log-projection-draft.md * - * Phase 1 scope: pure, synchronous projection. Replaces the ad-hoc - * StoredMessage filtering in AiSdkBackend.materializePriorMessages with an - * explicit, policy-driven filter over canonical events. The output is a - * neutral `ModelHistoryEntry[]` that callers (ai-sdk backend, flow runner) - * translate into provider-specific message shapes. + * RuntimeEvents are the semantic authority. This module owns both admission + * into replay and the chronological assistant-step timeline consumed by the + * AI SDK request path, text summarizer, and Codex compactor. StoredMessage is + * a UI/import projection and is never a provider-history fallback. * * Policy (why an event is KEPT): * - non-partial (final content, not a transient streaming chunk) @@ -44,12 +43,9 @@ * - system-role events by default (UI-only notes; system instructions * are injected fresh by the runner, not replayed from history) * - * Thinking and tool events are opt-in/opt-out so callers can match the - * replay contract of their provider (V0.1 text-only replay cannot use - * them; Anthropic replay can re-use signed thinking, etc.). - * - * NOTE: imports the new `@maka/core/runtime-event` subpath. The steward - * node re-exports it from the core barrel. + * Thinking and tool events are opt-in/opt-out so callers can match the replay + * contract of their provider; unsupported provider-native parts degrade per + * item without reviving the retired 0.1.x StoredMessage history path. */ import { @@ -319,6 +315,7 @@ export type RuntimeEventReplaySemanticKind = 'text' | 'thinking' | 'tool_call' | export type RuntimeEventModelReplayItem = | { kind: 'text'; + invocationId: string; role: 'user' | 'assistant' | 'system'; content: string; providerOptions?: NonNullable; @@ -338,6 +335,7 @@ export type RuntimeEventModelReplayItem = } | { kind: 'thinking'; + invocationId: string; text: string; signature?: string; providerOptions?: NonNullable; @@ -348,6 +346,7 @@ export type RuntimeEventModelReplayItem = } | { kind: 'tool_call'; + invocationId: string; toolCallId: string; toolName: string; input: unknown; @@ -360,6 +359,7 @@ export type RuntimeEventModelReplayItem = } | { kind: 'tool_result'; + invocationId: string; toolCallId: string; toolName: string; output: unknown; @@ -370,6 +370,137 @@ export type RuntimeEventModelReplayItem = ts: number; }; +export type RuntimeEventReplayTextItem = Extract; +export type RuntimeEventReplayThinkingItem = Extract< + RuntimeEventModelReplayItem, + { kind: 'thinking' } +>; +export type RuntimeEventReplayToolCallItem = Extract< + RuntimeEventModelReplayItem, + { kind: 'tool_call' } +>; +export type RuntimeEventReplayToolResultItem = Extract< + RuntimeEventModelReplayItem, + { kind: 'tool_result' } +>; + +export interface RuntimeEventReplayToolExchange { + call: RuntimeEventReplayToolCallItem; + result?: RuntimeEventReplayToolResultItem; +} + +export type RuntimeEventReplayTimelineEntry = + | { + kind: 'assistant_step'; + invocationId: string; + stepId?: string; + reasoning: RuntimeEventReplayThinkingItem[]; + text?: RuntimeEventReplayTextItem; + calls: RuntimeEventReplayToolExchange[]; + } + | { kind: 'text'; item: RuntimeEventReplayTextItem } + | { kind: 'thinking'; item: RuntimeEventReplayThinkingItem }; + +/** + * The single authority for model-history chronology. Results attach to calls + * by invocation + provider-local id, while assistant step parts join only the + * immediately adjacent segment with the same invocation + step id. Reusing an + * id later never moves that work across an execution boundary. + */ +export function buildRuntimeEventReplayTimeline( + items: readonly RuntimeEventModelReplayItem[], +): RuntimeEventReplayTimelineEntry[] { + const results = new Map< + string, + Array<{ item: RuntimeEventReplayToolResultItem; index: number }> + >(); + for (const [index, item] of items.entries()) { + if (item.kind !== 'tool_result') continue; + const identity = replayToolIdentity(item.invocationId, item.toolCallId); + const matches = results.get(identity) ?? []; + matches.push({ item, index }); + results.set(identity, matches); + } + + const timeline: RuntimeEventReplayTimelineEntry[] = []; + const adjacentStep = (invocationId: string, stepId: string | undefined) => { + const last = timeline.at(-1); + return last?.kind === 'assistant_step' && + last.invocationId === invocationId && + last.stepId === stepId + ? last + : undefined; + }; + const appendStep = (invocationId: string, stepId: string | undefined) => { + const entry: Extract = { + kind: 'assistant_step', + invocationId, + ...(stepId !== undefined ? { stepId } : {}), + reasoning: [], + calls: [], + }; + timeline.push(entry); + return entry; + }; + + const resultIndexes = new WeakMap(); + const adjacentLegacyStepIsOpen = (invocationId: string, currentIndex: number) => { + const step = adjacentStep(invocationId, undefined); + return step?.calls.some((exchange) => { + const resultIndex = resultIndexes.get(exchange); + return resultIndex === undefined || resultIndex > currentIndex; + }) + ? step + : undefined; + }; + + for (const [index, item] of items.entries()) { + if (item.kind === 'tool_result') continue; + if (item.kind === 'tool_call') { + const step = + (item.stepId === undefined + ? adjacentLegacyStepIsOpen(item.invocationId, index) + : adjacentStep(item.invocationId, item.stepId)) ?? + appendStep(item.invocationId, item.stepId); + const matches = results.get(replayToolIdentity(item.invocationId, item.toolCallId)); + while (matches?.[0] && matches[0].index <= index) matches.shift(); + const matchedResult = matches?.shift(); + const exchange: RuntimeEventReplayToolExchange = { + call: item, + ...(matchedResult ? { result: matchedResult.item } : {}), + }; + if (matchedResult) resultIndexes.set(exchange, matchedResult.index); + step.calls.push(exchange); + continue; + } + if (item.kind === 'thinking') { + if (item.stepId === undefined) timeline.push({ kind: 'thinking', item }); + else { + const step = + adjacentStep(item.invocationId, item.stepId) ?? + appendStep(item.invocationId, item.stepId); + step.reasoning.push(item); + } + continue; + } + if (item.role === 'assistant' && item.stepId !== undefined) { + const adjacent = adjacentStep(item.invocationId, item.stepId); + const step = + adjacent && adjacent.text === undefined + ? adjacent + : appendStep(item.invocationId, item.stepId); + step.text = item; + } else { + timeline.push({ kind: 'text', item }); + } + } + return timeline; +} + +function replayToolIdentity(invocationId: string, toolCallId: string): string { + return JSON.stringify([invocationId, toolCallId]); +} + export interface RuntimeEventModelReplayPlan { items: RuntimeEventModelReplayItem[]; textMessages: TextModelMessage[]; @@ -676,6 +807,7 @@ export function buildRuntimeEventModelReplayPlan( const assistantStepId = role === 'assistant' ? assistantReplayStepId(event) : undefined; items.push({ kind: 'text', + invocationId: event.invocationId, role, // A steered user event replays in its canonical provider form (the // envelope); the raw text is a UI/transcript projection only. @@ -727,6 +859,7 @@ export function buildRuntimeEventModelReplayPlan( const thinkingStepId = assistantReplayStepId(event); items.push({ kind: 'thinking', + invocationId: event.invocationId, text: event.content.text, ...(event.content.signature ? { signature: event.content.signature } : {}), ...(event.content.providerOptions !== undefined @@ -758,6 +891,7 @@ export function buildRuntimeEventModelReplayPlan( } const item: Extract = { kind: 'tool_call', + invocationId: event.invocationId, toolCallId: event.content.id, toolName: event.content.name, input: event.content.args, @@ -775,7 +909,7 @@ export function buildRuntimeEventModelReplayPlan( eventId: event.id, ts: event.ts, }; - callsById.set(event.content.id, { + callsById.set(replayToolIdentity(event.invocationId, event.content.id), { name: event.content.name, eventId: event.id, item, @@ -798,12 +932,13 @@ export function buildRuntimeEventModelReplayPlan( continue; } const effective = decodeEffectiveToolResultProjection(event.content, event.sessionId); + const identity = replayToolIdentity(event.invocationId, event.content.id); if (effective.kind === 'invalid_legacy') { - const call = callsById.get(event.content.id); + const call = callsById.get(identity); if (call) { const callIndex = items.indexOf(call.item); if (callIndex >= 0) items.splice(callIndex, 1); - callsById.delete(event.content.id); + callsById.delete(identity); } diagnostics.push(diagnostic(event, 'unsupported_content', effective.message)); continue; @@ -812,7 +947,7 @@ export function buildRuntimeEventModelReplayPlan( effective.kind === 'provider_native' || effective.kind === 'legacy_output' ? effective.output : effective.legacyOutput; - const call = callsById.get(event.content.id); + const call = callsById.get(identity); if (!call) { diagnostics.push( diagnostic( @@ -841,6 +976,7 @@ export function buildRuntimeEventModelReplayPlan( } items.push({ kind: 'tool_result', + invocationId: event.invocationId, toolCallId: event.content.id, toolName: event.content.name, output: normalizedResult, @@ -856,7 +992,7 @@ export function buildRuntimeEventModelReplayPlan( eventId: event.id, ts: event.ts, }); - callsById.delete(event.content.id); + callsById.delete(identity); break; } default: @@ -877,14 +1013,17 @@ export function buildRuntimeEventModelReplayPlan( // replay: a tool_use with no tool_result is a provider 400. Drop it — the // deliberately non-blocking mirror of unmatched_tool_result — so consumers // that read `items` directly (materializer, compact summarizer) stay valid. - for (const [toolCallId, call] of callsById) { + for (const call of callsById.values()) { const index = items.indexOf(call.item); if (index >= 0) items.splice(index, 1); diagnostics.push({ code: 'unmatched_tool_call', message: 'function_call has no matching function_response; dropped from model replay', eventId: call.eventId, - detail: { toolCallId }, + detail: { + invocationId: call.item.invocationId, + toolCallId: call.item.toolCallId, + }, }); } diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 11c705ab87..c920e624f8 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -33,8 +33,11 @@ import type { ModelMessage } from './model-protocol.js'; import { admitProviderReasoningReplayItems, buildRuntimeEventModelReplayPlan, + buildRuntimeEventReplayTimeline, compatibleProviderReasoningReplayEventIds, - type RuntimeEventModelReplayItem, + type RuntimeEventReplayTimelineEntry, + type RuntimeEventReplayToolCallItem, + type RuntimeEventReplayToolResultItem, } from './model-history.js'; import { withProviderStreamTracking } from './provider-request-telemetry.js'; import { effectiveReplayToolResultOutput } from './durable-tool-result-projection.js'; @@ -194,56 +197,14 @@ function openAiCodexCompactionMessages( events: readonly RuntimeEvent[], providerReasoningReplayEventIds: ReadonlySet, ): ModelMessage[] { - type ToolCall = Extract; - type ToolResult = Extract; - type Thinking = Extract; - type Text = Extract; - type Step = { - calls: ToolCall[]; - reasoning: Thinking[]; - text?: Text; - }; - type TimelineEntry = - | { kind: 'step'; stepId: string; value: Step } - | { kind: 'legacy_call'; call: ToolCall } - | { kind: 'text'; item: Text } - | { kind: 'thinking'; item: Thinking }; - const items = admitProviderReasoningReplayItems( buildRuntimeEventModelReplayPlan(events).items, providerReasoningReplayEventIds, ); - const results = new Map(); - for (const item of items) { - if (item.kind === 'tool_result') results.set(item.toolCallId, item); - } - - const timeline: TimelineEntry[] = []; - const step = (stepId: string): Step => { - const last = timeline.at(-1); - if (last?.kind === 'step' && last.stepId === stepId) return last.value; - const value = { calls: [], reasoning: [] }; - timeline.push({ kind: 'step', stepId, value }); - return value; - }; - for (const item of items) { - if (item.kind === 'tool_result') continue; - if (item.kind === 'tool_call') { - if (item.stepId) step(item.stepId).calls.push(item); - else timeline.push({ kind: 'legacy_call', call: item }); - continue; - } - if (item.kind === 'thinking') { - if (item.stepId) step(item.stepId).reasoning.push(item); - else timeline.push({ kind: 'thinking', item }); - continue; - } - if (item.role === 'assistant' && item.stepId) step(item.stepId).text = item; - else timeline.push({ kind: 'text', item }); - } + const timeline = buildRuntimeEventReplayTimeline(items); const messages: ModelMessage[] = []; - const pushToolResult = (result: ToolResult) => { + const pushToolResult = (result: RuntimeEventReplayToolResultItem) => { messages.push({ role: 'tool', content: [ @@ -256,7 +217,10 @@ function openAiCodexCompactionMessages( ], }); }; - const toolCallPart = (call: ToolCall, providerExecuted = call.providerExecuted) => ({ + const toolCallPart = ( + call: RuntimeEventReplayToolCallItem, + providerExecuted = call.providerExecuted, + ) => ({ type: 'tool-call' as const, toolCallId: call.toolCallId, toolName: call.toolName, @@ -264,7 +228,9 @@ function openAiCodexCompactionMessages( ...(call.providerOptions ? { providerOptions: call.providerOptions } : {}), ...(providerExecuted !== undefined ? { providerExecuted } : {}), }); - const pushStep = (value: Step) => { + const pushStep = ( + value: Extract, + ) => { const earlyContent: Array> = []; for (const reasoning of value.reasoning) { earlyContent.push({ @@ -274,24 +240,21 @@ function openAiCodexCompactionMessages( }); } const providerCalls = value.calls.filter( - (call) => - call.providerExecuted === true && results.get(call.toolCallId)?.providerExecuted === true, + ({ call, result }) => call.providerExecuted === true && result?.providerExecuted === true, ); // OpenAI's Responses converter omits hosted calls and results when // `store:false`. For a compaction request they are historical evidence, // so lower each settled pair to an ordinary function call/result instead. // This preserves the provider step order without creating a dangling // function_call_output or silently dropping the available tool evidence. - for (const call of providerCalls) { + for (const { call } of providerCalls) { earlyContent.push(toolCallPart(call, false)); } if (providerCalls.length > 0 && earlyContent.length > 0) { messages.push({ role: 'assistant', content: earlyContent } as ModelMessage); } - for (const call of providerCalls) { - const result = results.get(call.toolCallId)!; - results.delete(call.toolCallId); - pushToolResult(result); + for (const { result } of providerCalls) { + pushToolResult(result!); } const lateContent: Array> = providerCalls.length === 0 ? earlyContent : []; @@ -302,26 +265,22 @@ function openAiCodexCompactionMessages( ...(value.text.providerOptions ? { providerOptions: value.text.providerOptions } : {}), }); } - for (const call of value.calls) { + for (const { call } of value.calls) { if (call.providerExecuted !== true) lateContent.push(toolCallPart(call)); } if (lateContent.length > 0) { messages.push({ role: 'assistant', content: lateContent } as ModelMessage); } - for (const call of value.calls) { + for (const { call, result } of value.calls) { if (call.providerExecuted === true) continue; - const result = results.get(call.toolCallId); if (!result || result.providerExecuted === true) continue; - results.delete(call.toolCallId); pushToolResult(result); } }; for (const entry of timeline) { - if (entry.kind === 'step') { - pushStep(entry.value); - } else if (entry.kind === 'legacy_call') { - pushStep({ calls: [entry.call], reasoning: [] }); + if (entry.kind === 'assistant_step') { + pushStep(entry); } else if (entry.kind === 'thinking') { messages.push({ role: 'assistant', diff --git a/packages/runtime/src/provider-request-telemetry.ts b/packages/runtime/src/provider-request-telemetry.ts index 56b184a3f0..476908183d 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -35,6 +35,7 @@ import { import { latestContextProjectionInput } from './latest-context-snapshot.js'; import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; import type { ModelCallCommit } from '@maka/core/agent-run'; +import { computeCost } from './telemetry/cost.js'; export type ProviderRequestCacheValueSource = 'provider' | 'derived'; @@ -152,6 +153,140 @@ export interface ModelCallAccountingInput { assertReady?: () => void; } +export interface ProviderRequestTelemetryInput { + sessionId: string; + connectionSlug?: string; + providerId?: string; + defaultModelId: string; + now: () => number; + newId: () => string; + resolveContextWindow: (modelId: string) => number | undefined; + resolvePricing: (modelId: string) => PricingConfig | null; + recordModelCallAttempt?: (commit: ModelCallCommit) => void | Promise; + assertModelCallAccountingReady?: () => void; + beforeRunProviderDispatch?: (input: { + sessionId: string; + turnId: string; + runId: string; + }) => void | Promise; +} + +export interface CreateProviderRequestTrackerInput { + turnId: string; + callKind: ModelCallKind; + modelId: string; + historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; + runId: string | undefined; +} + +/** Session-scoped construction and pricing for physical provider requests. */ +export class ProviderRequestTelemetry { + constructor(private readonly input: ProviderRequestTelemetryInput) {} + + normalizedUsageCostUsd(usage: { + inputTokens: number; + outputTokens: number; + cacheHitInputTokens: number; + cacheMissInputTokens: number; + cacheWriteInputTokens: number; + }): number | undefined { + const pricing = this.resolvePricing(this.input.defaultModelId); + if (!pricing) return undefined; + try { + return computeCost(usage, pricing).totalCost; + } catch { + return undefined; + } + } + + createTracker(input: CreateProviderRequestTrackerInput): ProviderRequestTracker | undefined { + const accounting = this.accounting(input.callKind, { + modelId: input.modelId, + ...(input.runId ? { runId: input.runId } : {}), + ...(input.historyCompactRoute ? { historyCompactRoute: input.historyCompactRoute } : {}), + }); + const beforeDispatch = + input.runId && this.input.beforeRunProviderDispatch + ? () => + this.input.beforeRunProviderDispatch?.({ + sessionId: this.input.sessionId, + turnId: input.turnId, + runId: input.runId!, + }) + : undefined; + if (!accounting && !beforeDispatch) return undefined; + return new ProviderRequestTracker({ + traceId: this.input.newId(), + turnId: input.turnId, + contextWindow: this.input.resolveContextWindow(input.modelId), + now: this.input.now, + newId: this.input.newId, + ...(beforeDispatch ? { beforeDispatch } : {}), + ...(accounting ? { accounting } : {}), + }); + } + + private accounting( + callKind: ModelCallKind, + identity: { + runId?: string; + modelId?: string; + historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; + }, + ): ModelCallAccountingInput | undefined { + const record = this.input.recordModelCallAttempt; + if (!record) return undefined; + const modelId = identity.modelId ?? this.input.defaultModelId; + return { + sessionId: this.input.sessionId, + resolveRunId: () => identity.runId, + ...(this.input.connectionSlug ? { connectionSlug: this.input.connectionSlug } : {}), + ...(this.input.providerId ? { providerId: this.input.providerId } : {}), + callKind, + ...(identity.historyCompactRoute + ? { historyCompactRoute: identity.historyCompactRoute } + : {}), + record, + resolveCost: (usage) => this.resolveCost(usage, modelId), + ...(this.input.assertModelCallAccountingReady + ? { assertReady: this.input.assertModelCallAccountingReady } + : {}), + }; + } + + private resolveCost( + usage: ProviderRequestUsage, + modelId: string, + ): ResolvedModelCallCost | undefined { + const pricing = this.resolvePricing(modelId); + if (!pricing) return undefined; + try { + const costUsd = computeCost( + { + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + cacheHitInputTokens: usage.cacheReadInputTokens ?? 0, + cacheMissInputTokens: usage.cacheMissInputTokens ?? 0, + cacheWriteInputTokens: usage.cacheWriteInputTokens ?? 0, + }, + pricing, + ).totalCost; + if (costUsd === undefined || !Number.isFinite(costUsd)) return undefined; + return { costUsd, pricingRates: pricing }; + } catch { + return undefined; + } + } + + private resolvePricing(modelId: string): PricingConfig | undefined { + try { + return this.input.resolvePricing(modelId) ?? undefined; + } catch { + return undefined; + } + } +} + export interface TrackProviderStreamInput { providerId: string; modelId: string; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 80fa08b312..d18cce8d65 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -1392,7 +1392,6 @@ export class RuntimeKernel implements RuntimeKernelLike { orchestration: run.effectiveOrchestration, toolMode: run.toolMode, text: '', - context: [], runtimeContext: continuation.runtimeContext, runtimeContextInvocations: admissionRoute.invocations, continuation: continuationMetadata, diff --git a/packages/runtime/src/session-recap.ts b/packages/runtime/src/session-recap.ts index 4c8549cb40..87e34bef6d 100644 --- a/packages/runtime/src/session-recap.ts +++ b/packages/runtime/src/session-recap.ts @@ -17,17 +17,18 @@ * under the License. */ -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { runtimeEventHasModelVisibleContent, type RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; import { stableJsonLength } from './context-budget-helpers.js'; import { groupEventsByTurn } from './model-history.js'; import { HistoryCompactSummarizerError } from './history-compact-error.js'; import { fitHistoryCompactMessages } from './history-compact-input-fit.js'; -import { replayPlanItemsToModelMessages } from './history-compact-summarizer.js'; -import { buildRuntimeEventModelReplayPlan } from './model-history.js'; import type { ModelMessage } from './model-protocol.js'; +const SESSION_RECAP_TOOL_OUTCOME_MAX_CHARS = 600; + export const SESSION_RECAP_INSTRUCTION = 'The user is returning to this session after being away. Write ONE sentence (roughly 25-40 words) recapping where things stand so they can resume instantly. Write the sentence in the language of the user\'s most recent substantive message; for mixed-language sessions use the dominant language of the user\'s messages. Lead with agency, phrased naturally in that language: if the session was mainly questions or review with no landed change, open by referencing what the user asked (the equivalent of "You asked ..."); if the agent landed changes, reference what was done (the equivalent of "We fixed/added/wired ..."); if almost nothing happened, say in that language that the session had just begun. Output only the sentence - no labels, no quotes, no preamble.'; @@ -37,13 +38,14 @@ export function buildSessionRecapMessages(input: { readonly modelId: string; }): ModelMessage[] { const contextWindow = resolveSelectedModelContextWindow(input.connection, input.modelId); - let events = input.events; let maxEstimatedTokens: number | undefined; + let messages: ModelMessage[]; if (contextWindow !== undefined) { maxEstimatedTokens = Math.max(0, Math.floor(contextWindow * 0.85) - 4_096); - events = recentTurnsWithinBudget(events, maxEstimatedTokens); + messages = recentRecapMessagesWithinBudget(input.events, maxEstimatedTokens); + } else { + messages = projectSessionRecapMessages(input.events); } - let messages = replayPlanItemsToModelMessages(buildRuntimeEventModelReplayPlan(events).items); if ( messages.length === 0 && input.events.length > 0 && @@ -58,21 +60,25 @@ export function buildSessionRecapMessages(input: { } /** Request-only recap projection; never mutates or replaces canonical history. */ -function recentTurnsWithinBudget( +function recentRecapMessagesWithinBudget( events: readonly RuntimeEvent[], maxEstimatedTokens: number, charsPerToken = 4, -): RuntimeEvent[] { +): ModelMessage[] { const groups = groupEventsByTurn(events, charsPerToken); - const selected: RuntimeEvent[][] = []; - let selectedTokens = 0; + const selectedGroups: ModelMessage[][] = []; + let selectedChars = 2; for (let index = groups.length - 1; index >= 0; index -= 1) { - const group = groups[index]!; - if (selectedTokens + group.estimatedTokens > maxEstimatedTokens) break; - selected.unshift(group.events); - selectedTokens += group.estimatedTokens; + const projected = projectSessionRecapMessages(groups[index]!.events); + if (projected.length === 0) continue; + const projectedChars = stableJsonLength(projected); + const candidateChars = + selectedGroups.length === 0 ? projectedChars : projectedChars + selectedChars - 1; + if (candidateChars > maxEstimatedTokens * charsPerToken) break; + selectedGroups.push(projected); + selectedChars = candidateChars; } - return selected.flat(); + return selectedGroups.reverse().flat(); } function boundedOversizedTurnMessages( @@ -80,7 +86,7 @@ function boundedOversizedTurnMessages( maxEstimatedTokens: number, charsPerToken = 4, ): ModelMessage[] { - const messages = replayPlanItemsToModelMessages(buildRuntimeEventModelReplayPlan(events).items); + const messages = projectSessionRecapMessages(events); try { return fitHistoryCompactMessages(messages, { maxInputEstimatedTokens: maxEstimatedTokens, @@ -108,6 +114,73 @@ function boundedOversizedTurnMessages( return []; } +function projectSessionRecapMessages(events: readonly RuntimeEvent[]): ModelMessage[] { + const messages: ModelMessage[] = []; + for (const event of events) { + if (event.partial === true || !runtimeEventHasModelVisibleContent(event)) continue; + const content = event.content; + if (content?.kind === 'text' && (event.role === 'user' || event.role === 'model')) { + const text = content.text.trim(); + if (text.length > 0) { + messages.push({ role: event.role === 'user' ? 'user' : 'assistant', content: text }); + } + continue; + } + if (content?.kind !== 'function_response') continue; + const status = recapToolOutcomeStatus(content.isError === true, content.modelProjection); + const detail = recapToolOutcomeDetail(content.modelProjection); + messages.push({ + role: 'assistant', + content: `Tool outcome (${content.name}, ${status})${detail ? `: ${detail}` : '.'}`, + }); + } + return messages; +} + +function recapToolOutcomeStatus( + isError: boolean, + projection: DurableToolResultProjection | undefined, +): 'succeeded' | 'failed' | 'denied' { + if (projection?.kind === 'execution_denied') return 'denied'; + if ( + isError || + projection?.kind === 'failure' || + ((projection?.kind === 'text' || projection?.kind === 'json') && projection.isError === true) + ) { + return 'failed'; + } + return 'succeeded'; +} + +function recapToolOutcomeDetail(projection: DurableToolResultProjection | undefined): string { + if (!projection) return ''; + let detail: string; + switch (projection.kind) { + case 'text': + detail = projection.text; + break; + case 'json': + detail = JSON.stringify(projection.value); + break; + case 'content': + detail = projection.parts + .map((part) => + part.kind === 'text' + ? part.text + : `[stored artifact: ${part.ref.kind === 'session_context' ? part.ref.refId : part.ref.relativePath}]`, + ) + .join('\n'); + break; + case 'execution_denied': + detail = projection.reason ?? ''; + break; + case 'failure': + detail = projection.message; + break; + } + return boundedText(detail.trim(), SESSION_RECAP_TOOL_OUTCOME_MAX_CHARS); +} + function boundedTextMessage( role: 'user' | 'assistant', text: string,