diff --git a/packages/core/src/execution-inspect.ts b/packages/core/src/execution-inspect.ts index 1a5303454f..5022e1b23e 100644 --- a/packages/core/src/execution-inspect.ts +++ b/packages/core/src/execution-inspect.ts @@ -70,7 +70,8 @@ export interface AgentRunInspectToolSummary { export interface AgentRunInspectCompactionCheckpoint { eventId: string; - validation: 'shape_valid' | 'invalid'; + /** `superseded`: well-formed, but minted under an older source policy. */ + validation: 'shape_valid' | 'invalid' | 'superseded'; checkpointId?: string; policyVersion?: string; sourceCoverage?: ExecutionLogCoverage; @@ -297,7 +298,9 @@ function isCompactionCheckpoint(value: unknown): boolean { ['checkpointId', 'policyVersion', 'sourceCoverage'], ) && typeof value.eventId === 'string' && - (value.validation === 'shape_valid' || value.validation === 'invalid') && + (value.validation === 'shape_valid' || + value.validation === 'invalid' || + value.validation === 'superseded') && isOptionalString(value.checkpointId) && isOptionalString(value.policyVersion) && (value.sourceCoverage === undefined || isCoverage(value.sourceCoverage)) diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index c360aea827..25389b61dc 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -22,7 +22,7 @@ import { createHash } from 'node:crypto'; import { test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { applyRuntimeEventContextBudget } from '../context-budget.js'; -import { estimateRuntimeEventsTokens } from '../context-budget-helpers.js'; +import { estimateRuntimeEventsTokens } from '../model-history.js'; import { buildHistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; test('estimates only model-visible provider context', () => { diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index cf7ccbb3a4..e19d1b9a1f 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -2402,6 +2402,119 @@ test('conversation copy rebuilds an inline checkpoint without legacy child event } }); +test('conversation copy drops a checkpoint from a superseded source policy instead of failing', async () => { + // A ledger keeps every checkpoint it ever recorded, so a session that + // compacted under an older source policy still carries that record forever. + // Copy must treat it as absent — the copy carries the canonical raw + // RuntimeEvents and can compact again — or those sessions become permanently + // unbranchable (apache/maka#4283). + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-legacy-policy-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const run = agentRunHeader({ + runId: 'run-source', + invocationId: 'invocation-1', + turnId: 'turn-1', + cwd: root, + completedAt: 3, + }); + await runStore.createRun(run); + const sourceEvents = [ + runtimeEvent({ + id: 'event-user', + invocationId: 'invocation-1', + runId: 'run-source', + turnId: 'turn-1', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first' }, + }), + runtimeEvent({ + id: 'event-terminal', + invocationId: 'invocation-1', + runId: 'run-source', + turnId: 'turn-1', + ts: 2, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + for (const event of sourceEvents) { + await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } + const current = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), + summary: 'Everything so far is complete.', + summaryFormat: 'legacy_freeform', + highWaterSeq: 5, + }); + const legacyPolicyCheckpoint = { + ...current, + source: { + ...current.source, + policyVersion: 'maka.compactable_runtime_event_projection.v1', + }, + }; + await runStore.appendEvent('session-source', 'run-source', { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-legacy-policy', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.5, + data: { + checkpointId: legacyPolicyCheckpoint.checkpointId, + highWaterName: legacyPolicyCheckpoint.highWaterName, + highWaterSeq: legacyPolicyCheckpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint: legacyPolicyCheckpoint, + }, + }); + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + let sequence = 0; + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + const targetRuns = await runStore.listSessionRuns('session-target'); + assert.ok(targetRuns.length > 0); + const targetOperationalEvents = ( + await Promise.all(targetRuns.map((run) => runStore.readEvents('session-target', run.runId))) + ).flat(); + assert.equal( + targetOperationalEvents.some((event) => event.type === 'history_compact_checkpoint_recorded'), + false, + ); + const targetEvents = ( + await Promise.all( + targetRuns.map((run) => runtimeEventStore.readRuntimeEvents('session-target', run.runId)), + ) + ).flat(); + assert.equal(targetEvents.length, sourceEvents.length); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('conversation copy rebuilds a resumed child checkpoint over its child run chain', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-child-checkpoint-copy-')); try { diff --git a/packages/runtime/src/__tests__/effective-history-compaction.test.ts b/packages/runtime/src/__tests__/effective-history-compaction.test.ts new file mode 100644 index 0000000000..3c09d826d1 --- /dev/null +++ b/packages/runtime/src/__tests__/effective-history-compaction.test.ts @@ -0,0 +1,195 @@ +/* + * 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. + */ + +/** + * Budgeting, summarization, and checkpoint source digests must all read the + * EFFECTIVE model history — the durable Tool Result projection committed at T2 + * — and never the raw execution fact it replaced (apache/maka#4283, PR 2). + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import { estimateRuntimeEventsTokens } from '../model-history.js'; +import { + buildHistoryCompactCheckpoint, + matchHistoryCompactCheckpointPrefix, + validateHistoryCompactCheckpointShape, +} from '../history-compact-checkpoint.js'; +import { + buildLlmHistorySummarizer, + type AiSdkGenerateTextLike, +} from '../history-compact-summarizer.js'; + +const RAW_SECRET = 'RAW-EXECUTION-EVIDENCE-'.repeat(200); +const PROJECTED = 'bounded model-visible result'; + +const STRUCTURED_SUMMARY = [ + '## Goal', + 'X', + '', + '## Progress', + '- done', + '', + '## Next Steps', + '1. continue', + '', + '## Critical Context', + '- (none)', +].join('\n'); + +describe('effective model history feeds budgeting and compaction', () => { + test('budgeting sizes the durable projection, not the raw execution fact', () => { + const projected = toolResultEvent('evt-3', RAW_SECRET, textProjection(PROJECTED)); + const raw = toolResultEvent('evt-3', RAW_SECRET); + + // Same raw result, but only the un-projected legacy event is sized by it. + assert.equal(estimateRuntimeEventsTokens([projected], 1), 'Bash'.length + PROJECTED.length); + assert.ok(estimateRuntimeEventsTokens([raw], 1) > RAW_SECRET.length); + }); + + test('summarization cannot read raw output the projection replaced', async () => { + let seen: Parameters[0] | undefined; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async (options) => { + seen = options; + return { text: STRUCTURED_SUMMARY }; + }, + }); + + await summarize({ + sessionId: 'session-1', + turnId: 'turn-1', + source: { + foldedRuntimeEvents: [ + userEvent('evt-1', 'run it'), + toolCallEvent('evt-2'), + toolResultEvent('evt-3', RAW_SECRET, textProjection(PROJECTED)), + ], + }, + }); + + const serialized = JSON.stringify(seen?.messages ?? []); + assert.ok(serialized.includes(PROJECTED)); + assert.equal(serialized.includes('RAW-EXECUTION-EVIDENCE-'), false); + }); + + test('the source digest ignores raw evidence but tracks the effective projection', () => { + const covered = [ + userEvent('evt-1', 'run it'), + toolCallEvent('evt-2'), + toolResultEvent('evt-3', RAW_SECRET, textProjection(PROJECTED)), + ]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + summary: STRUCTURED_SUMMARY, + charsPerToken: 1, + }); + + // Raw evidence rewritten under an unchanged projection: still the same + // folded model history, so the checkpoint keeps replaying. + const rawRewritten = [ + ...covered.slice(0, 2), + toolResultEvent('evt-3', 'a different raw fact', textProjection(PROJECTED)), + ]; + assert.equal(matchHistoryCompactCheckpointPrefix(checkpoint, rawRewritten).reason, undefined); + + // The projection itself replaced: the folded content is no longer what + // this checkpoint covered, so it must not replay over it. + const projectionReplaced = [ + ...covered.slice(0, 2), + toolResultEvent('evt-3', RAW_SECRET, textProjection('[archived]')), + ]; + assert.equal( + matchHistoryCompactCheckpointPrefix(checkpoint, projectionReplaced).reason, + 'source_hash_mismatch', + ); + }); + + test('a checkpoint minted under the raw-source policy no longer validates', () => { + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [userEvent('evt-1', 'run it')], + summary: STRUCTURED_SUMMARY, + charsPerToken: 1, + }); + const legacy = { + ...checkpoint, + source: { + ...checkpoint.source, + policyVersion: 'maka.compactable_runtime_event_projection.v1', + }, + } as unknown as typeof checkpoint; + + assert.equal(validateHistoryCompactCheckpointShape(checkpoint, 'session-1'), true); + assert.equal(validateHistoryCompactCheckpointShape(legacy, 'session-1'), false); + }); +}); + +function textProjection(text: string): DurableToolResultProjection { + return { version: 1, kind: 'text', text }; +} + +function userEvent(id: string, text: string): RuntimeEvent { + return { + id, + invocationId: 'invocation-1', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + status: 'completed', + modelVisibility: 'visible', + content: { kind: 'text', text }, + }; +} + +function toolCallEvent(id: string): RuntimeEvent { + return { + ...userEvent(id, ''), + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-call', name: 'Bash', args: {} }, + }; +} + +function toolResultEvent( + id: string, + result: string, + modelProjection?: DurableToolResultProjection, +): RuntimeEvent { + return { + ...userEvent(id, ''), + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-call', + name: 'Bash', + result, + ...(modelProjection ? { modelProjection } : {}), + }, + }; +} diff --git a/packages/runtime/src/context-budget-helpers.ts b/packages/runtime/src/context-budget-helpers.ts index 920114f6ea..b66e78aa1b 100644 --- a/packages/runtime/src/context-budget-helpers.ts +++ b/packages/runtime/src/context-budget-helpers.ts @@ -26,7 +26,10 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; * modules can reuse them without a reverse import into `context-budget.ts`. * * These are intentionally dependency-free (only `node:crypto` and the - * `RuntimeEvent` type); no domain policy types live here. + * `RuntimeEvent` type); no domain policy types live here. Sizing a + * RuntimeEvent is NOT such a helper — it must read the effective model + * projection — so `estimateRuntimeEventChars`/`estimateRuntimeEventsTokens`/ + * `groupEventsByTurn` live with the reducer in `model-history.ts`. */ export function estimateTokens(chars: number, charsPerToken = 4): number { @@ -43,66 +46,10 @@ export function stableJsonLength(value: unknown): number { } } -export function estimateRuntimeEventChars(event: RuntimeEvent): number { - let total = 0; - const content = event.content; - if (content?.kind === 'text' || content?.kind === 'thinking') total += content.text.length; - else if (content?.kind === 'function_call') - total += content.name.length + stableJsonLength(content.args); - else if (content?.kind === 'function_response') - total += - content.name.length + - stableJsonLength( - content.providerExecuted && content.providerOutput !== undefined - ? content.providerOutput - : content.result, - ); - else if (content?.kind === 'error') total += content.message.length; - return total; -} - -export function estimateRuntimeEventsTokens( - events: readonly RuntimeEvent[], - charsPerToken = 4, -): number { - const chars = events.reduce( - (total, event) => - event.modelVisibility === 'hidden' ? total : total + estimateRuntimeEventChars(event), - 0, - ); - return estimateTokens(chars, charsPerToken); -} - export function turnKey(event: RuntimeEvent): string { return event.turnId || ''; } -export function groupEventsByTurn( - events: readonly RuntimeEvent[], - charsPerToken: number, -): Array<{ - turnId: string; - estimatedTokens: number; - events: RuntimeEvent[]; -}> { - const order: string[] = []; - const byTurn = new Map(); - for (const event of events) { - const key = turnKey(event); - const group = byTurn.get(key); - if (group) group.push(event); - else { - order.push(key); - byTurn.set(key, [event]); - } - } - return order.map((turnId) => ({ - turnId, - events: byTurn.get(turnId) ?? [], - estimatedTokens: estimateRuntimeEventsTokens(byTurn.get(turnId) ?? [], charsPerToken), - })); -} - export function uniqueSorted(values: readonly string[]): string[] { return [...new Set(values.filter((value) => value.length > 0))].sort(); } diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index a38ad111c0..2e133ea7f6 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -17,15 +17,13 @@ * under the License. */ -import { - estimateTokens, - estimateRuntimeEventsTokens, - stableJsonLength, -} from './context-budget-helpers.js'; +import { estimateTokens, stableJsonLength } from './context-budget-helpers.js'; +import { estimateRuntimeEventsTokens } from './model-history.js'; // Public re-export surface for @maka/runtime consumers. Explicit list keeps // the ./context-budget subpath from leaking leaf-internal collaboration symbols. -export { estimateRuntimeEventsTokens, estimateTokens } from './context-budget-helpers.js'; +export { estimateTokens } from './context-budget-helpers.js'; +export { estimateRuntimeEventsTokens } from './model-history.js'; export { ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, ARCHIVED_TOOL_RESULT_REWRITE_VERSION, diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 41d3c72618..cf2ab9d01b 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -738,17 +738,18 @@ function cloneAgentRunEvent( ); } else if (event.type === 'history_compact_checkpoint_recorded') { const sourceCheckpoint = event.data?.checkpoint; - if (!validateHistoryCompactCheckpointShape(sourceCheckpoint, event.sessionId)) { - throw new Error(`Cannot copy invalid history compact checkpoint ${event.id}`); - } // Conversation copies carry the canonical raw RuntimeEvents and can create - // a fresh checkpoint on demand. Do not export opaque provider state into a - // new session or degrade it into user-visible placeholder text. + // a fresh checkpoint on demand, so a checkpoint this Runtime can no longer + // hold to its own contract is DROPPED, never fatal: the copy is complete + // without it. That covers opaque provider state (do not export it into a + // new session or degrade it into user-visible placeholder text), a + // superseded source policy, and a prefix that no longer matches. A ledger + // keeps every checkpoint it ever recorded, so a session that compacted + // under an older policy would otherwise be permanently uncopyable. + if (!validateHistoryCompactCheckpointShape(sourceCheckpoint, event.sessionId)) return null; if (sourceCheckpoint.version === 3) return null; const match = matchHistoryCompactCheckpointPrefix(sourceCheckpoint, sourceCompactableEvents); - if (match.reason) { - throw new Error(`Cannot copy unmatched history compact checkpoint ${event.id}`); - } + if (match.reason) return null; // Copy is an admission seam for the sectioned summary contract: a marked // checkpoint whose summary no longer satisfies the COMPLETE predicate — // including the size floor, re-runnable here because the matched covered diff --git a/packages/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts index f81e4d5f82..ad58bbebd1 100644 --- a/packages/runtime/src/durable-tool-result-projection.ts +++ b/packages/runtime/src/durable-tool-result-projection.ts @@ -163,6 +163,24 @@ export function durableProjectionToToolResultOutput( } } +/** + * The one pure decision of WHICH source a replayed Tool Result materializes + * from: a durable projection wins, and only a response that has none (legacy + * or provider-native) falls back to its raw output. Replay, summarization, and + * provider-native compaction share it so no second path can re-read what the + * projection removed. The image-materializing replay path in the ai-sdk + * backend applies the same choice under a request budget. + */ +export function effectiveReplayToolResultOutput(item: { + modelProjection?: DurableToolResultProjection; + output: unknown; + isError: boolean; +}): ToolResultOutput { + return item.modelProjection + ? durableProjectionToToolResultOutput(item.modelProjection) + : toolResultOutput(item.output, item.isError); +} + export function rewriteDurableToolResultProjectionArtifactRefs( projection: DurableToolResultProjection, rewrite: (ref: DurableProjectionArtifactRef) => DurableProjectionArtifactRef, diff --git a/packages/runtime/src/execution-inspect.ts b/packages/runtime/src/execution-inspect.ts index 63b794928c..5647c313e5 100644 --- a/packages/runtime/src/execution-inspect.ts +++ b/packages/runtime/src/execution-inspect.ts @@ -42,6 +42,7 @@ import { type SessionAgentRunInspectReader, } from './agent-run-inspect.js'; import { + isSupersededHistoryCompactCheckpoint, validateHistoryCompactCheckpointShape, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; @@ -287,16 +288,26 @@ function inspectCompactionCheckpoints( if (event.type !== 'history_compact_checkpoint_recorded') continue; const checkpoint = event.data?.checkpoint; if (!validateHistoryCompactCheckpointShape(checkpoint, header.sessionId)) { + // A checkpoint recorded under an older source policy is expected history, + // not corruption: the ledger keeps every checkpoint it ever wrote, and + // every consumer fails open on it. Reporting it as an error would drown + // out the records that really are damaged. + const superseded = isSupersededHistoryCompactCheckpoint(checkpoint); diagnostics.push( diagnostic( header, - 'compaction_checkpoint_invalid', - 'error', - 'AgentRun contains an invalid durable Compaction checkpoint record.', + superseded ? 'compaction_checkpoint_superseded' : 'compaction_checkpoint_invalid', + superseded ? 'info' : 'error', + superseded + ? 'AgentRun contains a durable Compaction checkpoint from a superseded source policy; it is ignored and re-created on demand.' + : 'AgentRun contains an invalid durable Compaction checkpoint record.', event.id, ), ); - checkpoints.push({ eventId: event.id, validation: 'invalid' }); + checkpoints.push({ + eventId: event.id, + validation: superseded ? 'superseded' : 'invalid', + }); continue; } const valid = checkpoint as HistoryCompactCheckpoint; diff --git a/packages/runtime/src/history-compact-checkpoint.ts b/packages/runtime/src/history-compact-checkpoint.ts index d47463bfb3..0234f278d0 100644 --- a/packages/runtime/src/history-compact-checkpoint.ts +++ b/packages/runtime/src/history-compact-checkpoint.ts @@ -29,8 +29,13 @@ import { type SectionedSummaryFormat, } from './history-compact-summary-validation.js'; +// v2: coverage and source digest are taken over EFFECTIVE model history (the +// durable Tool Result projection), not raw RuntimeEvent evidence. A v1 +// checkpoint's digest was computed over a different source, so it fails the +// shape check and its session re-summarizes rather than replaying a +// coverage claim this policy never made. export const HISTORY_COMPACT_SOURCE_POLICY_VERSION = - 'maka.compactable_runtime_event_projection.v1' as const; + 'maka.compactable_runtime_event_projection.v2' as const; export interface HistoryCompactCheckpointSource { schemaVersion: 1; kind: 'runtime_event_projection'; @@ -505,6 +510,23 @@ export function validateHistoryCompactCheckpointShape( } /** Accept forward progress, or a compare-and-swap rewrite of the exact same source coverage. */ +/** + * A recorded checkpoint that this Runtime no longer holds to its own contract + * because it was minted under an older source policy — not a corrupt record. + * Every consumer already fails open on it (compaction re-summarizes, copy + * drops it); diagnostics use this to say so instead of crying corruption. + */ +export function isSupersededHistoryCompactCheckpoint(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const checkpoint = value as Partial; + const policyVersion = checkpoint.source?.policyVersion as unknown; + return ( + checkpoint.kind === 'maka.history_compact_checkpoint' && + typeof policyVersion === 'string' && + policyVersion !== HISTORY_COMPACT_SOURCE_POLICY_VERSION + ); +} + export function canReplaceHistoryCompactCheckpoint( current: HistoryCompactCheckpoint | undefined, candidate: HistoryCompactCheckpoint, @@ -725,10 +747,18 @@ function sameHistoryCompactSourceCoverage( ); } +/** + * A checkpoint replaces a contiguous prefix of EFFECTIVE model history, so its + * source digest must pin exactly that: raw execution evidence a durable + * projection already bounded or redacted is not part of what was folded, and + * changing it must not invalidate a valid checkpoint. Conversely, replacing a + * response's effective projection does change the digest, so a checkpoint can + * never be replayed over content it never covered. + */ function historyCompactSourceDigest(events: readonly RuntimeEvent[]): string { const hash = createHash('sha256'); for (const event of events) { - const serialized = stableStringify(event); + const serialized = stableStringify(effectiveDigestEvent(event)); hash.update(String(Buffer.byteLength(serialized, 'utf8'))); hash.update(':'); hash.update(serialized); @@ -737,6 +767,28 @@ function historyCompactSourceDigest(events: readonly RuntimeEvent[]): string { return `sha256:${hash.digest('hex')}`; } +/** + * Selects the persisted field that carries a response's model-visible content, + * dropping the others. This is a structural choice over durable data only — + * never a code-derived materialization — so the digest of an already-persisted + * checkpoint cannot drift when a projector, a codec bound, or a placeholder + * string changes. A response with no durable projection (legacy, or + * provider-native) keeps the raw field its effective content is still derived + * from, which is what the pre-projection digest already hashed. + */ +function effectiveDigestEvent(event: RuntimeEvent): unknown { + const content = event.content; + if (content?.kind !== 'function_response') return event; + const { result, providerOutput, modelProjection, ...identity } = content; + if (content.providerExecuted && providerOutput !== undefined) { + return { ...event, content: { ...identity, providerOutput } }; + } + if (modelProjection !== undefined) { + return { ...event, content: { ...identity, modelProjection } }; + } + return { ...event, content: { ...identity, result } }; +} + function sha256(value: string): string { return createHash('sha256').update(value).digest('hex'); } diff --git a/packages/runtime/src/history-compact-summarizer.ts b/packages/runtime/src/history-compact-summarizer.ts index f952b600df..bcfad2552c 100644 --- a/packages/runtime/src/history-compact-summarizer.ts +++ b/packages/runtime/src/history-compact-summarizer.ts @@ -23,7 +23,7 @@ import { findCheckpointSummaryDefect, SUMMARY_FORMAT_TEMPLATE, } from './history-compact-summary-validation.js'; -import { toolResultOutput } from './tool-result-output.js'; +import { effectiveReplayToolResultOutput } from './durable-tool-result-projection.js'; import type { HistoryCompactSummaryInput } from './ai-sdk-compaction-contract.js'; import { HistoryCompactSummarizerError, @@ -297,7 +297,7 @@ export function replayPlanItemsToModelMessages(items: ReplayPlanItems): ModelMes type: 'tool-result', toolCallId: item.toolCallId, toolName: item.toolName, - output: toolResultOutput(item.output, item.isError), + output: effectiveReplayToolResultOutput(item), }, ], }; diff --git a/packages/runtime/src/history-compact-summary-validation.ts b/packages/runtime/src/history-compact-summary-validation.ts index 6ed2f5da25..eec8172a5a 100644 --- a/packages/runtime/src/history-compact-summary-validation.ts +++ b/packages/runtime/src/history-compact-summary-validation.ts @@ -19,7 +19,8 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ContextBudgetExhaustedDetail } from '@maka/core/events'; -import { estimateRuntimeEventsTokens, estimateTokens } from './context-budget-helpers.js'; +import { estimateTokens } from './context-budget-helpers.js'; +import { estimateRuntimeEventsTokens } from './model-history.js'; // The single authority on what a history-compact checkpoint summary must look // like (#3029). The summarization prompt is built from the same constants and diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index ee50498ee5..1ee5fc1fb0 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -19,11 +19,8 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; -import { - estimateRuntimeEventChars, - estimateRuntimeEventsTokens, - finitePositive, -} from './context-budget-helpers.js'; +import { finitePositive } from './context-budget-helpers.js'; +import { estimateRuntimeEventChars, estimateRuntimeEventsTokens } from './model-history.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import { HistoryCompactSummarizerError, diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 1a74cde6c6..9d1be51abf 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -63,12 +63,124 @@ import { } from '@maka/core/runtime-event'; import { formatAttachmentResourceRef } from '@maka/core/attachments'; import type { AttachmentRef, QuoteRef } from '@maka/core/events'; -import type { ModelMessage, UserContent, UserModelMessage } from './model-protocol.js'; -import { decodeEffectiveToolResultProjection } from './durable-tool-result-projection.js'; +import type { + ModelMessage, + ToolResultOutput, + UserContent, + UserModelMessage, +} from './model-protocol.js'; +import { + decodeEffectiveToolResultProjection, + durableProjectionToToolResultOutput, +} from './durable-tool-result-projection.js'; +import { estimateTokens, stableJsonLength, turnKey } from './context-budget-helpers.js'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; export const PROVIDER_REPLAY_PROJECTION_VERSION = 1; +// ============================================================================ +// Effective model-history sizing +// ============================================================================ + +/** + * Model-visible character count of a `function_response`'s EFFECTIVE value — + * the durable projection when the response has one, never the raw execution + * fact the projection already bounded or redacted. + * + * Memoized on the content object because a legacy response (no durable + * projection) is re-derived through the whole compatibility codec, and one + * request measures the same events several times: budget verdicts, the + * compactable-content filter, and checkpoint prefix matching all walk the + * history. Content is immutable once committed, so identity is a sound key. + */ +const effectiveToolResultChars = new WeakMap(); + +/** Model-visible character count of one materialized Tool Result output. */ +function estimateToolResultOutputChars(output: ToolResultOutput): number { + switch (output.type) { + case 'text': + case 'error-text': + return output.value.length; + case 'json': + case 'error-json': + return stableJsonLength(output.value); + case 'content': + return output.value.reduce( + (total, part) => total + (part.type === 'text' ? part.text.length : 0), + 0, + ); + case 'execution-denied': + return output.reason?.length ?? 0; + } +} + +export function estimateEffectiveToolResultChars( + content: Extract, + sessionId: string, +): number { + const memoized = effectiveToolResultChars.get(content); + if (memoized !== undefined) return memoized; + const effective = decodeEffectiveToolResultProjection(content, sessionId); + const chars = + effective.kind === 'projection' + ? estimateToolResultOutputChars(durableProjectionToToolResultOutput(effective.projection)) + : effective.kind === 'invalid_legacy' + ? effective.message.length + : stableJsonLength(effective.output); + effectiveToolResultChars.set(content, chars); + return chars; +} + +export function estimateRuntimeEventChars(event: RuntimeEvent): number { + let total = 0; + const content = event.content; + if (content?.kind === 'text' || content?.kind === 'thinking') total += content.text.length; + else if (content?.kind === 'function_call') + total += content.name.length + stableJsonLength(content.args); + else if (content?.kind === 'function_response') + total += content.name.length + estimateEffectiveToolResultChars(content, event.sessionId); + else if (content?.kind === 'error') total += content.message.length; + return total; +} + +export function estimateRuntimeEventsTokens( + events: readonly RuntimeEvent[], + charsPerToken = 4, +): number { + const chars = events.reduce( + (total, event) => + event.modelVisibility === 'hidden' ? total : total + estimateRuntimeEventChars(event), + 0, + ); + return estimateTokens(chars, charsPerToken); +} + +export function groupEventsByTurn( + events: readonly RuntimeEvent[], + charsPerToken: number, +): Array<{ + turnId: string; + estimatedTokens: number; + events: RuntimeEvent[]; +}> { + const order: string[] = []; + const byTurn = new Map(); + for (const event of events) { + const key = turnKey(event); + const group = byTurn.get(key); + if (group) group.push(event); + else { + order.push(key); + byTurn.set(key, [event]); + } + } + return order.map((turnId) => ({ + turnId, + events: byTurn.get(turnId) ?? [], + estimatedTokens: estimateRuntimeEventsTokens(byTurn.get(turnId) ?? [], charsPerToken), + })); +} + // ============================================================================ // Output type // ============================================================================ diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index b147a24fb0..9d9e843dec 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -35,7 +35,7 @@ import { type RuntimeEventModelReplayItem, } from './model-history.js'; import { withProviderStreamTracking } from './provider-request-telemetry.js'; -import { toolResultOutput } from './tool-result-output.js'; +import { effectiveReplayToolResultOutput } from './durable-tool-result-projection.js'; import { providerFailureDiagnostic } from './provider-error-classification.js'; export { fitHistoryCompactMessages as fitOpenAiCodexCompactionMessages } from './history-compact-input-fit.js'; @@ -232,7 +232,7 @@ export function openAiCodexCompactionMessages(events: readonly RuntimeEvent[]): type: 'tool-result', toolCallId: result.toolCallId, toolName: result.toolName, - output: toolResultOutput(result.output, result.isError), + output: effectiveReplayToolResultOutput(result), }, ], }); diff --git a/packages/runtime/src/session-recap.ts b/packages/runtime/src/session-recap.ts index 1333fbf159..4c8549cb40 100644 --- a/packages/runtime/src/session-recap.ts +++ b/packages/runtime/src/session-recap.ts @@ -20,7 +20,8 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; -import { groupEventsByTurn, stableJsonLength } from './context-budget-helpers.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';