From f9c1b6e54ea97f2c4e3859cd33a028664bb13786 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 13 Aug 2026 11:15:28 +0800 Subject: [PATCH 1/3] feat(context): say what the context is made of, at the owner that already answers it (#2323) --- .../main/__tests__/use-session-trace.test.ts | 9 + .../main/runtime-host-renderer-ipc-main.ts | 2 + apps/desktop/src/preload/bridge-contract.d.ts | 3 + apps/desktop/src/preload/preload.ts | 15 + .../runtime-host-renderer-operations.ts | 4 + .../src/renderer/locales/conversation-copy.ts | 54 ++ .../session-inspector-overview-model.ts | 200 ++++++- .../src/renderer/session-inspector-panel.tsx | 118 +++- .../src/renderer/styles/chat-detail.css | 23 + .../desktop/src/renderer/use-session-trace.ts | 53 +- .../stories/session-workbar.stories.tsx | 112 +++- .../context-diagnostics-render.test.ts | 57 ++ packages/cli/src/pi-tui-runner.ts | 46 +- .../cli/src/runtime-host-session-driver.ts | 32 +- packages/core/src/agent-run.ts | 100 +++- .../src/__tests__/context-protocol.test.ts | 13 +- packages/runtime-host/src/protocol/context.ts | 120 +++- .../src/server/execution-model-composition.ts | 10 +- .../src/__tests__/context-diagnostics.test.ts | 519 +++++++++++++++--- .../history-compact-summarizer.test.ts | 3 +- .../__tests__/latest-context-commit.test.ts | 164 ++++++ .../mid-turn-capacity-backend.test.ts | 55 +- .../overflow-reactive-recovery.test.ts | 181 +++++- .../src/__tests__/prompt-composition.test.ts | 204 +++++++ .../provider-request-telemetry.test.ts | 33 +- .../src/__tests__/request-shape.test.ts | 23 + .../src/__tests__/session-manager.test.ts | 2 +- packages/runtime/src/agent-run.ts | 10 +- packages/runtime/src/ai-sdk-backend.ts | 68 ++- packages/runtime/src/context-budget.ts | 11 + packages/runtime/src/context-diagnostics.ts | 435 +++++++++++---- .../runtime/src/latest-context-snapshot.ts | 126 +++++ packages/runtime/src/model-adapter.ts | 12 + packages/runtime/src/prompt-composition.ts | 175 ++++++ .../runtime/src/provider-request-telemetry.ts | 39 +- packages/runtime/src/request-shape.ts | 26 +- packages/runtime/src/runtime-kernel.ts | 6 +- packages/runtime/src/session-manager.ts | 3 +- packages/storage/src/agent-run-store.ts | 93 +++- packages/storage/src/execution-stores.ts | 9 +- 40 files changed, 2833 insertions(+), 335 deletions(-) create mode 100644 packages/cli/src/__tests__/context-diagnostics-render.test.ts create mode 100644 packages/runtime/src/__tests__/latest-context-commit.test.ts create mode 100644 packages/runtime/src/__tests__/prompt-composition.test.ts create mode 100644 packages/runtime/src/latest-context-snapshot.ts create mode 100644 packages/runtime/src/prompt-composition.ts diff --git a/apps/desktop/src/main/__tests__/use-session-trace.test.ts b/apps/desktop/src/main/__tests__/use-session-trace.test.ts index f2081f4229..1528412268 100644 --- a/apps/desktop/src/main/__tests__/use-session-trace.test.ts +++ b/apps/desktop/src/main/__tests__/use-session-trace.test.ts @@ -38,6 +38,7 @@ function trace(sessionId: string): SessionTrace { interface TraceHarness { reads: string[]; + contextReads: string[]; emit: (event: SessionEvent) => void; subscriptions: number; unsubscribes: number; @@ -47,6 +48,7 @@ function installMakaBridge(): TraceHarness { const handlers = new Set<(event: SessionEvent) => void>(); const harness: TraceHarness = { reads: [], + contextReads: [], emit: (event) => { for (const handler of [...handlers]) handler(event); }, @@ -61,6 +63,13 @@ function installMakaBridge(): TraceHarness { harness.reads.push(sessionId); return { ok: true, data: trace(sessionId) }; }, + // The hook reads the context snapshot on the same signal (#2323). It + // is counted separately: the assertions below are about how often the + // TRACE is re-read, and an enrichment read must not move them. + context: async (sessionId: string) => { + harness.contextReads.push(sessionId); + return { ok: true as const, data: { status: 'unavailable' as const, reason: 'no_completed_request' as const } }; + }, }, sessions: { subscribeEvents: (_sessionId: string, handler: (event: SessionEvent) => void) => { diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts index 1c4645cf10..9bb25af465 100644 --- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts @@ -69,6 +69,8 @@ function request( value: unknown, ): Promise { switch (operation) { + case 'context.diagnostics.query': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'daily-review.mutate': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'daily-review.query': diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 9b66346511..7764e9b75e 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -87,6 +87,7 @@ import type { RendererRuntimeHostQueryOperation, } from './runtime-host-renderer-operations.js'; import type { SessionTrace } from '@maka/core/session-trace'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import type { @@ -804,6 +805,8 @@ export interface MakaBridge { inspector: { /** Read-only per-session causal trace (#1625). */ trace(sessionId: string): Promise>; + /** What the session's context is made of right now (#2323). */ + context(sessionId: string): Promise>; }; webSearch: { query(input: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f5f1f28dc2..d894d68aeb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -109,6 +109,7 @@ import { isSessionTrace, type SessionTrace, } from '@maka/core/session-trace'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { DAILY_REVIEW_RANGES, normalizeDailyReviewConfig, @@ -1353,6 +1354,20 @@ const makaBridge = { trace(sessionId: string): Promise> { return bridgeResult(() => loadSessionTrace(sessionId), 'INSPECTOR_TRACE_FAILED'); }, + /** + * What the session's context is made of right now (#2323). + * + * A different question from "what happened in this session", and it has + * its own typed owner on the Host — the same snapshot `/context` prints. + * The Inspector asks that owner rather than widening the trace, so the two + * surfaces cannot drift into two implementations of one fact. + */ + context(sessionId: string): Promise> { + return bridgeResult( + () => runtimeHost.query('context.diagnostics.query', { sessionId }), + 'INSPECTOR_CONTEXT_FAILED', + ); + }, }, dailyReview: { day(offsetDays: number, daySpan?: number): Promise> { diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts index 81d6090c8c..e0dde01165 100644 --- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts +++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts @@ -1,6 +1,10 @@ import type { OperationSpecMap } from '@maka/runtime-host/protocol'; export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [ + // Read-only, session-scoped, and already the owner of "what is the context + // made of" for `/context` (#1580, #2323). Admitted on the same terms as the + // inspect query beside it: it reads a projection and writes nothing. + 'context.diagnostics.query', 'daily-review.query', 'execution.inspect.query', 'scheduled-task.query', diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 8cfb748e97..5ce9f54eb9 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -216,6 +216,32 @@ export interface DesktopConversationCopy { cacheHit: string; /** Heading over the causal record. */ timelineTab: string; + /** + * What filled the context, under the bar that says how full it is. + * + * Kept verbally separate from the bar on purpose: these are estimates + * over serialized bytes and do not sum to the provider-reported prompt + * (#2323), so the heading says estimate and every figure carries a `≈`. + */ + composition: { + title: string; + /** States the unit and its authority, once, under the heading. */ + basis: string; + part: { + system_instructions: string; + tool_definitions: string; + messages: string; + other: string; + }; + /** Heading over the per-tool rows. */ + tools: string; + /** The tools below the visible rows, folded into one. */ + remainingTools: (count: number) => string; + /** Tool schemas the payload never named. */ + unlabelled: string; + /** The metered call carried no capture — a gap, not an empty prompt. */ + unrecorded: string; + }; }; }; quoteCompanion: { @@ -479,6 +505,20 @@ const COPY = { }, cacheHit: '缓存命中率', timelineTab: '时间轴', + composition: { + title: '构成估算', + basis: '按请求字节估算,非模型报告的 token', + part: { + system_instructions: '系统提示', + tool_definitions: '工具定义', + messages: '对话记录', + other: '其他参数', + }, + tools: '按工具', + remainingTools: (count) => `其余 ${count} 个工具`, + unlabelled: '未命名的工具', + unrecorded: '这次调用没有留下构成记录', + }, }, }, quoteCompanion: { @@ -666,6 +706,20 @@ const COPY = { }, cacheHit: 'Cache hit rate', timelineTab: 'Timeline', + composition: { + title: 'Estimated composition', + basis: 'Estimated from request bytes, not provider-reported tokens', + part: { + system_instructions: 'System instructions', + tool_definitions: 'Tool definitions', + messages: 'Messages', + other: 'Other options', + }, + tools: 'By tool', + remainingTools: (count) => `${count} more tool${count === 1 ? '' : 's'}`, + unlabelled: 'Unnamed tools', + unrecorded: 'This call left no composition on record', + }, }, }, quoteCompanion: { diff --git a/apps/desktop/src/renderer/session-inspector-overview-model.ts b/apps/desktop/src/renderer/session-inspector-overview-model.ts index 988d55abb7..d6065d5286 100644 --- a/apps/desktop/src/renderer/session-inspector-overview-model.ts +++ b/apps/desktop/src/renderer/session-inspector-overview-model.ts @@ -1,3 +1,7 @@ +import type { + ContextDiagnosticsResult, + ContextDiagnosticsSegment, +} from '@maka/runtime-host/protocol'; import type { SessionTrace, TraceModelAttempt, @@ -44,9 +48,71 @@ export interface InspectorContextBudget { segments: readonly InspectorContextSegment[]; } +/** + * One row of "what was this prompt made of", sized in bytes and estimated in + * tokens (#2323). + * + * The estimate is made HERE, at the display layer, and never enters the trace: + * `bytes / 4` is a rule of thumb over serialized JSON, and a figure that has + * been rounded into the contract can no longer be labelled as an estimate where + * it is shown. The panel prints every one of these with a `≈`. + */ +export interface InspectorCompositionRow { + estimatedTokens: number; +} + +export interface InspectorCompositionPart extends InspectorCompositionRow { + kind: ContextDiagnosticsSegment['kind']; +} + +export interface InspectorCompositionTool extends InspectorCompositionRow { + name: string; +} + +export interface InspectorComposition { + parts: readonly InspectorCompositionPart[]; + /** The largest tool schemas, largest first — the ones worth removing. */ + tools: readonly InspectorCompositionTool[]; + /** + * Everything below the visible tools, folded rather than dropped. + * + * The BYTES add up: this row plus the visible ones equal the tool total. The + * token column does not, and cannot — each row is rounded up on its own, so + * a sum of estimates is not the estimate of the sum. That is why every + * figure carries `≈` and none of them is presented as a subtotal. + */ + remainingTools?: { count: number; estimatedTokens: number }; + /** Tool schemas the payload did not name; counted, never attributed. */ + unlabelledTools?: { estimatedTokens: number }; +} + +/** + * Composition of the same request the bar measures, or the reason there is + * none. + * + * `unrecorded` is a real answer, not an empty one: metering is durable and the + * capture carrying the segments is best-effort, so a metered call with no + * breakdown on record happens, and rendering it as a prompt made of nothing + * would be the fabrication the ledger rules exist to prevent (#1679). + */ +export type InspectorCompositionState = + | { status: 'available'; composition: InspectorComposition } + | { status: 'unrecorded' }; + export interface InspectorOverviewModel { /** Absent when no completed main call reported both usage and a window. */ context?: InspectorContextBudget; + /** + * What filled the context, from the Host operation that owns that question. + * + * Independent of `context` above, and that independence is the fix for a + * real hole: the bar needs a `contextWindow` to have a denominator, so a + * provider that reports usage without one used to take the whole section + * down with it. The snapshot answers on its own terms — a request with no + * window still explains itself, and a request with no capture says + * `unrecorded` instead of vanishing. + */ + composition?: InspectorCompositionState; /** * cacheRead / input over the attempts that reported input, session-wide. * Absent when no input was metered at all — a rate over nothing is not @@ -59,15 +125,39 @@ export interface InspectorOverviewModel { cacheHitRate?: number; } -export function deriveInspectorOverviewModel(trace: SessionTrace | undefined): InspectorOverviewModel { - if (!trace || trace.turns.length === 0) return {}; +/** + * The overview reads two owners, and keeps them apart. + * + * The trace answers what happened and what it cost. The context snapshot + * answers what the context holds right now — its own Host operation, the one + * `/context` prints (#1580, #2323). Neither is derived from the other, so a + * session with a trace and no snapshot still shows its history, and a snapshot + * with no trace still sizes the window. + */ +export function deriveInspectorOverviewModel( + trace: SessionTrace | undefined, + diagnostics?: ContextDiagnosticsResult, +): InspectorOverviewModel { + // Both halves of the context block come from the SAME snapshot. They used to + // be picked separately — the bar from the latest trace attempt that carried a + // window, the breakdown from the latest diagnostics — so a newest call + // without a window put one request's fullness above another request's + // contents. One source cannot disagree with itself (#2323). + const composition = compositionState(diagnostics); + const context = contextBudget(diagnostics); + if (!trace || trace.turns.length === 0) { + return { + ...(context ? { context } : {}), + ...(composition ? { composition } : {}), + }; + } const modelSteps = trace.turns.flatMap(modelCallSteps); const cacheHitRate = sessionCacheHitRate(modelSteps.flatMap((step) => step.attempts)); - const context = contextBudget(modelSteps); return { ...(context ? { context } : {}), + ...(composition ? { composition } : {}), ...(cacheHitRate !== undefined ? { cacheHitRate } : {}), }; } @@ -98,32 +188,22 @@ function sessionCacheHitRate(attempts: readonly TraceModelAttempt[]): number | u /** * The budget question a reader actually asks — "how full is the context right - * now" — is answered by the most recent completed call whose provider counted - * a prompt: its input total IS the context size the next call builds on. + * now" — answered by the snapshot's own metered prompt against the window that + * same call ran under. Absent when the provider reported no prompt, or no + * window to measure it against: a bar with no denominator is not a bar. */ -function contextBudget(steps: readonly TraceModelCallStep[]): InspectorContextBudget | undefined { - const candidates = steps - .filter((step) => step.callKind === 'main') - .flatMap((step) => step.attempts) - .filter( - (attempt) => - attempt.status === 'completed' && - attempt.inputTokens !== undefined && - attempt.contextWindow !== undefined && - attempt.contextWindow > 0, - ); - const latest = candidates.reduce( - (carry, attempt) => (carry === undefined || attempt.completedAt >= carry.completedAt ? attempt : carry), - undefined, - ); - if (!latest) return undefined; - const usedTokens = latest.inputTokens!; - const windowTokens = latest.contextWindow!; +function contextBudget( + diagnostics: ContextDiagnosticsResult | undefined, +): InspectorContextBudget | undefined { + if (!diagnostics || diagnostics.status !== 'available') return undefined; + const usedTokens = diagnostics.inputTokens; + const windowTokens = diagnostics.contextWindow; + if (usedTokens === undefined || windowTokens === undefined || windowTokens <= 0) return undefined; // A cache figure larger than the prompt it belongs to is not a fact about // the window, so it is clamped rather than allowed to push `fresh` negative. const cacheRead = - latest.cacheReadInputTokens !== undefined - ? Math.min(latest.cacheReadInputTokens, usedTokens) + diagnostics.cacheReadInputTokens !== undefined + ? Math.min(diagnostics.cacheReadInputTokens, usedTokens) : undefined; const prompt: { kind: InspectorContextSegmentKind; tokens: number }[] = cacheRead === undefined @@ -143,6 +223,76 @@ function contextBudget(steps: readonly TraceModelCallStep[]): InspectorContextBu }; } +/** + * How many tool rows are worth showing. + * + * The list exists so a reader can name a tool to remove, and that decision is + * made off the biggest few; a full registry printed at 4pt is a table, not an + * answer. What falls below the cut is folded into one row rather than dropped, + * so the parts still add up to the tool total above them. + */ +const VISIBLE_TOOL_ROWS = 5; + +function compositionState( + diagnostics: ContextDiagnosticsResult | undefined, +): InspectorCompositionState | undefined { + // No snapshot at all is not a state to render: there is nothing to ask about + // yet. A snapshot that reports no request is the same — the panel's empty + // state already covers a session that has not run. + if (!diagnostics || diagnostics.status !== 'available') return undefined; + const composition = diagnostics.composition; + // A request the durable record names but no capture explains. Stated, so the + // reader knows the breakdown is missing rather than the prompt being empty. + if (!composition) return { status: 'unrecorded' }; + + const tools = composition.tools ?? []; + const visible = tools.slice(0, VISIBLE_TOOL_ROWS); + const hidden = tools.slice(VISIBLE_TOOL_ROWS); + // The Host already folded everything past ITS cap into `remainingTools`, so + // the panel's remainder is the locally hidden rows PLUS that fold. Counting + // only the local ones silently dropped every tool beyond the Host's 64 — + // 236 of them, and their bytes, on a 300-tool session (#2323). + const producerRemainder = composition.remainingTools; + const remainingCount = hidden.length + (producerRemainder?.count ?? 0); + const remainingBytes = + hidden.reduce((carry, tool) => carry + tool.bytes, 0) + (producerRemainder?.bytes ?? 0); + + return { + status: 'available', + composition: { + parts: composition.segments.map((part) => ({ + kind: part.kind, + estimatedTokens: estimateTokens(part.bytes), + })), + tools: visible.map((tool) => ({ + name: tool.name, + estimatedTokens: estimateTokens(tool.bytes), + })), + ...(remainingCount > 0 + ? { + remainingTools: { + count: remainingCount, + estimatedTokens: estimateTokens(remainingBytes), + }, + } + : {}), + ...(composition.unlabelledToolBytes !== undefined + ? { unlabelledTools: { estimatedTokens: estimateTokens(composition.unlabelledToolBytes) } } + : {}), + }, + }; +} + +/** + * The same four-bytes-per-token rule of thumb `/context` prints, kept at the + * display layer and rendered with a `≈` everywhere it appears. It is not a + * count: the bytes it divides are serialized JSON, and an attachment's base64 + * makes it wrong in a direction nobody can correct for here. + */ +function estimateTokens(bytes: number): number { + return Math.ceil(bytes / 4); +} + function sum(attempts: readonly TraceModelAttempt[], pick: (attempt: TraceModelAttempt) => number | undefined): number { return attempts.reduce((carry, attempt) => carry + (pick(attempt) ?? 0), 0); } diff --git a/apps/desktop/src/renderer/session-inspector-panel.tsx b/apps/desktop/src/renderer/session-inspector-panel.tsx index 73e7c6f78e..620971d50a 100644 --- a/apps/desktop/src/renderer/session-inspector-panel.tsx +++ b/apps/desktop/src/renderer/session-inspector-panel.tsx @@ -77,7 +77,10 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea const [filter, setFilter] = useState({}); const trace = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]); const model = useMemo(() => applyInspectorFilter(trace, filter, copy), [trace, filter, copy]); - const overview = useMemo(() => deriveInspectorOverviewModel(snapshot.trace), [snapshot.trace]); + const overview = useMemo( + () => deriveInspectorOverviewModel(snapshot.trace, snapshot.context), + [snapshot.trace, snapshot.context], + ); // Counted on the unfiltered trace, so turning the filter on cannot change // the number that named it. const failedTurns = trace.turns.filter((turn) => turn.failed).length; @@ -207,8 +210,18 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea )} - {!model.empty && ( - + {/* The two halves answer to different owners, so they are gated + separately: totals come from the trace, the context block from the + diagnostics query. A successful snapshot beside an empty or failed + trace used to be hidden entirely (#2323). */} + {(!model.empty || overview.context || overview.composition) && ( + )} {!model.empty && ( @@ -334,6 +347,8 @@ function InspectorOverview(props: { locale: UiLocale; totals: TraceTotals; overview: ReturnType; + /** Trace-derived figures; absent when the trace is empty or failed to read. */ + showTotals: boolean; }) { const { copy, overview, totals } = props; const formatNumber = numberFormatter(props.locale); @@ -345,6 +360,7 @@ function InspectorOverview(props: { tried here ranked below the numbers it introduced, because a label over a 20px figure is exactly what a StatCell already is — the three cells label themselves. */} + {props.showTotals && (
@@ -352,10 +368,19 @@ function InspectorOverview(props: { )}
+ )} {context && ( )} + + {overview.composition && ( + + )} ); } @@ -383,7 +408,7 @@ function StatCell(props: { label: string; value: ReactNode }) { * left is name-left / number-right, the same skeleton as a step row, so the * whole panel scans on one rhythm. */ -function FactRow(props: { label: string; value: ReactNode; swatch?: ReactNode }) { +function FactRow(props: { label: ReactNode; value: ReactNode; swatch?: ReactNode }) { return (
@@ -463,6 +488,91 @@ function InspectorContextSection(props: { ); } +/** + * What filled the context, under the bar that says how full it is (#2323). + * + * A separate block rather than bands inside that bar, and the separation is the + * point: the bar's numbers are provider-reported tokens summing to the metered + * prompt, these are estimates over serialized bytes summing to the request. One + * track holding both would present the estimate as a decomposition of the + * reported figure — the confusion #1679 exists to prevent — so the heading says + * estimate, the basis line says what the unit is, and every figure carries `≈`. + * + * Tools are listed by name because that is the only row a reader can act on: + * "tool definitions ≈ 40%" names nothing to remove. + */ +function InspectorCompositionSection(props: { + copy: InspectorCopy; + state: NonNullable['composition']>; + formatNumber: (value: number) => string; +}) { + const { copy, formatNumber, state } = props; + const labels = copy.overview.composition; + const estimate = (tokens: number) => `≈${formatNumber(tokens)}`; + + return ( + +
+ + {labels.title} + +
+

{labels.basis}

+ + {state.status === 'unrecorded' ? ( + // Stated, not hidden: a metered call whose capture never landed is a + // gap in what the reader can see, and an absent section would read as + // "nothing to explain" instead. +

{labels.unrecorded}

+ ) : ( + <> +
+ {state.composition.parts.map((part) => ( + + ))} +
+ + {/* Gated on either, not on the named list alone: a session recorded + before tools carried a name has bytes and no names, and that is + exactly when the unnamed row is the only thing to show. */} + {(state.composition.tools.length > 0 || state.composition.unlabelledTools) && ( + <> + + {labels.tools} + +
+ {state.composition.tools.map((tool) => ( + {tool.name}} + value={estimate(tool.estimatedTokens)} + /> + ))} + {state.composition.remainingTools && ( + + )} + {state.composition.unlabelledTools && ( + + )} +
+ + )} + + )} +
+ ); +} + function numberFormatter(locale: UiLocale): (value: number) => string { const formatter = new Intl.NumberFormat(uiLocaleToIntlLocale(locale)); return (value) => formatter.format(value); diff --git a/apps/desktop/src/renderer/styles/chat-detail.css b/apps/desktop/src/renderer/styles/chat-detail.css index ac36d26c18..0cf99ab5e7 100644 --- a/apps/desktop/src/renderer/styles/chat-detail.css +++ b/apps/desktop/src/renderer/styles/chat-detail.css @@ -659,6 +659,29 @@ color: var(--destructive-text); } +/* The line under a section heading that says what its figures are, rather than + reporting one. The composition block needs it because its unit is not the + unit of the bar above it: estimated bytes, not reported tokens (#2323). */ +.maka-inspector-section-note { + margin: 0; + font: var(--maka-text-supporting); + color: var(--muted-foreground); +} + +/* One tier under a section title, for the per-tool list inside the estimate — + it belongs to the section above it rather than opening a new one. */ +.maka-inspector-section-subtitle { + color: var(--foreground-secondary); + margin-top: var(--space-1); +} + +/* A tool name is an identifier, and an MCP one can be longer than the column; + it wraps rather than pushing the figure off the panel. */ +.maka-inspector-composition-name { + white-space: normal; + overflow-wrap: anywhere; +} + /* The context window drawn as bands. Colour is the join between the track and its legend, so both read it from the same `data-segment` rules below and a band can never mean one thing in the bar and another in the list. */ diff --git a/apps/desktop/src/renderer/use-session-trace.ts b/apps/desktop/src/renderer/use-session-trace.ts index 60edf29f9e..f9c2d31c08 100644 --- a/apps/desktop/src/renderer/use-session-trace.ts +++ b/apps/desktop/src/renderer/use-session-trace.ts @@ -2,11 +2,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; import { type UiLocale } from '@maka/core/ui-locale'; import type { SessionTrace } from '@maka/core/session-trace'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { createTraceRefreshCoalescer } from './session-trace-refresh.js'; interface SessionTraceSnapshot { sessionId?: string; trace?: SessionTrace; + /** + * What the context is made of right now, from the Host operation that owns + * that question (#2323). Read on the same signal as the trace but kept + * separate: it is a different fact from a different owner, and a failure to + * answer it must not blank the causal record beside it. + */ + context?: ContextDiagnosticsResult; loading: boolean; error?: string; } @@ -44,10 +52,14 @@ export function useSessionTrace( const revision = ++revisionRef.current; setSnapshot((current) => ({ sessionId: targetSessionId, - // Keep the last trace on screen through every read: a live turn would - // otherwise blank the timeline on each event, and re-activation would - // blank it on each glance. - ...(current.sessionId === targetSessionId ? { trace: current.trace } : {}), + // Keep BOTH reads on screen through every refresh. They settle + // independently, so preserving only the trace left the composition + // blank from the moment a read started until the second response + // landed — a flicker on every ledger event, on data that was still + // valid the whole time. + ...(current.sessionId === targetSessionId + ? { trace: current.trace, context: current.context } + : {}), loading: true, })); void window.maka.inspector.trace(targetSessionId).then( @@ -56,19 +68,28 @@ export function useSessionTrace( if (!result.ok) { setSnapshot((current) => ({ sessionId: targetSessionId, - ...(current.sessionId === targetSessionId ? { trace: current.trace } : {}), + ...(current.sessionId === targetSessionId + ? { trace: current.trace, context: current.context } + : {}), loading: false, error: result.error.message || copy.loadFailed, })); return; } - setSnapshot({ sessionId: targetSessionId, trace: result.data, loading: false }); + setSnapshot((current) => ({ + sessionId: targetSessionId, + trace: result.data, + ...(current.sessionId === targetSessionId ? { context: current.context } : {}), + loading: false, + })); }, (error: unknown) => { if (revision !== revisionRef.current) return; setSnapshot((current) => ({ sessionId: targetSessionId, - ...(current.sessionId === targetSessionId ? { trace: current.trace } : {}), + ...(current.sessionId === targetSessionId + ? { trace: current.trace, context: current.context } + : {}), loading: false, error: copy.locale === 'zh' @@ -77,6 +98,24 @@ export function useSessionTrace( })); }, ); + // Enrichment, and read as such: the context snapshot has its own owner + // and its own failure modes, so it lands when it lands and its absence + // costs the composition block, never the trace. + void window.maka.inspector.context(targetSessionId).then( + (result) => { + if (revision !== revisionRef.current) return; + setSnapshot((current) => + current.sessionId === targetSessionId && result.ok + ? { ...current, context: result.data } + : current, + ); + }, + () => { + // A refresh that could not reach the snapshot leaves the last one + // standing: it is still the newest answer anyone has, and blanking it + // would report "no composition" for a read that simply failed. + }, + ); }, [copy.loadFailed, copy.locale], ); diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index c9d689e4df..91944dc4c9 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -4,6 +4,7 @@ import type { GitReviewSnapshot } from '@maka/core/git-review'; import type { SessionSummary } from '@maka/core/session'; import type { Task } from '@maka/core/task-ledger'; import type { SessionTrace } from '@maka/core/session-trace'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { ToastProvider } from '@maka/ui'; import { SessionWorkbar } from '../src/renderer/session-workbar'; import { @@ -410,32 +411,66 @@ const populatedTrace: SessionTrace = { }, }; +const populatedContext: ContextDiagnosticsResult = { + status: 'available', + providerId: 'zai', + modelId: 'glm-5.1', + completedAt: NOW + 42_900, + inputTokens: 18_900, + contextWindow: 200_000, + composition: { + segments: [ + { kind: 'system_instructions', bytes: 12_000 }, + { kind: 'tool_definitions', bytes: 42_000 }, + { kind: 'messages', bytes: 21_800 }, + { kind: 'other', bytes: 400 }, + ], + tools: [ + { name: 'Bash', bytes: 9_400 }, + { name: 'Read', bytes: 7_100 }, + { name: 'Edit', bytes: 6_300 }, + { name: 'Grep', bytes: 5_200 }, + { name: 'mcp__Claude_Browser__computer', bytes: 4_800 }, + { name: 'WebFetch', bytes: 3_900 }, + { name: 'Write', bytes: 3_100 }, + { name: 'Glob', bytes: 2_200 }, + ], + }, +}; + +/** A ledger written before tool schemas carried a name: bytes, no names. */ /** - * The same session with its latest prompt resized, rather than a second copy - * of the fixture: what the near-limit story is about is the size of one call, - * and a fork would drift from `populatedTrace` on every other axis. + * The same session with its latest prompt resized. The bar reads the snapshot + * now, so "near the limit" is a property of the snapshot rather than of a + * trace attempt (#2323). */ -const nearLimitTrace: SessionTrace = { - ...populatedTrace, - turns: populatedTrace.turns.map((turn, index) => - index !== populatedTrace.turns.length - 1 - ? turn - : { - ...turn, - steps: turn.steps.map((step) => - step.kind !== 'model_call' - ? step - : { - ...step, - attempts: step.attempts.map((attempt) => ({ - ...attempt, - inputTokens: 186_400, - cacheReadInputTokens: 151_800, - })), - }, - ), - }, - ), +const nearLimitContext: ContextDiagnosticsResult = { + ...populatedContext, + ...(populatedContext.status === 'available' + ? { inputTokens: 186_400, cacheReadInputTokens: 151_800 } + : {}), +}; + +const unnamedToolsContext: ContextDiagnosticsResult = { + ...populatedContext, + composition: { + segments: populatedContext.status === 'available' ? populatedContext.composition!.segments : [], + unlabelledToolBytes: 42_000, + }, +}; + +/** + * The durable metering record named this request; the best-effort capture that + * would have explained it never landed. A real state, and the one the panel + * must state rather than render as an empty prompt. + */ +const unrecordedContext: ContextDiagnosticsResult = { + status: 'available', + providerId: 'zai', + modelId: 'glm-5.1', + completedAt: NOW + 42_900, + inputTokens: 18_900, + contextWindow: 200_000, }; const emptyTrace: SessionTrace = { @@ -474,6 +509,8 @@ function bridge(options: { tasksFail?: boolean; trace?: SessionTrace; traceFail?: boolean; + /** The context snapshot the composition block reads (#2323). */ + context?: ContextDiagnosticsResult; recordFilePath?: string; } = {}) { return withScopedMakaBridge({ @@ -507,6 +544,10 @@ function bridge(options: { options.traceFail ? { ok: false, error: { message: '追踪读取失败:无法读取运行记录' } } : { ok: true, data: options.trace ?? emptyTrace }, + context: async () => ({ + ok: true, + data: options.context ?? { status: 'unavailable', reason: 'no_completed_request' }, + }), }, gitReview: { read: async () => ({ @@ -612,7 +653,7 @@ export const Files: Story = { // denied tool sits in the raw record under the coverage notice the projection // raises when records are missing. export const Trace: Story = { - decorators: [bridge({ trace: populatedTrace })], + decorators: [bridge({ trace: populatedTrace, context: populatedContext })], render: () => , }; @@ -621,7 +662,26 @@ export const Trace: Story = { // before a compaction, and the state a reader is most likely to open the tab // for. Same session as Trace, sized differently, so the two read side by side. export const TraceContextNearLimit: Story = { - decorators: [bridge({ trace: nearLimitTrace })], + decorators: [bridge({ trace: populatedTrace, context: nearLimitContext })], + render: () => , +}; + +// Real path: 会话工作栏 → 追踪 on a session recorded before tool schemas carried +// a name — the shape of every ledger written prior to #2323. The composition +// block still has to show those bytes, as unnamed tools rather than as a +// missing category, which is what gating the tool list on the NAMED rows alone +// silently broke. +export const TraceUnnamedTools: Story = { + decorators: [bridge({ trace: populatedTrace, context: unnamedToolsContext })], + render: () => , +}; + +// Real path: 会话工作栏 → 追踪 when the durable metering record names the latest +// request but its best-effort capture never landed — the composition block has +// to SAY so, since an absent section reads as "nothing to explain" and a zero +// reads as an empty prompt. +export const TraceCompositionUnrecorded: Story = { + decorators: [bridge({ trace: populatedTrace, context: unrecordedContext })], render: () => , }; diff --git a/packages/cli/src/__tests__/context-diagnostics-render.test.ts b/packages/cli/src/__tests__/context-diagnostics-render.test.ts new file mode 100644 index 0000000000..8a547a8964 --- /dev/null +++ b/packages/cli/src/__tests__/context-diagnostics-render.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; +import { formatContextDiagnostics } from '../pi-tui-runner.js'; + +/** + * `/context` prints the same snapshot the Inspector renders (#2323), so this + * covers the two things that are easy to get wrong in text: that every derived + * figure is marked as an estimate, and that a request with no capture says so + * instead of printing zeros. + */ +function diagnostics( + overrides: Partial> = {}, +) { + return { + status: 'available', + providerId: 'anthropic', + modelId: 'claude-test', + completedAt: 20, + inputTokens: 40, + contextWindow: 200, + ...overrides, + } as ContextDiagnostics; +} + +test('names each tool, and marks every derived figure as an estimate', () => { + const out = formatContextDiagnostics( + diagnostics({ + composition: { + segments: [ + { kind: 'system_instructions', bytes: 400 }, + { kind: 'tool_definitions', bytes: 800 }, + ], + tools: [ + { name: 'Bash', bytes: 500 }, + { name: 'Read', bytes: 300 }, + ], + remainingTools: { count: 3, bytes: 120 }, + }, + }), + ).replace(/\s+/g, ' '); + + assert.match(out, /System instructions: ≈100 tokens Tool definitions: ≈200 tokens/); + // Per tool, because that is the only row a reader can act on. + assert.match(out, /By tool Bash: ≈125 tokens Read: ≈75 tokens/); + assert.match(out, /3 more tools: ≈30 tokens/); + // The provider-reported figures keep naming their source; the estimates + // never borrow that authority. + assert.match(out, /Used: 40 tokens provider-reported/); +}); + +test('says a request left no composition rather than printing an empty one', () => { + const out = formatContextDiagnostics(diagnostics()).replace(/\s+/g, ' '); + + assert.match(out, /Estimated breakdown Unavailable this request left no composition on record/); + assert.doesNotMatch(out, /≈0 tokens/); +}); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 7099f94033..c7edbeb5e3 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2868,7 +2868,7 @@ const BOTTOM_PICKER_MARGIN_ROWS = 4; // silently clipping the last command. const EDITOR_AUTOCOMPLETE_MAX_VISIBLE = 24; -function formatContextDiagnostics(diagnostics: ContextDiagnostics): string { +export function formatContextDiagnostics(diagnostics: ContextDiagnostics): string { if (diagnostics.status === 'unavailable') { return diagnostics.reason === 'no_completed_request' ? 'Context unavailable\nNo completed provider request exists for this session.' @@ -2913,18 +2913,42 @@ function formatContextDiagnostics(diagnostics: ContextDiagnostics): string { } lines.push('', 'Estimated breakdown'); - if (diagnostics.segments.length === 0) { - lines.push(' Unavailable', ' no captured request segments'); + const composition = diagnostics.composition; + if (!composition) { + // The metering record named this request; no capture explained it. Said + // plainly, because a silent "0 tokens" would read as an empty prompt. + lines.push(' Unavailable', ' this request left no composition on record'); } else { - const labels: Record<(typeof diagnostics.segments)[number]['kind'], string> = { + const labels: Record<(typeof composition.segments)[number]['kind'], string> = { system_instructions: 'System instructions', tool_definitions: 'Tool definitions', messages: 'Messages', other: 'Other options', }; - for (const segment of diagnostics.segments) { + for (const segment of composition.segments) { lines.push( - ` ${labels[segment.kind]}: ≈${formatContextCount(segment.estimatedTokens)} tokens`, + ` ${labels[segment.kind]}: ≈${formatContextCount(estimateContextTokens(segment.bytes))} tokens`, + ); + } + // Per tool, because that is the only row a reader can act on: "tool + // definitions ≈ 40%" names nothing to remove (#2323). + if (composition.tools && composition.tools.length > 0) { + lines.push('', 'By tool'); + for (const tool of composition.tools) { + lines.push( + ` ${tool.name}: ≈${formatContextCount(estimateContextTokens(tool.bytes))} tokens`, + ); + } + if (composition.remainingTools) { + const remainder = composition.remainingTools; + lines.push( + ` ${remainder.count} more tool${remainder.count === 1 ? '' : 's'}: ≈${formatContextCount(estimateContextTokens(remainder.bytes))} tokens`, + ); + } + } + if (composition.unlabelledToolBytes !== undefined) { + lines.push( + ` Unnamed tools: ≈${formatContextCount(estimateContextTokens(composition.unlabelledToolBytes))} tokens`, ); } } @@ -2943,6 +2967,16 @@ function formatContextDiagnostics(diagnostics: ContextDiagnostics): string { return lines.join('\n'); } +/** + * The estimate lives here, at the surface that shows it, and prints with a `≈` + * every time. The Host reports measured bytes; four-bytes-per-token is a rule + * of thumb over serialized JSON, and one that is wrong for an attachment's + * base64 in a direction nobody downstream can correct (#2323). + */ +function estimateContextTokens(bytes: number): number { + return Math.ceil(bytes / 4); +} + function formatContextCount(value: number): string { return value.toLocaleString('en-US'); } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 4116d1c278..65e4a002c3 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -611,13 +611,31 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { const diagnostics = await this.#request('context.diagnostics.query', { sessionId: this.#sessionId, }); - return diagnostics.status === 'unavailable' - ? diagnostics - : { - ...diagnostics, - segments: diagnostics.segments.map((segment) => ({ ...segment })), - ...(diagnostics.compaction ? { compaction: { ...diagnostics.compaction } } : {}), - }; + if (diagnostics.status === 'unavailable') return diagnostics; + // The protocol frame is readonly; the CLI's own type is not. Copied field + // by field rather than spread so a future protocol field cannot arrive + // here unnoticed. + const { composition, compaction, ...rest } = diagnostics; + return { + ...rest, + ...(composition + ? { + composition: { + segments: composition.segments.map((segment) => ({ ...segment })), + ...(composition.tools + ? { tools: composition.tools.map((tool) => ({ ...tool })) } + : {}), + ...(composition.remainingTools + ? { remainingTools: { ...composition.remainingTools } } + : {}), + ...(composition.unlabelledToolBytes !== undefined + ? { unlabelledToolBytes: composition.unlabelledToolBytes } + : {}), + }, + } + : {}), + ...(compaction ? { compaction: { ...compaction } } : {}), + }; } getOrchestrationMode(): OrchestrationMode { diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index a786d02663..40ac15a749 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -384,6 +384,93 @@ export const AGENT_RUN_EVENT_TYPES = [ export type AgentRunEventType = (typeof AGENT_RUN_EVENT_TYPES)[number]; +/** + * Derived state committed with the event that authorises it (#2323). + * + * `latestContext` rides the canonical completed-main attempt append so the two + * cannot disagree: there is one durable commit for the request, and the + * projection is a product of it rather than a second record racing it. A store + * without projections ignores this; the projection is rebuildable either way. + */ +export interface AgentRunAppendOptions { + durable?: boolean; + latestContext?: LatestContextProjectionInput; +} + +/** + * The projection key. Deliberately NOT an emitted event type: nothing appends + * a record under this name. It names one derived row per session, written by + * the transaction that commits the canonical attempt and rebuildable from the + * ledger at any time. + */ +export const LATEST_CONTEXT_PROJECTION_TYPE = 'latest_context'; + +/** + * What a projection may be keyed by: usually an event type, but not always. + * + * Named once so every layer that passes a key declares the same thing. A store + * whose parameter says `AgentRunEventType` while the interface it implements + * says otherwise only pushes the mismatch out to its callers as a cast. + */ +export type AgentRunProjectionKey = AgentRunEventType | typeof LATEST_CONTEXT_PROJECTION_TYPE; + +/** + * Everything one settled provider request commits, as one value (#2323). + * + * Deliberately an object rather than positional arguments: a layer that + * forwards only the attempt used to be a silent drop — JavaScript discards the + * extra argument and TypeScript accepts the narrower callback — so the derived + * row never reached storage in production. Passing one object makes an + * incomplete forward a type error instead of a missing feature. + */ +export interface ModelCallCommit { + attempt: TAttempt; + latestContext?: LatestContextProjectionInput; +} + +/** + * The facts a latest-context projection freezes, all bound to one request. + * + * `orderedAt` is what makes the projection monotonic: overlapping turns append + * on independent queues, so arrival order is not completion order, and a later + * arrival must not move the answer backwards. + */ +export interface LatestContextProjectionInput { + attemptId: string; + orderedAt: number; + snapshot: Record; +} + +/** How two candidate latest-context rows compare. */ +export interface LatestContextOrder { + completedAt: number; + attemptId: string; +} + +/** + * The one ordering rule for the latest-context row. + * + * Lives here because two independent writers must agree on it: the storage + * transaction deciding whether an arriving commit supersedes the stored row, + * and the cold rebuild deciding which record of a whole ledger is the newest. + * A warm read and a rebuild of the same session that disagreed about which + * request is "latest" would be indistinguishable from data loss. + * + * Completion time, never arrival — overlapping turns append on independent + * queues. Ties break on `attemptId` rather than on arrival, so the answer does + * not depend on which writer got there first. + */ +export function supersedesLatestContext( + candidate: LatestContextOrder, + incumbent: LatestContextOrder | undefined, +): boolean { + if (!incumbent) return true; + if (candidate.completedAt !== incumbent.completedAt) { + return candidate.completedAt > incumbent.completedAt; + } + return candidate.attemptId > incumbent.attemptId; +} + /** * A decoded ledger record. The ledger is append-only and outlives any single build, so `type` is * an open string: a reader must accept a type another version wrote, whether that version retired @@ -611,18 +698,23 @@ export interface AgentRunStore { sessionId: string, runId: string, event: EmittedAgentRunEvent, - options?: { durable?: boolean }, + options?: AgentRunAppendOptions, ): Promise; readEvents(sessionId: string, runId: string): Promise; - /** `undefined` means uninitialized; `null` is an initialized empty projection. */ + /** + * `undefined` means uninitialized; `null` is an initialized empty projection. + * + * The key is a projection name, which is usually an event type but need not + * be: `latest_context` names a derived row nothing appends under (#2323). + */ readEventProjection?( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): Promise; /** Rewrites derived state after the canonical event ledger repairs an absent or damaged projection. */ repairEventProjection?( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, event: AgentRunEvent | null, options?: { replaceEventId?: string }, ): Promise; diff --git a/packages/runtime-host/src/__tests__/context-protocol.test.ts b/packages/runtime-host/src/__tests__/context-protocol.test.ts index e29e519a5c..aa64a943d9 100644 --- a/packages/runtime-host/src/__tests__/context-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/context-protocol.test.ts @@ -39,7 +39,11 @@ test('context operations preserve bounded exact wire values', () => { completedAt: 10, inputTokens: 20, contextWindow: 128_000, - segments: [{ kind: 'messages', bytes: 80, estimatedTokens: 20 }], + composition: { + segments: [{ kind: 'messages', bytes: 80 }], + tools: [{ name: 'Bash', bytes: 40 }], + remainingTools: { count: 3, bytes: 90 }, + }, compaction: { kind: 'history', phase: 'pre_turn', @@ -60,7 +64,11 @@ test('context operations preserve bounded exact wire values', () => { completedAt: 10, inputTokens: 20, contextWindow: 128_000, - segments: [{ kind: 'messages', bytes: 80, estimatedTokens: 20 }], + composition: { + segments: [{ kind: 'messages', bytes: 80 }], + tools: [{ name: 'Bash', bytes: 40 }], + remainingTools: { count: 3, bytes: 90 }, + }, compaction: { kind: 'history', phase: 'pre_turn', @@ -119,7 +127,6 @@ test('context operations reject open shapes and invalid diagnostics', () => { modelId: 'openrouter/free', completedAt: 10, contextWindow: 0, - segments: [], }, }), isProtocolError, diff --git a/packages/runtime-host/src/protocol/context.ts b/packages/runtime-host/src/protocol/context.ts index 2c88bec47c..717626b0b5 100644 --- a/packages/runtime-host/src/protocol/context.ts +++ b/packages/runtime-host/src/protocol/context.ts @@ -23,7 +23,26 @@ export type ContextCompactResult = TurnSnapshot; export interface ContextDiagnosticsSegment { readonly kind: 'system_instructions' | 'tool_definitions' | 'messages' | 'other'; readonly bytes: number; - readonly estimatedTokens: number; +} + +/** One tool's schema, sized on its own, so a reader knows which to remove. */ +export interface ContextDiagnosticsTool { + readonly name: string; + readonly bytes: number; +} + +/** + * What the latest request was made of, in bytes of serialized request (#2323). + * + * Bytes cross the wire; `bytes / 4` does not. The estimate is a display rule, + * made and labelled `≈` where it is shown — a figure rounded into this frame + * could no longer be labelled at all. + */ +export interface ContextDiagnosticsComposition { + readonly segments: readonly ContextDiagnosticsSegment[]; + readonly tools?: readonly ContextDiagnosticsTool[]; + readonly remainingTools?: { readonly count: number; readonly bytes: number }; + readonly unlabelledToolBytes?: number; } export type ContextDiagnosticsResult = @@ -37,8 +56,15 @@ export type ContextDiagnosticsResult = readonly modelId: string; readonly completedAt: number; readonly inputTokens?: number; + /** Provider-reported cache read for the same request, when it counted one. */ + readonly cacheReadInputTokens?: number; readonly contextWindow?: number; - readonly segments: readonly ContextDiagnosticsSegment[]; + /** + * Absent when the durable metering record has no matching capture — a + * request that cannot explain itself says nothing rather than wearing an + * older request's breakdown. + */ + readonly composition?: ContextDiagnosticsComposition; readonly compaction?: { readonly kind: 'history'; readonly phase: 'pre_turn' | 'mid_turn'; @@ -102,8 +128,9 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul 'modelId', 'completedAt', 'inputTokens', + 'cacheReadInputTokens', 'contextWindow', - 'segments', + 'composition', 'compaction', ], ); @@ -126,12 +153,9 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul const available = requireShapedRecord( record, 'Available context diagnostics', - ['status', 'providerId', 'modelId', 'completedAt', 'segments'], - ['inputTokens', 'contextWindow', 'compaction'], + ['status', 'providerId', 'modelId', 'completedAt'], + ['inputTokens', 'cacheReadInputTokens', 'contextWindow', 'composition', 'compaction'], ); - if (!Array.isArray(available.segments) || available.segments.length > 4) { - throw invalidProtocolFrame('Invalid context diagnostics segments'); - } return { status: 'available', providerId: requireString(available.providerId, 'providerId', 512), @@ -140,22 +164,86 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul ...(available.inputTokens === undefined ? {} : { inputTokens: requireCount(available.inputTokens, 'inputTokens') }), + ...(available.cacheReadInputTokens === undefined + ? {} + : { + cacheReadInputTokens: requireCount( + available.cacheReadInputTokens, + 'cacheReadInputTokens', + ), + }), ...(available.contextWindow === undefined ? {} : { contextWindow: requirePositiveCount(available.contextWindow, 'contextWindow') }), - segments: available.segments.map(decodeContextDiagnosticsSegment), + ...(available.composition === undefined + ? {} + : { composition: decodeContextDiagnosticsComposition(available.composition) }), ...(available.compaction === undefined ? {} : { compaction: decodeContextDiagnosticsCompaction(available.compaction) }), }; } -function decodeContextDiagnosticsSegment(value: unknown): ContextDiagnosticsSegment { - const segment = requireExactRecord(value, 'Context diagnostics segment', [ - 'kind', +/** + * The tool list is bounded on the wire for the same reason the evidence reads + * are: a Host answer is built in memory, and a registry that grew without limit + * would be a frame nobody sized. + */ +const MAX_COMPOSITION_TOOLS = 256; + +function decodeContextDiagnosticsComposition(value: unknown): ContextDiagnosticsComposition { + const composition = requireShapedRecord( + value, + 'Context diagnostics composition', + ['segments'], + ['tools', 'remainingTools', 'unlabelledToolBytes'], + ); + if (!Array.isArray(composition.segments) || composition.segments.length > 4) { + throw invalidProtocolFrame('Invalid context diagnostics segments'); + } + if ( + composition.tools !== undefined && + (!Array.isArray(composition.tools) || composition.tools.length > MAX_COMPOSITION_TOOLS) + ) { + throw invalidProtocolFrame('Invalid context diagnostics tools'); + } + return { + segments: composition.segments.map(decodeContextDiagnosticsSegment), + ...(composition.tools === undefined + ? {} + : { tools: composition.tools.map(decodeContextDiagnosticsTool) }), + ...(composition.remainingTools === undefined + ? {} + : { remainingTools: decodeContextDiagnosticsRemainder(composition.remainingTools) }), + ...(composition.unlabelledToolBytes === undefined + ? {} + : { + unlabelledToolBytes: requireCount(composition.unlabelledToolBytes, 'unlabelledToolBytes'), + }), + }; +} + +function decodeContextDiagnosticsRemainder(value: unknown): { count: number; bytes: number } { + const remainder = requireExactRecord(value, 'Context diagnostics tool remainder', [ + 'count', 'bytes', - 'estimatedTokens', ]); + return { + count: requireCount(remainder.count, 'count'), + bytes: requireCount(remainder.bytes, 'bytes'), + }; +} + +function decodeContextDiagnosticsTool(value: unknown): ContextDiagnosticsTool { + const tool = requireExactRecord(value, 'Context diagnostics tool', ['name', 'bytes']); + return { + name: requireString(tool.name, 'name', 512), + bytes: requireCount(tool.bytes, 'bytes'), + }; +} + +function decodeContextDiagnosticsSegment(value: unknown): ContextDiagnosticsSegment { + const segment = requireExactRecord(value, 'Context diagnostics segment', ['kind', 'bytes']); if ( segment.kind !== 'system_instructions' && segment.kind !== 'tool_definitions' && @@ -164,11 +252,7 @@ function decodeContextDiagnosticsSegment(value: unknown): ContextDiagnosticsSegm ) { throw invalidProtocolFrame('Invalid context diagnostics segment kind'); } - return { - kind: segment.kind, - bytes: requireCount(segment.bytes, 'bytes'), - estimatedTokens: requireCount(segment.estimatedTokens, 'estimatedTokens'), - }; + return { kind: segment.kind, bytes: requireCount(segment.bytes, 'bytes') }; } function decodeContextDiagnosticsCompaction( diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 3f8db51488..9d9a6e24b1 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -3,6 +3,7 @@ import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import { resolveModelVisionSupport } from '@maka/core/model-metadata'; import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; +import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { @@ -186,9 +187,14 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom * provider call has already completed and billed. */ let accountingAuthorityFailed = false; - const recordModelCallAttempt = async (attempt: ModelCallAttempt): Promise => { + const recordModelCallAttempt = async ( + commit: ModelCallCommit, + ): Promise => { + const attempt = commit.attempt; try { - await input.context.recordModelCallAttempt?.(attempt); + // Forwarded whole. Taking `attempt` alone here is what silently dropped + // the derived latest-context row before it reached storage (#2323). + await input.context.recordModelCallAttempt?.(commit); } catch (error) { accountingAuthorityFailed = true; throw error; diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 7b4842288d..bc8932b32a 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -12,48 +12,225 @@ import type { import { createSqliteAgentRunStore } from '@maka/storage'; import { readLatestContextDiagnostics } from '../context-diagnostics.js'; -test('reads the latest completed provider request instead of a later failed attempt', async () => { +test('serves the sealed snapshot without reading a single run', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200), + { durable: true, latestContext: latestContext('attempt-1', 10) }, + ); + + const reader = createSqliteAgentRunStore(root); + let scanned = 0; + const counted = countingStore(reader, () => { + scanned += 1; + }); + + const diagnostics = await readLatestContextDiagnostics(counted, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.deepEqual(diagnostics.composition?.tools, [{ name: 'Bash', bytes: 800 }]); + assert.equal(scanned, 0, 'a sealed snapshot is one projection read'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a failed call does not replace the last good snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200), + { durable: true, latestContext: latestContext('attempt-1', 10) }, + ); + // A failed attempt still writes its metering record. It must not touch the + // snapshot: the reader would otherwise lose its warm answer to a call that + // never produced a context at all. + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-2', 20, 'model-failed', undefined, undefined, { + status: 'failed', + }), + ); + + let scanned = 0; + const counted = countingStore(createSqliteAgentRunStore(root), () => { + scanned += 1; + }); + const diagnostics = await readLatestContextDiagnostics(counted, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'model', 'the completed call still answers'); + assert.equal(scanned, 0, 'and the read stays warm'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a subagent's run never becomes the session's context", async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-parent', 1)); + await writer.createRun({ + ...runHeader('run-child', 2), + parentRunId: 'run-parent', + agentId: 'sub', + }); + await writer.appendEvent( + 'session-1', + 'run-parent', + meteringEvent('run-parent', 'a-parent', 10, 'model', 40, 200), + { durable: true, latestContext: latestContext('a-parent', 10) }, + ); + await writer.appendEvent( + 'session-1', + 'run-child', + meteringEvent('run-child', 'a-child', 20, 'model-child', 40, 200), + { durable: true, latestContext: latestContext('a-child', 20, 'model-child') }, + ); + + const diagnostics = await readLatestContextDiagnostics( + createSqliteAgentRunStore(root), + 'session-1', + ); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'model', "a child's prompt is not this session's context"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rebuilds a legacy ledger, then repairs it so the next read scans nothing', async () => { + // A session written before canonical metering sealed anything. The scan is + // the compatibility path; proving it happens ONCE needs two reads. + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-1', 20, 'model-new', 40, 200), + ); + await writer.appendEvent( + 'session-1', + 'run-1', + attemptEvent('run-1', 'attempt-1', 20, 'completed', 'model-new', 40, 200, [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 't', bytes: 800, label: 'Bash' }, + ]), + ); + + let scanned = 0; + const counted = countingStore(createSqliteAgentRunStore(root), () => { + scanned += 1; + }); + + const cold = await readLatestContextDiagnostics(counted, 'session-1'); + assert.equal(cold.status, 'available'); + if (cold.status !== 'available') return; + assert.equal(cold.modelId, 'model-new'); + assert.deepEqual(cold.composition?.tools, [{ name: 'Bash', bytes: 800 }]); + assert.ok(scanned > 0, 'the first read falls back to the ledger'); + + scanned = 0; + const warm = await readLatestContextDiagnostics(counted, 'session-1'); + assert.equal(warm.status, 'available'); + if (warm.status !== 'available') return; + assert.deepEqual(warm.composition?.tools, [{ name: 'Bash', bytes: 800 }]); + assert.equal(scanned, 0, 'the cold read repaired the projection on its way out'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('reads a provider-only ledger that predates canonical metering', async () => { + // No `model_call_attempt_recorded` anywhere. The provider attempt is the + // only record of the request, and returning "no completed request" would + // lose an answer the ledger still holds. const store = runStore([ { header: runHeader('run-1', 1), - events: [attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model-old', 10, 100)], + events: [ + attemptEvent('run-1', 'attempt-1', 20, 'completed', 'model-old', 40, 200, [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 't', bytes: 800, label: 'Bash' }, + ]), + ], }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'model-old'); + assert.deepEqual(diagnostics.composition?.tools, [{ name: 'Bash', bytes: 800 }]); +}); + +test('a canonical record on the ledger keeps the legacy path out of it', async () => { + // The fallback must never become a second authority: once a canonical + // attempt exists, a newer provider-only attempt is not promoted over it. + const store = runStore([ { - header: runHeader('run-2', 2), + header: runHeader('run-1', 1), events: [ - attemptEvent('run-2', 'attempt-2', 20, 'completed', 'model-new', 40, 200), - attemptEvent('run-2', 'attempt-3', 30, 'failed', 'model-failed', undefined, 300), + meteringEvent('run-1', 'attempt-1', 10, 'model-canonical', 40, 200), + attemptEvent('run-1', 'attempt-2', 30, 'completed', 'model-provider-only', 50, 200), ], }, ]); const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); - assert.deepEqual(diagnostics, { - status: 'available', - providerId: 'anthropic', - modelId: 'model-new', - completedAt: 20, - inputTokens: 40, - contextWindow: 200, - segments: [], - }); + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'model-canonical'); }); -test('ignores non-inline child runs when reading session context', async () => { +test('a legacy request whose capture is missing reports no composition, not an older one', async () => { const store = runStore([ { - header: runHeader('run-parent', 1), + header: runHeader('run-1', 1), events: [ - checkpointEvent('run-parent', 5, 12, 3, 77), - attemptEvent('run-parent', 'attempt-parent', 10, 'completed', 'model-parent', 40, 200), + meteringEvent('run-1', 'attempt-1', 10, 'model-old', 10, 100), + attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model-old', 10, 100, [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 't', bytes: 800, label: 'Bash' }, + ]), + meteringEvent('run-1', 'attempt-2', 20, 'model-new', 40, 200), ], }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'model-new'); + assert.equal(diagnostics.composition, undefined); +}); + +test('a compaction call never becomes the reported context', async () => { + const store = runStore([ { - header: { ...runHeader('run-child', 2), parentRunId: 'run-parent' }, + header: runHeader('run-1', 1), events: [ - checkpointEvent('run-child', 8, 99, 9, 999), - attemptEvent('run-child', 'attempt-child', 20, 'completed', 'model-child', 50, 500), + meteringEvent('run-1', 'attempt-1', 10, 'model-main', 40, 200), + meteringEvent('run-1', 'attempt-2', 20, 'model-compact', 5, 200, { + callKind: 'history_compact', + }), ], }, ]); @@ -62,14 +239,7 @@ test('ignores non-inline child runs when reading session context', async () => { assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; - assert.equal(diagnostics.modelId, 'model-parent'); - assert.deepEqual(diagnostics.compaction, { - kind: 'history', - phase: 'pre_turn', - eventCount: 12, - turnCount: 3, - estimatedTokens: 77, - }); + assert.equal(diagnostics.modelId, 'model-main'); }); test('reports that no completed request exists instead of inferring session values', async () => { @@ -78,39 +248,22 @@ test('reports that no completed request exists instead of inferring session valu 'session-1', ); - assert.deepEqual(diagnostics, { - status: 'unavailable', - reason: 'no_completed_request', - }); + assert.deepEqual(diagnostics, { status: 'unavailable', reason: 'no_completed_request' }); }); -test('does not fall back when the latest completed request trace is invalid', async () => { - const older = attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model-old', 10, 100); - const invalidLatest = attemptEvent('run-1', 'attempt-2', 20, 'completed', 'model-new', 20, 200); - invalidLatest.data = { - ...invalidLatest.data, - segments: 'invalid', - } as AgentRunEvent['data']; - - const diagnostics = await readLatestContextDiagnostics( - runStore([{ header: runHeader('run-1', 1), events: [older, invalidLatest] }]), - 'session-1', - ); - - assert.deepEqual(diagnostics, { - status: 'unavailable', - reason: 'trace_unavailable', - }); -}); - -test('reports the latest history compaction that preceded the displayed request', async () => { +test('a rebuilt session reports the fold that was in place when its request started', async () => { + // The warm path seals the boundary the prompt was built under; the cold path + // has to reach the same description from the ledger alone. A checkpoint + // written AFTER the request started belongs to a later prompt, so the scan + // takes the newest one at or before the anchor's start — the same "no field + // here describes a different request" rule the sealed row enforces. const store = runStore([ { header: runHeader('run-1', 1), events: [ - checkpointEvent('run-1', 5, 12, 3, 77), - attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model', 40, 200), - checkpointEvent('run-1', 12, 20, 5, 88), + checkpointEvent('run-1', 5, 12, 3, 900), + meteringEvent('run-1', 'attempt-1', 20, 'model-new', 40, 200), + checkpointEvent('run-1', 25, 40, 9, 2_400), ], }, ]); @@ -124,20 +277,173 @@ test('reports the latest history compaction that preceded the displayed request' phase: 'pre_turn', eventCount: 12, turnCount: 3, - estimatedTokens: 77, + estimatedTokens: 900, }); }); -test('reads the same diagnostics after reopening the durable run ledger', async () => { +test('a canonical ledger with nothing reportable does not fall back to a provider row', async () => { + // `anchor` only ever holds a completed MAIN call, so a session whose + // canonical records are all failed, aborted, a compaction's own request, or + // undecodable leaves it empty — which is not the same as having no canonical + // metering at all. Treating the two alike would let the compatibility path + // resurrect exactly the request the canonical rule declined to report. + const store = runStore([ + { + header: runHeader('run-1', 1), + events: [ + meteringEvent('run-1', 'attempt-1', 10, 'model-failed', 40, 200, { status: 'failed' }), + meteringEvent('run-1', 'attempt-2', 15, 'model-compact', 5, 200, { + callKind: 'history_compact', + }), + { + type: 'model_call_attempt_recorded', + id: 'metering-junk', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-run-1', + ts: 18, + data: { schemaVersion: 1 }, + }, + attemptEvent('run-1', 'attempt-3', 30, 'completed', 'model-provider-only', 50, 200), + ], + }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.deepEqual(diagnostics, { status: 'unavailable', reason: 'no_completed_request' }); +}); + +test('warm and cold agree on which of two requests that finished together is the latest', async () => { + // Two records sharing a completion millisecond. The tie has to break the + // same way in both writers — the storage transaction comparing an arriving + // commit against the stored row, and the scan comparing every record of a + // ledger — or a rebuilt session would disagree with a live one about which + // request the panel is describing. + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + // Appended greater-id first, so a rule that simply kept the last write + // would answer 'model-a' here and disagree with the scan below. + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-b', 10, 'model-b', 40, 200), + { durable: true, latestContext: latestContext('attempt-b', 10, 'model-b') }, + ); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-a', 10, 'model-a', 40, 200), + { durable: true, latestContext: latestContext('attempt-a', 10, 'model-a') }, + ); + + const reader = createSqliteAgentRunStore(root); + const warm = await readLatestContextDiagnostics(reader, 'session-1'); + // The same ledger read by a session whose projection was never + // initialized: the answer has to come out identical. + const cold = await readLatestContextDiagnostics( + { + listSessionRuns: (sessionId) => reader.listSessionRuns(sessionId), + readEvents: (sessionId, runId) => reader.readEvents(sessionId, runId), + }, + 'session-1', + ); + + assert.equal(warm.status, 'available'); + assert.equal(cold.status, 'available'); + if (warm.status !== 'available' || cold.status !== 'available') return; + assert.equal(warm.modelId, 'model-b', 'the tie breaks on the attempt id, not on arrival'); + assert.equal(cold.modelId, warm.modelId); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a session confirmed to have nothing is answered from the projection, not re-scanned', async () => { + // `null` is a decided answer, and the only thing that stops a session with + // no completed request from scanning its whole ledger on every refresh. + // `undefined` — never decided — is what still earns a scan. + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + + let scanned = 0; + const counted = countingStore(createSqliteAgentRunStore(root), () => { + scanned += 1; + }); + + const cold = await readLatestContextDiagnostics(counted, 'session-1'); + assert.deepEqual(cold, { status: 'unavailable', reason: 'no_completed_request' }); + assert.ok(scanned > 0, 'an uninitialized projection is not an answer'); + + scanned = 0; + const warm = await readLatestContextDiagnostics(counted, 'session-1'); + assert.deepEqual(warm, { status: 'unavailable', reason: 'no_completed_request' }); + assert.equal(scanned, 0, 'the initialized-empty projection answers on its own'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('names at most the bounded number of tools, and accounts for the rest', async () => { + // The 257th tool used to fail the whole query at the wire decoder. The fold + // bounds it instead, so a large registry summarises rather than breaks. + const segments = Array.from({ length: 300 }, (_, index) => ({ + kind: 'tool_schema', + index, + cacheable: true, + hash: `t${index}`, + bytes: 300 - index, + label: `tool-${String(index).padStart(3, '0')}`, + })); + const store = runStore([ + { + header: runHeader('run-1', 1), + events: [ + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200), + attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model', 40, 200, segments), + ], + }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + const composition = diagnostics.composition; + assert.equal(composition?.tools?.length, 64); + assert.equal(composition?.remainingTools?.count, 236); + const named = composition!.tools!.reduce((carry, tool) => carry + tool.bytes, 0); + assert.equal( + named + composition!.remainingTools!.bytes, + composition!.segments.find((segment) => segment.kind === 'tool_definitions')?.bytes, + 'named rows plus the remainder account for every tool byte', + ); +}); + +test('a request that finished earlier cannot move the answer backwards', async () => { + // Overlapping turns append on independent queues, so arrival order is not + // completion order. The projection is monotonic on the request's own + // completion, or a late arrival would permanently rewind the panel. const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - const header = runHeader('run-1', 1); - await writer.createRun(header); + await writer.createRun(runHeader('run-1', 1)); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'late', 20, 'model-newer', 40, 200), + { durable: true, latestContext: latestContext('late', 20, 'model-newer') }, + ); + // Appended second, but it finished FIRST. await writer.appendEvent( 'session-1', 'run-1', - attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model', 40, 200), + meteringEvent('run-1', 'early', 10, 'model-older', 40, 200), + { durable: true, latestContext: latestContext('early', 10, 'model-older') }, ); const diagnostics = await readLatestContextDiagnostics( @@ -147,13 +453,52 @@ test('reads the same diagnostics after reopening the durable run ledger', async assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; - assert.equal(diagnostics.inputTokens, 40); - assert.equal(diagnostics.contextWindow, 200); + assert.equal(diagnostics.modelId, 'model-newer'); } finally { await rm(root, { recursive: true, force: true }); } }); +function countingStore( + reader: ReturnType, + onScan: () => void, +): Parameters[0] { + return { + listSessionRuns: (sessionId) => reader.listSessionRuns(sessionId), + readEvents: async (sessionId, runId) => { + onScan(); + return reader.readEvents(sessionId, runId); + }, + readEventProjection: (sessionId, type) => reader.readEventProjection(sessionId, type), + repairEventProjection: (sessionId, type, event, options) => + reader.repairEventProjection(sessionId, type, event, options), + }; +} + +/** + * The derived row as the canonical append commits it — the same shape the + * store writes inside that transaction, never a separate ledger record. + */ +function latestContext(attemptId: string, completedAt: number, modelId = 'model') { + return { + attemptId, + orderedAt: completedAt, + snapshot: { + schemaVersion: 1, + attemptId, + providerId: 'anthropic', + modelId, + completedAt, + inputTokens: 40, + contextWindow: 200, + composition: { + segments: [{ kind: 'tool_definitions', bytes: 800 }], + tools: [{ name: 'Bash', bytes: 800 }], + }, + }, + }; +} + function runStore( runs: Array<{ header: AgentRunHeader; events: AgentRunEvent[] }>, ): Pick { @@ -221,6 +566,54 @@ function attemptEvent( }; } +/** + * The DURABLE metering record. It is the anchor now: identity and every + * provider-reported number come from here, and the best-effort capture only + * gets to describe the request this one names (#2323). + */ +function meteringEvent( + runId: string, + attemptId: string, + completedAt: number, + modelId: string, + inputTokens: number | undefined, + contextWindow: number | undefined, + overrides: Record = {}, +): EmittedAgentRunEvent { + const turnId = `turn-${runId}`; + return { + type: 'model_call_attempt_recorded', + id: `metering-${attemptId}`, + runId, + sessionId: 'session-1', + turnId, + ts: completedAt, + data: { + schemaVersion: 1, + logicalCallId: `call-${attemptId}`, + attemptId, + traceId: `trace-${attemptId}`, + sessionId: 'session-1', + runId, + turnId, + step: 0, + attempt: 0, + callKind: 'main', + providerId: 'anthropic', + modelId, + startedAt: completedAt - 1, + completedAt, + latencyMs: 1, + status: 'completed', + usageBasis: 'reported', + costBasis: 'unpriced', + ...(inputTokens === undefined ? {} : { inputTokens }), + ...(contextWindow === undefined ? {} : { contextWindow }), + ...overrides, + }, + }; +} + function checkpointEvent( runId: string, ts: number, diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts index 2d00c80bd3..a71c2a3f4b 100644 --- a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -1,3 +1,4 @@ +import type { ModelCallCommit } from '@maka/core/agent-run'; /** * Tests for buildLlmHistorySummarizer — the AI-SDK-backed LLM summary that * replaces the deterministic excerpt draft when wiring injects it. @@ -104,7 +105,7 @@ describe('buildLlmHistorySummarizer', () => { connectionSlug: 'connection', providerId: 'provider', callKind: 'history_compact', - record: (attempt: ModelCallAttempt) => { + record: ({ attempt }: ModelCallCommit) => { recorded.push(attempt); }, }, diff --git a/packages/runtime/src/__tests__/latest-context-commit.test.ts b/packages/runtime/src/__tests__/latest-context-commit.test.ts new file mode 100644 index 0000000000..ecff35ce09 --- /dev/null +++ b/packages/runtime/src/__tests__/latest-context-commit.test.ts @@ -0,0 +1,164 @@ +/** + * The commit crossing the production chain (#2323). + * + * The previous shape passed `latestContext` as a second argument, and every + * layer between the tracker and storage declared a one-argument callback — + * JavaScript dropped the extra argument, TypeScript accepted the narrower + * signature, and the derived row never reached the store in production while + * every storage-level test kept passing by injecting it directly. + * + * So the test that matters here is the one that injects nothing: a real send, + * through the real seams, read back the way the panel reads it. + */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; +import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; +import type { ModelCallCommit } from '@maka/core/agent-run'; +import { + createSessionStore, + createSqliteAgentRunStore, + createWorkspaceRuntimeStore, +} from '@maka/storage'; +import { BackendRegistry, SessionManager } from '../session-manager.js'; +import { readLatestContextDiagnostics } from '../context-diagnostics.js'; +import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; + +test('a real send seals its row all the way into SQLite, with nothing injected', async () => { + // Tracker → backend → the kernel seam a backend is actually built with → + // AgentRun → the storage transaction. Every layer in that list once had a + // signature that compiled while dropping the row, and no test crossed all of + // them: they each started from a `latestContext` handed straight to storage. + const root = await mkdtemp(join(tmpdir(), 'maka-latest-context-chain-')); + try { + const sessionStore = createSessionStore(root); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const backends = new BackendRegistry(); + let ids = 0; + const newId = () => `chain-${++ids}`; + let clock = 1_000; + const now = () => (clock += 1); + + backends.register('fake', (ctx) => + createTestAiSdkBackend({ + sessionId: ctx.sessionId, + header: ctx.header, + appendMessage: async () => {}, + connection: { + slug: 'mock-main', + providerType: 'anthropic', + defaultModel: 'mock-model-id', + models: [{ id: 'mock-model-id', contextWindow: 200_000 }], + }, + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => answeringModel(), + tools: [], + // The seams the kernel hands a real backend, forwarded exactly as the + // production composition forwards them — this is the hop that broke. + ...(ctx.recordModelCallAttempt + ? { recordModelCallAttempt: ctx.recordModelCallAttempt } + : {}), + newId, + now, + }), + ); + + const manager = new SessionManager({ + store: sessionStore, + runStore, + runtimeEventStore, + backends, + newId, + now, + }); + const session = await manager.createSession({ + cwd: root, + backend: 'fake', + llmConnectionSlug: 'mock-main', + permissionMode: 'bypass', + }); + for await (const _event of manager.sendMessage(session.id, { + turnId: 'turn-1', + text: 'what is my context made of?', + })) { + // Drain the turn so its run reaches the durable ledger. + } + + let scanned = 0; + const diagnostics = await readLatestContextDiagnostics( + { + listSessionRuns: (sessionId) => runStore.listSessionRuns(sessionId), + readEvents: async (sessionId, runId) => { + scanned += 1; + return runStore.readEvents(sessionId, runId); + }, + readEventProjection: (sessionId, type) => runStore.readEventProjection(sessionId, type), + repairEventProjection: (sessionId, type, event, options) => + runStore.repairEventProjection(sessionId, type, event, options), + }, + session.id, + ); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.equal(diagnostics.modelId, 'mock-model-id'); + assert.equal(diagnostics.inputTokens, 120, 'the metered numbers are the ones sealed'); + assert.equal(diagnostics.contextWindow, 200_000); + assert.ok( + diagnostics.composition?.segments.some((segment) => segment.kind === 'messages'), + 'and the request describes what it was made of', + ); + assert.equal(scanned, 0, 'the row was committed by the send, not rebuilt by the read'); + + await manager.stopSession(session.id, { source: 'stop_button' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a layer that forwards only the attempt no longer type-checks', () => { + // The regression this replaces was invisible precisely because it compiled. + // Keeping the shape in a value here means a future narrowing is a build + // failure rather than a silently missing feature. + const forward = (commit: ModelCallCommit) => commit; + const commit = { + attempt: { attemptId: 'a-1' } as ModelCallAttempt, + latestContext: { attemptId: 'a-1', orderedAt: 10, snapshot: { attemptId: 'a-1' } }, + } satisfies ModelCallCommit; + + const forwarded = forward(commit); + + assert.equal(forwarded.latestContext?.attemptId, 'a-1', 'the derived row survives the hop'); + assert.equal(forwarded.attempt.attemptId, 'a-1'); +}); + +function answeringModel(): MockLanguageModelV4 { + return new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'system instructions, tools and messages.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 120, noCache: 120, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 9, text: 9, reasoning: 0 }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); +} diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index b473c8ca91..8ba6bcc2da 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1,3 +1,4 @@ +import type { ModelCallCommit } from '@maka/core/agent-run'; import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { setImmediate as flushMacrotask } from 'node:timers/promises'; @@ -24,6 +25,10 @@ import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; import { HistoryCompactSummarizerError } from '../history-compact-error.js'; import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; +import { + LATEST_CONTEXT_PROJECTION_TYPE, + readLatestContextSnapshot, +} from '../latest-context-snapshot.js'; import { createTestAiSdkBackend, testToolResultArchive, @@ -49,6 +54,11 @@ interface MidTurnFixture { ledger: RuntimeEvent[]; /** Canonical accounting records settled during the turn (#1679). */ modelCalls: ModelCallAttempt[]; + /** + * The same settlements as whole commits, so a test can read the derived + * latest-context row the attempt authorised rather than only the attempt. + */ + commits: ModelCallCommit[]; ledgerReads: number; events: SessionEvent[]; messages: unknown[]; @@ -285,6 +295,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { }; const modelCalls: ModelCallAttempt[] = []; + const commits: ModelCallCommit[] = []; const summarizerModel = new MockLanguageModelV4({ doGenerate: { content: [{ type: 'text', text: 'MID_TURN_SUMMARY_SENTINEL' }], @@ -417,8 +428,9 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { ...(options.meteredSummarizer ? { recordProviderRequestCapture: async () => ({ artifactId: 'artifact-mid-turn-capture' }), - recordModelCallAttempt: (attempt: ModelCallAttempt) => { - modelCalls.push(attempt); + recordModelCallAttempt: (commit: ModelCallCommit) => { + commits.push(commit); + modelCalls.push(commit.attempt); }, } : {}), @@ -465,6 +477,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { anchor, ledger, modelCalls, + commits, events, messages, llmCalls, @@ -607,6 +620,44 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { ); }); + test('each request seals the fold its own prompt was built under, not the send’s last one', async () => { + // The boundary is the one fact in the snapshot that moves DURING a send. + // Read from session state at settlement it would be the newest fold for + // every request of the send, including the two dispatched before the fold + // existed — a request reporting a compaction its prompt never saw. So this + // asserts the difference between requests of ONE send, which is the only + // assertion a per-tracker value could not also satisfy (#2323). + const fixture = buildFixture({ meteredSummarizer: true }); + await runFixtureTurn(fixture, consumer); + + assert.equal(fixture.recorded.length, 1, 'one mid-turn fold in this send'); + const checkpoint = fixture.recorded[0]!; + const mainCommits = fixture.commits + .filter((commit) => decodeModelCallAttempt(commit.attempt).callKind === 'main') + .sort((a, b) => a.attempt.step - b.attempt.step); + assert.equal(mainCommits.length, 3, 'three physical requests, three sealed rows'); + + const boundaryOf = (commit: (typeof mainCommits)[number]) => + readLatestContextSnapshot({ + type: LATEST_CONTEXT_PROJECTION_TYPE, + data: commit.latestContext?.snapshot, + })?.compaction; + + assert.equal(boundaryOf(mainCommits[0]!), undefined, 'nothing was folded yet'); + assert.equal(boundaryOf(mainCommits[1]!), undefined, 'still nothing at the second request'); + assert.deepEqual( + boundaryOf(mainCommits[2]!), + { + kind: 'history', + phase: 'mid_turn', + eventCount: checkpoint.coverage.eventCount, + turnCount: checkpoint.coverage.turnCount, + estimatedTokens: checkpoint.estimatedTokens, + }, + 'the request built after the fold reports that fold, in the checkpoint’s own numbers', + ); + }); + test('recovery re-projection with ctx.branch replays the checkpoint without the raw span', async () => { const fixture = buildFixture({ branch: 'lane-7' }); await runFixtureTurn(fixture, consumer); diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index b5b885d9b0..73bea6387d 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -8,10 +8,19 @@ import type { SessionHeader } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { z } from 'zod'; +import type { ModelCallCommit } from '@maka/core/agent-run'; +import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; import { AiSdkBackend } from '../ai-sdk-backend.js'; +import { + LATEST_CONTEXT_PROJECTION_TYPE, + readLatestContextSnapshot, +} from '../latest-context-snapshot.js'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; import type { InvocationContext } from '../invocation-context.js'; -import type { HistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; +import { + buildHistoryCompactCheckpoint, + type HistoryCompactCheckpoint, +} from '../history-compact-checkpoint.js'; import { createTestAiSdkBackend, testToolResultArchive, @@ -109,6 +118,18 @@ interface ReactiveFixtureOptions { providerRetrySleep?: (delayMs: number, signal: AbortSignal) => Promise; /** Override the stream idle watchdog for retry-wait coordination tests. */ streamIdleTimeoutMs?: number; + /** + * Wire the canonical metering sink, so the fixture collects the commits a + * settled request produces. Off by default: it is the accounting path, and + * the recovery tests around it assert provider behaviour, not billing. + */ + canonicalAccounting?: boolean; + /** + * A durable checkpoint the session already holds, served through the loader + * seam exactly as the kernel serves one written by an earlier turn. Read at + * send time, so a test can build it from the fixture's own prior events. + */ + loadCheckpoint?: () => HistoryCompactCheckpoint | undefined; } interface ReactiveLlmCall { @@ -129,6 +150,8 @@ interface ReactiveFixture { priorEvents: RuntimeEvent[]; events: SessionEvent[]; llmCalls: ReactiveLlmCall[]; + /** Canonical settlements, whole, when `canonicalAccounting` is on. */ + commits: ModelCallCommit[]; retryDelays: number[]; /** JSON of each summarizer call's folded runtime events (coverage evidence). */ summarizedSources: string[]; @@ -139,6 +162,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture const contextWindow = options.contextWindow ?? 200_000; const reserveTokens = options.reserveTokens ?? 1_000; const recorded: HistoryCompactCheckpoint[] = []; + const commits: ModelCallCommit[] = []; const toolExecutions: string[] = []; const events: SessionEvent[] = []; const llmCalls: ReactiveLlmCall[] = []; @@ -456,6 +480,16 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture : {}), ...durableReader, ...compactionSeams, + ...(options.canonicalAccounting + ? { + recordModelCallAttempt: (commit: ModelCallCommit) => { + commits.push(commit); + }, + } + : {}), + ...(options.loadCheckpoint + ? { loadHistoryCompactCheckpoint: async () => options.loadCheckpoint!() } + : {}), // The send-level record is gone (#1679); its diagnostics moved to the run // trace, which is what these assertions observe now. recordRunTrace: (event) => { @@ -489,6 +523,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture priorEvents, events, llmCalls, + commits, retryDelays, summarizedSources, persist, @@ -525,6 +560,17 @@ async function runTurn( } } +/** The compaction each sealed row reports, in settlement order. */ +function boundariesOf(commits: readonly ModelCallCommit[]) { + return commits.map( + (commit) => + readLatestContextSnapshot({ + type: LATEST_CONTEXT_PROJECTION_TYPE, + data: commit.latestContext?.snapshot, + })?.compaction, + ); +} + function complete( fixture: ReactiveFixture, ): Extract | undefined { @@ -871,6 +917,139 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(successorPrompt.includes(RAW_SPAN_ONE), true); }); + test('the resend seals the fold recovery made for it, and the request before it seals none', async () => { + // Recovery is the other place the boundary moves between two dispatches of + // one send, and the harder one: the fold happens after a request was + // already built, dispatched and rejected. The rejected request settles no + // sealed row (it never completed), and the resend must report the fold it + // was rebuilt from rather than the state the send started in (#2323). + const fixture = buildReactiveFixture({ + script: ['tool', 'overflow', 'done'], + bigPriors: true, + canonicalAccounting: true, + }); + await runTurn(fixture); + + assert.equal(fixture.model.doStreamCalls.length, 3); + assert.equal(fixture.recorded.length, 1); + const checkpoint = fixture.recorded[0]!; + assert.equal(checkpoint.phase, 'mid_turn'); + + const sealed = fixture.commits.filter((commit) => commit.latestContext !== undefined); + assert.equal(sealed.length, 2, 'the rejected request seals nothing; the other two do'); + for (const commit of sealed) { + const attempt = decodeModelCallAttempt(commit.attempt); + assert.equal(attempt.callKind, 'main'); + assert.equal(attempt.status, 'completed'); + } + assert.deepEqual(boundariesOf(sealed), [ + undefined, + { + kind: 'history', + phase: 'mid_turn', + eventCount: checkpoint.coverage.eventCount, + turnCount: checkpoint.coverage.turnCount, + estimatedTokens: checkpoint.estimatedTokens, + }, + ]); + }); + + test('a checkpoint carried in from an earlier turn is the boundary of a send that folds nothing', async () => { + // The ordinary case, and the only one where the boundary comes from the + // pre-turn projection rather than from mid-turn state: nothing compacts + // during this send, so what the prompt stands on is what the session + // carried into it. + let carried: HistoryCompactCheckpoint | undefined; + const fixture = buildReactiveFixture({ + script: ['done'], + midTurnEnabled: false, + canonicalAccounting: true, + // Large enough that folding them is a real saving: the replay refuses a + // checkpoint that would not shrink the history it replaces. + bigPriors: true, + loadCheckpoint: () => carried, + }); + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: fixture.priorEvents, + summary: 'EARLIER_TURN_SUMMARY', + }); + carried = checkpoint; + await runTurn(fixture); + + const prompt = JSON.stringify(fixture.model.doStreamCalls[0]?.prompt); + assert.equal(prompt.includes('EARLIER_TURN_SUMMARY'), true, 'the fold is in the prompt'); + assert.equal(prompt.includes('PRIOR_FACT'), false, 'and the raw prefix it replaced is not'); + assert.deepEqual(boundariesOf(fixture.commits), [ + { + kind: 'history', + phase: 'pre_turn', + eventCount: checkpoint.coverage.eventCount, + turnCount: checkpoint.coverage.turnCount, + estimatedTokens: checkpoint.estimatedTokens, + }, + ]); + }); + + test('a loaded checkpoint the projection refused is not reported as the boundary', async () => { + // The difference between the checkpoint a session HOLDS and the one a + // prompt was BUILT from. This one covers an event the ledger does not + // have, so the prefix match fails and the replay keeps the raw prior + // history — reporting it would describe a fold the request never had. + const foreign = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [ + runtimeTextEvent('never-happened', 'turn-x', 'user', 'AN EVENT THIS LEDGER NEVER HELD'), + ], + summary: 'SUMMARY_OF_ANOTHER_HISTORY', + }); + const fixture = buildReactiveFixture({ + script: ['done'], + midTurnEnabled: false, + canonicalAccounting: true, + // Same priors as the accepted case above, so the refusal can only be the + // coverage miss and not a fold that failed to pay for itself. + bigPriors: true, + loadCheckpoint: () => foreign, + }); + await runTurn(fixture); + + const prompt = JSON.stringify(fixture.model.doStreamCalls[0]?.prompt); + assert.equal(prompt.includes('SUMMARY_OF_ANOTHER_HISTORY'), false, 'the fold was refused'); + assert.equal(prompt.includes('PRIOR_FACT'), true, 'so the raw history is what was sent'); + assert.deepEqual(boundariesOf(fixture.commits), [undefined]); + }); + + test('a step-0 recovery fold is sealed as pre_turn by the request it rebuilt', async () => { + // The same seam with the other phase, and the only path that reaches a + // pre-turn boundary without a prior turn having written one: nothing had + // completed when the provider rejected, so recovery folds prior history + // alone and both later requests were built under that one boundary. + const fixture = buildReactiveFixture({ + script: ['overflow', 'tool', 'done'], + bigPriors: true, + canonicalAccounting: true, + }); + await runTurn(fixture); + + assert.equal(fixture.recorded.length, 1); + const checkpoint = fixture.recorded[0]!; + assert.equal(checkpoint.phase, undefined, 'a step-0 fold carries no mid_turn phase'); + + const preTurn = { + kind: 'history', + phase: 'pre_turn', + eventCount: checkpoint.coverage.eventCount, + turnCount: checkpoint.coverage.turnCount, + estimatedTokens: checkpoint.estimatedTokens, + }; + assert.deepEqual( + boundariesOf(fixture.commits.filter((commit) => commit.latestContext !== undefined)), + [preTurn, preTurn], + 'the retry and its successor were both built under the recovery fold', + ); + }); + test('does not retry an overflow after an after-step stop is requested', async () => { let signalSecondStream!: () => void; let releaseSecondStream!: () => void; diff --git a/packages/runtime/src/__tests__/prompt-composition.test.ts b/packages/runtime/src/__tests__/prompt-composition.test.ts new file mode 100644 index 0000000000..a3a196ab44 --- /dev/null +++ b/packages/runtime/src/__tests__/prompt-composition.test.ts @@ -0,0 +1,204 @@ +/** + * Prompt composition — what one request's prompt was made of (#2323). + * + * Run: `npm --workspace @maka/runtime run test` + */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + foldPromptComposition, + readPromptCompositionEvent, + PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, +} from '../prompt-composition.js'; +import type { SizedRequestSegment } from '../prompt-composition.js'; +import { capturePreparedProviderRequest } from '../request-shape.js'; + +function segment(overrides: Partial = {}): SizedRequestSegment { + return { kind: 'message', bytes: 10, ...overrides }; +} + +describe('foldPromptComposition', () => { + test('folds kinds into the vocabulary /context already uses', () => { + const composition = foldPromptComposition([ + segment({ kind: 'system_prompt', bytes: 400 }), + segment({ kind: 'tool_schema', bytes: 300, label: 'Bash' }), + segment({ kind: 'message', bytes: 200 }), + segment({ kind: 'provider_options', bytes: 100 }), + ]); + + assert.deepEqual(composition?.segments, [ + { kind: 'system_instructions', bytes: 400 }, + { kind: 'tool_definitions', bytes: 300 }, + { kind: 'messages', bytes: 200 }, + { kind: 'other', bytes: 100 }, + ]); + }); + + test('sizes each tool on its own, largest first', () => { + const composition = foldPromptComposition([ + segment({ kind: 'tool_schema', bytes: 300, label: 'Read' }), + segment({ kind: 'tool_schema', bytes: 900, label: 'Bash' }), + segment({ kind: 'tool_schema', bytes: 300, label: 'Edit' }), + ]); + + // Ties broken by name so two reads of one session order identically. + assert.deepEqual(composition?.tools, [ + { name: 'Bash', bytes: 900 }, + { name: 'Edit', bytes: 300 }, + { name: 'Read', bytes: 300 }, + ]); + assert.deepEqual(composition?.segments, [{ kind: 'tool_definitions', bytes: 1500 }]); + }); + + test('counts unnamed tool schemas without inventing a name for them', () => { + const composition = foldPromptComposition([ + segment({ kind: 'tool_schema', bytes: 500, label: 'Bash' }), + segment({ kind: 'tool_schema', bytes: 250 }), + ]); + + assert.deepEqual(composition?.tools, [{ name: 'Bash', bytes: 500 }]); + assert.equal(composition?.unlabelledToolBytes, 250); + // The kind total still holds every byte, named or not. + assert.deepEqual(composition?.segments, [{ kind: 'tool_definitions', bytes: 750 }]); + }); + + test('drops a kind nothing contributed to instead of showing it as zero', () => { + const composition = foldPromptComposition([ + segment({ kind: 'system_prompt', bytes: 400 }), + segment({ kind: 'message', bytes: 0 }), + ]); + + assert.deepEqual(composition?.segments, [{ kind: 'system_instructions', bytes: 400 }]); + }); + + test('no segments is no composition, not an empty one', () => { + assert.equal(foldPromptComposition([]), undefined); + }); + + test('omits the tool list when nothing was a tool', () => { + const composition = foldPromptComposition([segment({ kind: 'message', bytes: 10 })]); + + assert.equal(composition?.tools, undefined); + assert.equal(composition?.unlabelledToolBytes, undefined); + }); +}); + +describe('a real capture survives the whole chain into one fold', () => { + test('capture -> JSON -> decode -> fold keeps the same breakdown', () => { + // Every other test here writes its own segments, so a field renamed on one + // side and not the other would pass all of them; and the decode side reads + // `label` and `bytes` off an untyped record, so a hand-written fixture + // agrees with itself by construction. This is the one test where the + // writer, the storage encoding, the reader and the fold all meet. + const capture = capturePreparedProviderRequest({ + providerId: 'anthropic', + modelId: 'claude-test', + instructions: 'you are a helpful assistant', + messages: [{ role: 'user', content: 'hello' }], + tools: [ + { name: 'Bash', description: 'Run a command', inputSchema: { type: 'object' } }, + { name: 'Read', inputSchema: { type: 'object' } }, + ], + providerOptions: { anthropic: { thinking: { type: 'enabled' } } }, + }); + + const composition = foldPromptComposition(capture.segments); + + assert.deepEqual( + composition?.tools?.map((tool) => tool.name), + ['Bash', 'Read'], + 'the tool names survive capture, storage shape and fold', + ); + assert.equal(composition?.unlabelledToolBytes, undefined, 'both tools were named'); + assert.deepEqual( + composition?.segments.map((part) => part.kind), + ['system_instructions', 'tool_definitions', 'messages', 'other'], + ); + assert.equal( + composition?.segments.find((part) => part.kind === 'tool_definitions')?.bytes, + composition!.tools!.reduce((carry, tool) => carry + tool.bytes, 0), + 'the per-tool rows sum to the tool total above them', + ); + + const stored = JSON.parse( + JSON.stringify({ + type: PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, + data: { + attemptId: 'attempt-1', + requestBytes: capture.requestBytes, + segments: capture.segments, + }, + }), + ); + + assert.deepEqual( + readPromptCompositionEvent(stored)?.composition, + composition, + 'and the ledger round-trip changes none of it', + ); + }); +}); + +describe('readPromptCompositionEvent', () => { + const event = (data: unknown) => ({ type: PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, data }); + + test('reads an attempt capture into its composition', () => { + const read = readPromptCompositionEvent( + event({ + attemptId: 'attempt-1', + requestBytes: 900, + segments: [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 'h', bytes: 800, label: 'Bash' }, + ], + }), + ); + + assert.equal(read?.attemptId, 'attempt-1'); + assert.deepEqual(read?.composition.tools, [{ name: 'Bash', bytes: 800 }]); + }); + + test('ignores every other event on the stream', () => { + assert.equal( + readPromptCompositionEvent({ type: 'model_call_attempt_recorded', data: { attemptId: 'a' } }), + undefined, + ); + }); + + test('drops the whole composition when one segment will not decode', () => { + // A partial fold would put every share of this request out by the missing + // segment's size, which is worse than having no breakdown at all. + const read = readPromptCompositionEvent( + event({ + attemptId: 'attempt-1', + requestBytes: 900, + segments: [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 'h', bytes: 800, label: 'Bash' }, + { kind: 'tool_schema', index: 1, cacheable: true, hash: 'h', bytes: 'lots' }, + ], + }), + ); + + assert.equal(read, undefined); + }); + + test('requires an attempt to attach to', () => { + assert.equal( + readPromptCompositionEvent(event({ requestBytes: 10, segments: [segment()] })), + undefined, + ); + }); + + test('rejects a non-string label rather than coercing it', () => { + const read = readPromptCompositionEvent( + event({ + attemptId: 'attempt-1', + requestBytes: 10, + segments: [ + { kind: 'tool_schema', index: 0, cacheable: true, hash: 'h', bytes: 10, label: 7 }, + ], + }), + ); + + assert.equal(read, undefined); + }); +}); diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index d4e52757ed..9e09a40275 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -1,3 +1,4 @@ +import type { ModelCallCommit } from '@maka/core/agent-run'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; @@ -813,7 +814,7 @@ async function drain(stream: ReadableStream): Promise { describe('canonical model-call accounting', () => { function accountingTracker(overrides: { - record: (attempt: ModelCallAttempt) => void | Promise; + record: ({ attempt }: ModelCallCommit) => void | Promise; resolveCost?: telemetry.ModelCallAccountingInput['resolveCost']; assertReady?: () => void; resolveRunId?: () => string | undefined; @@ -845,8 +846,8 @@ describe('canonical model-call accounting', () => { test('emits a decodable priced record for a completed call', async () => { const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, resolveCost: () => ({ costUsd: 0.002, pricingRevision: 4 }), }); @@ -878,8 +879,8 @@ describe('canonical model-call accounting', () => { // nobody made. `missing` says the call happened and the meter did not read. const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, resolveCost: () => ({ costUsd: 0.002, pricingRevision: 4 }), }); @@ -909,8 +910,8 @@ describe('canonical model-call accounting', () => { const attempts: telemetry.ProviderRequestAttemptRecord[] = []; const tracker = accountingTracker({ withoutCapture: true, - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, recordAttempt: (a) => { attempts.push(a); @@ -939,8 +940,8 @@ describe('canonical model-call accounting', () => { test('an unresolvable price records unpriced rather than zero', async () => { const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, resolveCost: () => undefined, }); @@ -961,8 +962,8 @@ describe('canonical model-call accounting', () => { test('retries of one step share a logicalCallId and increment the ordinal', async () => { const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, }); @@ -1030,8 +1031,8 @@ describe('canonical model-call accounting', () => { const recorded: ModelCallAttempt[] = []; const controller = new AbortController(); const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, resolveCost: () => ({ costUsd: 0.003 }), }); @@ -1070,7 +1071,7 @@ describe('canonical model-call accounting', () => { }); let writes = 0; const tracker = accountingTracker({ - record: async (attempt) => { + record: async ({ attempt }) => { const write = writes; writes += 1; if (write === 0) { @@ -1112,8 +1113,8 @@ describe('canonical model-call accounting', () => { test('no canonical record is emitted without a resolvable run', async () => { const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ - record: (a) => { - recorded.push(a); + record: ({ attempt }) => { + recorded.push(attempt); }, resolveRunId: () => undefined, }); diff --git a/packages/runtime/src/__tests__/request-shape.test.ts b/packages/runtime/src/__tests__/request-shape.test.ts index ad841a3a4c..4a752b003f 100644 --- a/packages/runtime/src/__tests__/request-shape.test.ts +++ b/packages/runtime/src/__tests__/request-shape.test.ts @@ -165,6 +165,29 @@ describe('prepared provider request capture', () => { assert.ok(result.segments.every((segment) => /^sha256:[a-f0-9]{64}$/.test(segment.hash))); }); + test('names a tool schema from the payload, and only that segment kind', () => { + // A size nobody can attribute is not actionable: "tool definitions are 40%" + // names no tool to remove (#2323). + const result = requestShape.capturePreparedProviderRequest({ + providerId: 'anthropic', + modelId: 'claude-test', + instructions: 'system', + messages: [{ role: 'user', content: 'hello' }], + tools: [{ name: 'Bash', inputSchema: { type: 'object' } }, { inputSchema: {} }], + providerOptions: {}, + }); + + const labels = result.segments.map((segment) => [segment.kind, segment.label] as const); + assert.deepEqual(labels, [ + ['tool_schema', 'Bash'], + // A tool the payload does not name gets no invented one. + ['tool_schema', undefined], + ['system_prompt', undefined], + ['message', undefined], + ['provider_options', undefined], + ]); + }); + test('versions and hashes non-provider-options request parameters for comparison', () => { const capture = (providerOptions: Record, maxOutputTokens?: number) => requestShape.capturePreparedProviderRequest({ diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 5a40906423..63e34dd990 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3920,7 +3920,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => resolveModel: () => summarizerModel, }), recordHistoryCompactCheckpoint: () => {}, - recordModelCallAttempt: (attempt) => { + recordModelCallAttempt: ({ attempt }) => { modelCalls.push(attempt); }, }), diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 71ece3ecbe..63159a51f5 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -15,6 +15,8 @@ import { ToolLedgerCorruptionError, ToolLedgerRejectionError, } from '@maka/core/tool-ledger-scanner'; +import type { ModelCallCommit } from '@maka/core/agent-run'; + import { Buffer } from 'node:buffer'; import { isDeepStrictEqual } from 'node:util'; import { redactSecrets } from '@maka/core/redaction'; @@ -468,7 +470,8 @@ export class AgentRun { * handler, and the seam swallows this so a billed, completed response is * never failed by its own bookkeeping. */ - recordModelCallAttempt(attempt: ModelCallAttempt): Promise { + recordModelCallAttempt(commit: ModelCallCommit): Promise { + const { attempt, latestContext } = commit; if (!this.input.runStore) return Promise.resolve(); return this.enqueueRequiredRunStoreWrite('append model call attempt', async () => { await this.input.runStore?.appendEvent( @@ -483,7 +486,10 @@ export class AgentRun { ts: attempt.completedAt, data: { ...attempt }, }, - { durable: true }, + // The latest-context projection rides this durable append rather than + // racing it: one commit for the request, and derived state that cannot + // survive a metering write that failed (#2323). + { durable: true, ...(latestContext ? { latestContext } : {}) }, ); }); } diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 666df5e662..5bd904993e 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -102,6 +102,7 @@ import type { ToolResultOutput, UserContent, } from './model-protocol.js'; +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'; @@ -148,6 +149,10 @@ import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result- import { toolResultOutput } from './tool-result-output.js'; import { buildActiveCompactionHeadAnchor } from './active-full-compact.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; +import { + contextDiagnosticsCompactionOf, + type ContextDiagnosticsCompaction, +} from './context-diagnostics.js'; import type { ProviderImageBudget } from './ai-sdk-compaction.js'; import { AiSdkCompaction, @@ -797,7 +802,12 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { * Canonical metering sink. Separate from `recordProviderRequestAttempt`, which * stays a diagnostic trace: this one carries the accounting record. */ - recordModelCallAttempt?: (attempt: ModelCallAttempt) => void | Promise; + /** + * Commits one settled provider request: the canonical attempt and, when it + * is the completed main call, the derived latest-context row it authorises. + * One object so a layer cannot forward half of it (#2323). + */ + recordModelCallAttempt?: (commit: ModelCallCommit) => void | Promise; /** * Pre-dispatch accounting gate, paired with `recordModelCallAttempt` and read * only when it is present. Throws when the canonical record could not be @@ -1659,6 +1669,27 @@ export class AiSdkBackend implements AgentBackend { // turn start) so a mid-turn summary only re-reads the newly folded span. midTurnState.previousCheckpoint = priorReplay.latestHistoryCompactCheckpoint; } + /** + * 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 () => { @@ -2119,6 +2150,10 @@ export class AiSdkBackend implements AgentBackend { ? nestableToolSnapshot(providerTools, activeToolsForRequest) : 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, @@ -2148,6 +2183,7 @@ export class AiSdkBackend implements AgentBackend { providerRequestAbortController.signal, ]), ...(providerRequestTracker ? { providerRequestTracker } : {}), + ...(historyCompactBoundary ? { historyCompactBoundary } : {}), continuationKey: scope.turnId, }); @@ -3379,7 +3415,11 @@ export class AiSdkBackend implements AgentBackend { } let runtimeContext = budgeted?.events ?? priorRuntimeContext; let contextBudgetDiagnostic = budgeted?.diagnostic; - let latestHistoryCompactCheckpoint = contextBudget?.historyCompact?.checkpoint; + // The checkpoint this projection was replayed THROUGH, not the one the + // policy happens to carry: a loaded checkpoint that missed its prefix or + // failed the replay fit left the raw prefix in these events, and a caller + // asking what the prompt was built from must not be told otherwise (#2323). + let latestHistoryCompactCheckpoint = budgeted?.historyCompactCheckpoint; if (preparedContextBudget.diagnosticPatch) { contextBudgetDiagnostic = mergeContextBudgetDiagnostic( contextBudgetDiagnostic ?? @@ -3421,6 +3461,10 @@ export class AiSdkBackend implements AgentBackend { if (oversizedRetainedTurn && !writePatch.fallbackCheckpoint) { contextBudgetExhaustedDetail = 'summarizer_failed'; } + // Fail-open rebuilds the context around the older checkpoint, so + // that one — not the fold this send failed to write — is the + // boundary the prompt now stands on. + latestHistoryCompactCheckpoint = writePatch.fallbackCheckpoint; runtimeContext = writePatch.fallbackCheckpoint ? buildHistoryCompactCheckpointFailOpenContext( writePatch.fallbackCheckpoint, @@ -3450,7 +3494,10 @@ export class AiSdkBackend implements AgentBackend { writePatch.replacementBlocks, ); } else { + // Back to the raw prior ledger: whatever boundary the projection + // stood on a moment ago is not in this context any more. runtimeContext = priorRuntimeContext; + latestHistoryCompactCheckpoint = undefined; contextBudgetDiagnostic = buildContextBudgetDiagnosticShell( priorRuntimeContext, runtimeContext, @@ -3610,6 +3657,13 @@ export class AiSdkBackend implements AgentBackend { } } + // 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 && latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}; + const plan = buildRuntimeEventModelReplayPlan( runtimeContext, // `runtimeContext` may be a budget/history-search slice; the tool-turn @@ -3627,7 +3681,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3643,7 +3697,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3655,7 +3709,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(true), }; } @@ -3671,7 +3725,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3682,7 +3736,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(true), }; } diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index a3e8404527..270eed99b7 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -165,6 +165,16 @@ export interface BudgetedRuntimeContext { events: RuntimeEvent[]; diagnostic: ContextBudgetDiagnostic; historyCompactBlocks?: HistoryCompactBlock[]; + /** + * The checkpoint this projection was actually replayed through — present only + * when it passed the prefix match and the replay fit, i.e. when these events + * really are `[block, tail]` rather than the raw prefix. + * + * A loaded checkpoint that failed either gate is a checkpoint the caller + * holds and the projection ignored; the two must not be confused by anyone + * reporting what a prompt was built from (#2323). + */ + historyCompactCheckpoint?: HistoryCompactCheckpoint; } export interface PromptSegmentInput { @@ -301,6 +311,7 @@ export function applyRuntimeEventContextBudget( events: keptEvents, diagnostic, ...(compacted.blocks.length > 0 ? { historyCompactBlocks: compacted.blocks } : {}), + ...(compacted.checkpoint ? { historyCompactCheckpoint: compacted.checkpoint } : {}), }; } diff --git a/packages/runtime/src/context-diagnostics.ts b/packages/runtime/src/context-diagnostics.ts index b5e8f6c19b..562672274a 100644 --- a/packages/runtime/src/context-diagnostics.ts +++ b/packages/runtime/src/context-diagnostics.ts @@ -1,4 +1,20 @@ -import { isSessionInlineRun, type AgentRunStore } from '@maka/core/agent-run'; +import { + isSessionInlineRun, + supersedesLatestContext, + type AgentRunEvent, + type AgentRunStore, +} from '@maka/core/agent-run'; +import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; +import { + PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, + readPromptCompositionEvent, +} from './prompt-composition.js'; +import { + LATEST_CONTEXT_PROJECTION_TYPE, + LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION, + readLatestContextSnapshot, + type LatestContextSnapshot, +} from './latest-context-snapshot.js'; import { validateHistoryCompactCheckpointShape, type HistoryCompactCheckpoint, @@ -6,10 +22,29 @@ import { export type ContextDiagnosticsUnavailableReason = 'no_completed_request' | 'trace_unavailable'; +export type ContextDiagnosticsSegmentKind = + | 'system_instructions' + | 'tool_definitions' + | 'messages' + | 'other'; + +/** + * One part of the latest request, measured in bytes of serialized request. + * + * Bytes only. `bytes / 4` is a rule of thumb over serialized JSON — wrong in a + * direction nobody here can correct for, badly so for an attachment's base64 — + * so the estimate is made where it is shown and labelled `≈` there. A figure + * rounded into this contract could no longer be labelled at all (#2323). + */ export interface ContextDiagnosticsSegment { - kind: 'system_instructions' | 'tool_definitions' | 'messages' | 'other'; + kind: ContextDiagnosticsSegmentKind; + bytes: number; +} + +/** One tool's schema, sized on its own, so a reader knows which to remove. */ +export interface ContextDiagnosticsTool { + name: string; bytes: number; - estimatedTokens: number; } export interface ContextDiagnosticsCompaction { @@ -32,79 +67,262 @@ export type ContextDiagnostics = completedAt: number; inputTokens?: number; contextWindow?: number; - segments: ContextDiagnosticsSegment[]; + /** + * What the latest request was made of, or absent when the durable + * metering record has no capture to match. + * + * Absence is a state a reader must be able to see. Metering is durable + * because lost spend is unreconstructable; the capture carrying the + * segments is appended best-effort. Reporting the composition of an + * *older* request under a current heading would be the quiet lie this + * separation exists to prevent — so a mismatch reports nothing rather + * than the wrong request (#2323). + */ + composition?: ContextDiagnosticsComposition; compaction?: ContextDiagnosticsCompaction; }; +export interface ContextDiagnosticsComposition { + segments: ContextDiagnosticsSegment[]; + /** The largest named tool schemas, largest first; bounded at the fold. */ + tools?: ContextDiagnosticsTool[]; + /** Everything past the named rows, so the bytes still account for every tool. */ + remainingTools?: { count: number; bytes: number }; + /** Tool schemas the payload did not name, so their bytes are still counted. */ + unlabelledToolBytes?: number; +} + +type ContextRunStore = Pick< + AgentRunStore, + 'listSessionRuns' | 'readEvents' | 'readEventProjection' | 'repairEventProjection' +>; + +/** + * What the session's context is made of right now (#1580, reshaped for #2323). + * + * One sealed row answers this. The `latest_context` projection is written by + * the same storage transaction that commits a completed MAIN call's canonical + * attempt, freezing that request's identity, its provider-reported numbers, + * the folded composition of its own capture, and the compaction boundary its + * prompt was built under — all at one moment, so no two fields here can + * describe different requests. It is a projection, not an event: nothing + * appends a record under that name. + * + * That sealing is the whole design. The facts come from appends with different + * guarantees (durable metering, best-effort capture) and different owners (the + * compaction boundary belongs to recovery), so reading "the newest of each + * kind" and joining them produces a snapshot whose parts drift apart: a failed + * call replaces the newest metering record, an unmatched capture hides a + * matching one, and the boundary moves on its own. + * + * Warm reads are O(1) — one projection row. The ledger scan below is the cold + * path, for a session written before this record existed. + */ export async function readLatestContextDiagnostics( - runStore: Pick, + runStore: ContextRunStore, sessionId: string, ): Promise { try { - // ponytail: command-time O(session ledger); add a completed-attempt projection if measured. - const runs = (await runStore.listSessionRuns(sessionId)).filter(isSessionInlineRun); - let latestCompleted: { ts: number; attempt: AttemptCandidate | undefined } | undefined; - const checkpoints: CheckpointCandidate[] = []; - for (const run of runs) { - const events = await runStore.readEvents(sessionId, run.runId); - for (const event of events) { - const checkpoint = event.data?.checkpoint; - if ( - event.type === 'history_compact_checkpoint_recorded' && - validateHistoryCompactCheckpointShape(checkpoint, sessionId) - ) { - checkpoints.push({ eventId: event.id, ts: event.ts, checkpoint }); - continue; - } - if (event.type !== 'provider_request_attempt_recorded') continue; - if (event.data?.status !== 'completed') continue; - const completed = { ts: event.ts, attempt: attemptCandidate(event.data) }; - if (!latestCompleted || completed.ts >= latestCompleted.ts) { - latestCompleted = completed; - } - } + if (runStore.readEventProjection) { + // Three states, three answers. `undefined` is an uninitialized + // projection — nothing has been decided about this session, so the + // ledger must be scanned. `null` is a decided answer: a previous read + // scanned this ledger and found nothing to report, and honouring it is + // what keeps that scan from repeating on every panel refresh. A row + // present but unreadable — damaged, or written by a build whose shape + // this one cannot anchor on — is not an answer, and falls back to the + // ledger that can still produce one. + const projected = await runStore + .readEventProjection(sessionId, LATEST_CONTEXT_PROJECTION_TYPE) + .catch(() => undefined); + if (projected === null) return { status: 'unavailable', reason: 'no_completed_request' }; + const snapshot = readLatestContextSnapshot(projected ?? undefined); + if (snapshot) return availableFrom(snapshot); } - if (!latestCompleted) return { status: 'unavailable', reason: 'no_completed_request' }; - if (!latestCompleted.attempt) return { status: 'unavailable', reason: 'trace_unavailable' }; - const latest = latestCompleted.attempt; - const checkpoint = checkpoints - .filter((candidate) => candidate.ts <= latest.startedAt) - .sort( - (left, right) => right.ts - left.ts || right.eventId.localeCompare(left.eventId), - )[0]?.checkpoint; - return { - status: 'available', - providerId: latest.providerId, - modelId: latest.modelId, - completedAt: latest.completedAt, - ...(latest.inputTokens !== undefined ? { inputTokens: latest.inputTokens } : {}), - ...(latest.contextWindow !== undefined ? { contextWindow: latest.contextWindow } : {}), - segments: latest.segments, - ...(checkpoint - ? { - compaction: { - kind: 'history', - phase: checkpoint.phase === 'mid_turn' ? 'mid_turn' : 'pre_turn', - eventCount: checkpoint.coverage.eventCount, - turnCount: checkpoint.coverage.turnCount, - estimatedTokens: checkpoint.estimatedTokens, - } satisfies ContextDiagnosticsCompaction, - } - : {}), - }; + return await rebuildContextFromLedger(runStore, sessionId); } catch { return { status: 'unavailable', reason: 'trace_unavailable' }; } } -interface AttemptCandidate { +/** + * The cold path, and the compatibility path. + * + * A ledger written before sealed snapshots existed still has the two records + * they were sealed from, so the reader assembles one — but only here, only + * once, and only when no sealed record is present at all. Nothing is repaired + * into another owner's projection: the compaction boundary is read from the + * events of this session's own runs, never from recovery's derived row. + */ +async function rebuildContextFromLedger( + runStore: ContextRunStore, + sessionId: string, +): Promise { + const runs = (await runStore.listSessionRuns(sessionId)).filter(isSessionInlineRun); + let anchor: MeteringAnchor | undefined; + // Only consulted when the scan finds no canonical attempt at all: a session + // written before canonical metering existed has provider attempts and + // nothing else, and returning "no completed request" for it would lose an + // answer the ledger still holds (#2323). + let legacy: LegacyProviderAnchor | undefined; + // Whether this ledger is canonical-era AT ALL — tracked apart from `anchor`, + // which only ever holds a completed main call. A session whose canonical + // records are all failed, aborted, a compaction's own request, or written in + // a shape this build cannot decode still HAS canonical metering; letting the + // legacy provider rows answer for it would resurrect the very request the + // canonical rule declined to report. + let sawCanonicalRecord = false; + const captures = new Map(); + const checkpoints: CheckpointCandidate[] = []; + + for (const run of runs) { + for (const event of await runStore.readEvents(sessionId, run.runId)) { + if (event.type === METERING_EVENT_TYPE) { + sawCanonicalRecord = true; + const candidate = meteringAnchor(event); + if (candidate && supersedesLatestContext(candidate, anchor)) anchor = candidate; + continue; + } + if (event.type === PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE) { + const attemptId = event.data?.attemptId; + if (typeof attemptId === 'string') captures.set(attemptId, event); + const candidate = legacyProviderAnchor(event); + if (candidate && supersedesLatestContext(candidate, legacy)) legacy = candidate; + continue; + } + if (event.type !== CHECKPOINT_EVENT_TYPE) continue; + const shape = event.data?.checkpoint; + if (validateHistoryCompactCheckpointShape(shape, sessionId)) { + checkpoints.push({ eventId: event.id, ts: event.ts, checkpoint: shape }); + } + } + } + + // The compatibility fallback is exactly that: it applies only when the scan + // found no canonical record anywhere, so it can never become a second + // authority for data written since canonical metering shipped. + const resolved = anchor ?? (sawCanonicalRecord ? undefined : legacy); + if (!resolved) { + await repairLatestContext(runStore, sessionId, null); + return { status: 'unavailable', reason: 'no_completed_request' }; + } + const capture = captures.get(resolved.attemptId); + const read = capture ? readPromptCompositionEvent(capture) : undefined; + const boundary = latestCheckpointBefore(checkpoints, resolved); + const snapshot: LatestContextSnapshot = { + schemaVersion: LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION, + attemptId: resolved.attemptId, + providerId: resolved.providerId, + modelId: resolved.modelId, + completedAt: resolved.completedAt, + ...(resolved.inputTokens !== undefined ? { inputTokens: resolved.inputTokens } : {}), + ...(resolved.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: resolved.cacheReadInputTokens } + : {}), + ...(resolved.contextWindow !== undefined ? { contextWindow: resolved.contextWindow } : {}), + ...(read?.attemptId === resolved.attemptId ? { composition: read.composition } : {}), + ...(boundary ? { compaction: contextDiagnosticsCompactionOf(boundary.checkpoint) } : {}), + }; + // Repair on the way out, so this scan happens once per session rather than + // on every panel refresh. Best-effort: the caller already has its answer, + // and a later cold read can retry the derived write. + await repairLatestContext(runStore, sessionId, snapshot); + return availableFrom(snapshot); +} + +/** + * Rewrites the derived row the canonical append normally maintains. + * + * `null` is written deliberately for a session with nothing to report: an + * initialized-empty projection is an answer, and it is what stops the next + * read from scanning the whole ledger to learn the same nothing. + */ +async function repairLatestContext( + runStore: ContextRunStore, + sessionId: string, + snapshot: LatestContextSnapshot | null, +): Promise { + const repair = runStore.repairEventProjection; + if (!repair) return; + await repair + .call( + runStore, + sessionId, + LATEST_CONTEXT_PROJECTION_TYPE, + snapshot + ? ({ + type: LATEST_CONTEXT_PROJECTION_TYPE, + id: `latest-context-${snapshot.attemptId}`, + runId: '', + sessionId, + turnId: '', + ts: snapshot.completedAt, + data: snapshot as unknown as Record, + } as AgentRunEvent) + : null, + ) + .catch(() => {}); +} + +/** + * A completed provider attempt, for ledgers that predate canonical metering. + * + * Deliberately narrower than the canonical anchor: it exists to keep old + * sessions readable, not to describe anything written since. + */ +function legacyProviderAnchor(event: AgentRunEvent): MeteringAnchor | undefined { + const data = event.data; + if (!data || data.status !== 'completed') return undefined; + const { attemptId, providerId, modelId, completedAt, startedAt } = data; + if ( + typeof attemptId !== 'string' || + typeof providerId !== 'string' || + typeof modelId !== 'string' || + typeof completedAt !== 'number' + ) { + return undefined; + } + return { + attemptId, + providerId, + modelId, + startedAt: typeof startedAt === 'number' ? startedAt : completedAt, + completedAt, + ...(typeof data.inputTokens === 'number' ? { inputTokens: data.inputTokens } : {}), + ...(typeof data.contextWindow === 'number' ? { contextWindow: data.contextWindow } : {}), + }; +} + +type LegacyProviderAnchor = MeteringAnchor; + +function availableFrom(snapshot: LatestContextSnapshot): ContextDiagnostics { + return { + status: 'available', + providerId: snapshot.providerId, + modelId: snapshot.modelId, + completedAt: snapshot.completedAt, + ...(snapshot.inputTokens !== undefined ? { inputTokens: snapshot.inputTokens } : {}), + ...(snapshot.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: snapshot.cacheReadInputTokens } + : {}), + ...(snapshot.contextWindow !== undefined ? { contextWindow: snapshot.contextWindow } : {}), + ...(snapshot.composition ? { composition: snapshot.composition } : {}), + ...(snapshot.compaction ? { compaction: snapshot.compaction } : {}), + }; +} + +const METERING_EVENT_TYPE = 'model_call_attempt_recorded'; +const CHECKPOINT_EVENT_TYPE = 'history_compact_checkpoint_recorded'; + +interface MeteringAnchor { + attemptId: string; providerId: string; modelId: string; startedAt: number; completedAt: number; inputTokens?: number; + cacheReadInputTokens?: number; contextWindow?: number; - segments: ContextDiagnosticsSegment[]; } interface CheckpointCandidate { @@ -113,69 +331,58 @@ interface CheckpointCandidate { checkpoint: HistoryCompactCheckpoint; } -function attemptCandidate(data: Record | undefined): AttemptCandidate | undefined { - if ( - data?.status !== 'completed' || - typeof data.providerId !== 'string' || - typeof data.modelId !== 'string' || - !isNonNegativeNumber(data.startedAt) || - !isNonNegativeNumber(data.completedAt) || - !Array.isArray(data.segments) || - (data.inputTokens !== undefined && !isNonNegativeInteger(data.inputTokens)) || - (data.contextWindow !== undefined && !isPositiveInteger(data.contextWindow)) - ) { +/** Only a completed MAIN call describes the conversation's own context. */ +function meteringAnchor(event: AgentRunEvent): MeteringAnchor | undefined { + let attempt: ModelCallAttempt; + try { + attempt = decodeModelCallAttempt(event.data); + } catch { return undefined; } - const segments = contextSegmentEstimates(data.segments); - if (!segments) return undefined; + if (attempt.callKind !== 'main' || attempt.status !== 'completed') return undefined; return { - providerId: data.providerId, - modelId: data.modelId, - startedAt: data.startedAt, - completedAt: data.completedAt, - ...(data.inputTokens !== undefined ? { inputTokens: data.inputTokens as number } : {}), - ...(data.contextWindow !== undefined ? { contextWindow: data.contextWindow as number } : {}), - segments, + attemptId: attempt.attemptId, + providerId: attempt.providerId, + modelId: attempt.modelId, + startedAt: attempt.startedAt, + completedAt: attempt.completedAt, + ...(attempt.inputTokens !== undefined ? { inputTokens: attempt.inputTokens } : {}), + ...(attempt.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: attempt.cacheReadInputTokens } + : {}), + ...(attempt.contextWindow !== undefined ? { contextWindow: attempt.contextWindow } : {}), }; } -function contextSegmentEstimates(segments: unknown[]): ContextDiagnosticsSegment[] | undefined { - const bytes = new Map(); - for (const segment of segments) { - if (!segment || typeof segment !== 'object') return undefined; - const value = segment as Record; - const kind = contextSegmentKind(value.kind); - if (!kind || !isNonNegativeInteger(value.bytes)) return undefined; - bytes.set(kind, (bytes.get(kind) ?? 0) + value.bytes); - } - const order: ContextDiagnosticsSegment['kind'][] = [ - 'system_instructions', - 'tool_definitions', - 'messages', - 'other', - ]; - return order.flatMap((kind) => { - const value = bytes.get(kind) ?? 0; - return value > 0 ? [{ kind, bytes: value, estimatedTokens: Math.ceil(value / 4) }] : []; - }); -} - -function contextSegmentKind(value: unknown): ContextDiagnosticsSegment['kind'] | undefined { - if (value === 'system_prompt') return 'system_instructions'; - if (value === 'tool_schema') return 'tool_definitions'; - if (value === 'message') return 'messages'; - if (value === 'provider_options') return 'other'; - return undefined; -} - -function isNonNegativeInteger(value: unknown): value is number { - return Number.isInteger(value) && (value as number) >= 0; +function latestCheckpointBefore( + candidates: readonly CheckpointCandidate[], + anchor: MeteringAnchor, +): CheckpointCandidate | undefined { + return candidates + .filter((candidate) => candidate.ts <= anchor.startedAt) + .reduce((selected, candidate) => { + if (!selected) return candidate; + if (candidate.ts !== selected.ts) return candidate.ts > selected.ts ? candidate : selected; + return candidate.eventId > selected.eventId ? candidate : selected; + }, undefined); } -function isNonNegativeNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) && value >= 0; -} - -function isPositiveInteger(value: unknown): value is number { - return Number.isInteger(value) && (value as number) > 0; +/** + * The one rule for describing a checkpoint as a compaction boundary. + * + * Exported because two paths must describe the same checkpoint identically: the + * warm path converts the boundary the prompt was built from at dispatch, the + * cold scan converts the one it finds in the ledger. Two spellings of this would + * make a rebuilt session disagree with a live one about the same fold (#2323). + */ +export function contextDiagnosticsCompactionOf( + checkpoint: HistoryCompactCheckpoint, +): ContextDiagnosticsCompaction { + return { + kind: 'history', + phase: checkpoint.phase === 'mid_turn' ? 'mid_turn' : 'pre_turn', + eventCount: checkpoint.coverage.eventCount, + turnCount: checkpoint.coverage.turnCount, + estimatedTokens: checkpoint.estimatedTokens, + }; } diff --git a/packages/runtime/src/latest-context-snapshot.ts b/packages/runtime/src/latest-context-snapshot.ts new file mode 100644 index 0000000000..db4e935b03 --- /dev/null +++ b/packages/runtime/src/latest-context-snapshot.ts @@ -0,0 +1,126 @@ +import { + LATEST_CONTEXT_PROJECTION_TYPE, + type AgentRunEvent, + type LatestContextProjectionInput, +} from '@maka/core/agent-run'; + +export { LATEST_CONTEXT_PROJECTION_TYPE }; +import type { + ContextDiagnosticsCompaction, + ContextDiagnosticsComposition, +} from './context-diagnostics.js'; +import { foldPromptComposition, type SizedRequestSegment } from './prompt-composition.js'; + +/** + * One request's context, frozen by the transaction that committed it (#2323). + * + * The reason this is a record rather than a read-time join: the facts it holds + * are written by different appends with different guarantees, and reading "the + * newest of each kind" separately produces a snapshot whose parts describe + * different moments. A failed call replaces the newest metering record; a + * capture that never matched replaces the newest capture; the compaction + * boundary moves on its own. Each of those is correct in isolation and wrong + * together. + * + * So the facts are copied into one derived row by the same storage transaction + * that commits the canonical completed-main attempt. There is one durable + * authority for the request — the attempt — and this is a product of it, not a + * second record racing it. A failed or aborted attempt, or a compaction's own + * request, never authorises a write, so the last good answer stands. + */ + +export const LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION = 1 as const; + +export interface LatestContextSnapshot { + schemaVersion: typeof LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION; + /** The request this describes. Frozen so every field below belongs to it. */ + attemptId: string; + providerId: string; + modelId: string; + completedAt: number; + /** Provider-reported, as metered. Absent stays absent (#1679). */ + inputTokens?: number; + cacheReadInputTokens?: number; + /** The window this call was metered against, frozen at call time. */ + contextWindow?: number; + /** + * What the prompt was made of. Absent when the best-effort capture did not + * describe THIS attempt — a request explains itself or says nothing, never + * borrows another request's breakdown. + */ + composition?: ContextDiagnosticsComposition; + /** The boundary that applied when this request was built, if any. */ + compaction?: ContextDiagnosticsCompaction; +} + +/** + * The derived row for one completed main request, ready to commit alongside + * the attempt that authorises it. + * + * `orderedAt` is the request's own completion, so a row arriving late from an + * overlapping turn cannot move the answer backwards. + */ +export function latestContextProjectionInput( + attempt: LatestContextFacts, + segments: readonly SizedRequestSegment[] | undefined, + compaction: ContextDiagnosticsCompaction | undefined, +): LatestContextProjectionInput { + const composition = segments ? foldPromptComposition(segments) : undefined; + const snapshot: LatestContextSnapshot = { + schemaVersion: LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION, + attemptId: attempt.attemptId, + providerId: attempt.providerId, + modelId: attempt.modelId, + completedAt: attempt.completedAt, + ...(attempt.inputTokens !== undefined ? { inputTokens: attempt.inputTokens } : {}), + ...(attempt.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: attempt.cacheReadInputTokens } + : {}), + ...(attempt.contextWindow !== undefined ? { contextWindow: attempt.contextWindow } : {}), + ...(composition ? { composition } : {}), + ...(compaction ? { compaction } : {}), + }; + return { + attemptId: attempt.attemptId, + orderedAt: attempt.completedAt, + snapshot: snapshot as unknown as Record, + }; +} + +/** The metered facts a snapshot freezes, as the canonical attempt carries them. */ +export interface LatestContextFacts { + attemptId: string; + providerId: string; + modelId: string; + completedAt: number; + inputTokens?: number; + cacheReadInputTokens?: number; + contextWindow?: number; +} + +/** + * Reads a snapshot back off the ledger. + * + * Tolerant in the one direction that matters: a record written by a newer + * build may carry fields this one does not know, and dropping the whole + * snapshot for that would lose an answer it could still give. A record missing + * the identity it is anchored on is a different matter, and is rejected. + */ +export function readLatestContextSnapshot( + event: Pick | undefined, +): LatestContextSnapshot | undefined { + if (!event) return undefined; + const data = event.data; + if (!data || typeof data !== 'object') return undefined; + const record = data as Record; + if ( + typeof record.attemptId !== 'string' || + record.attemptId.length === 0 || + typeof record.providerId !== 'string' || + typeof record.modelId !== 'string' || + typeof record.completedAt !== 'number' + ) { + return undefined; + } + return record as unknown as LatestContextSnapshot; +} diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 77e5a9f9c9..90ae015a7d 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -45,6 +45,7 @@ import { withProviderGenerateTracking, type ProviderRequestTracker, } from './provider-request-telemetry.js'; +import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; import { createOpenAiChatReasoningTransportState, openAiChatReasoningFieldProviderOptions, @@ -134,6 +135,14 @@ export interface ModelAdapterStreamInput { }) => RepairableAiSdkToolCall | null | Promise; /** Main-agent provider-call tracker. Auxiliary calls track their own generates. */ providerRequestTracker?: ProviderRequestTracker; + /** + * The compaction boundary the messages of THIS call were projected under + * (#2323). Travels with the messages rather than being read from session + * state at settlement, which is why it is an argument here at all: the + * caller dispatches once per physical request and knows which fold each one + * was built from; nothing downstream can recover that afterwards. + */ + historyCompactBoundary?: ContextDiagnosticsCompaction; /** Turn-scoped continuation lane. Omitted callers keep the full-request path. */ continuationKey?: string; } @@ -233,6 +242,9 @@ export class ModelAdapter { params, abortSignal: input.abortSignal, doStream, + ...(input.historyCompactBoundary + ? { historyCompactBoundary: input.historyCompactBoundary } + : {}), }), }, }) diff --git a/packages/runtime/src/prompt-composition.ts b/packages/runtime/src/prompt-composition.ts new file mode 100644 index 0000000000..5d13580478 --- /dev/null +++ b/packages/runtime/src/prompt-composition.ts @@ -0,0 +1,175 @@ +import type { + ContextDiagnosticsComposition, + ContextDiagnosticsSegment, +} from './context-diagnostics.js'; +import type { PreparedRequestSegmentKind } from './request-shape.js'; + +/** + * The three fields a fold needs, and no more. + * + * `PreparedRequestSegment` satisfies this structurally, so a live capture folds + * without conversion — but a decoder reading one back off the ledger does not + * have to invent an `index` or a `hash` it never uses just to produce the wider + * type. A fabricated field is a silent wrong answer waiting for the first + * caller that reads it. + */ +export interface SizedRequestSegment { + kind: PreparedRequestSegmentKind; + bytes: number; + label?: string; +} + +/** + * Folds one request's captured segments into "what was this prompt made of" + * (#2323). + * + * The bar above this in the Inspector answers how full the context is, from + * provider-reported tokens. This answers what filled it, and the two are not + * views of one number: composition is measured in **bytes of serialized + * request**, sums to `requestBytes`, and never sums to the reported + * `inputTokens`. Nothing here estimates tokens — a byte count is the fact this + * layer holds, and turning it into a token figure is a display decision that + * has to be labelled as an estimate where it is made (#1679). + * + * `tool_schema` folds per tool rather than into one total, because that is the + * only breakdown a reader can act on: "tool definitions are 40%" names nothing + * to remove. Every other kind folds whole — one system prompt, one history, one + * options blob — and splitting `message` by what produced it is not knowable + * here, where messages arrive already serialized. + */ +export function foldPromptComposition( + segments: readonly SizedRequestSegment[], +): ContextDiagnosticsComposition | undefined { + if (segments.length === 0) return undefined; + + const byKind = new Map(); + const byTool = new Map(); + let unlabelledToolBytes = 0; + + for (const segment of segments) { + byKind.set(segment.kind, (byKind.get(segment.kind) ?? 0) + segment.bytes); + if (segment.kind !== 'tool_schema') continue; + if (segment.label === undefined) unlabelledToolBytes += segment.bytes; + else byTool.set(segment.label, (byTool.get(segment.label) ?? 0) + segment.bytes); + } + + // A zero-byte kind is dropped rather than shown as `≈0`, the same way + // `/context` folds it — a part nothing contributed to is not a part. + const folded: ContextDiagnosticsSegment[] = KIND_ORDER.flatMap((kind) => { + const bytes = byKind.get(kind) ?? 0; + return bytes > 0 ? [{ kind: PART_KINDS[kind], bytes }] : []; + }); + if (folded.length === 0) return undefined; + + // Sorted by size because the question the list answers is "what is big + // enough to be worth removing", and ties by name so a reader comparing two + // reads of the same session sees the same order. + const ranked = [...byTool.entries()] + .map(([name, bytes]) => ({ name, bytes })) + .sort((left, right) => right.bytes - left.bytes || left.name.localeCompare(right.name)); + // Bounded HERE, at the owner that decides what the list means — not at the + // wire decoder. A single MCP server may advertise up to 1000 tools, so a cap + // downstream only moves the cliff: the 257th tool would fail the whole query + // instead of being summarised. What falls below the cut is carried as a + // remainder, so the rows still account for every tool byte. + const tools = ranked.slice(0, MAX_TOOL_ROWS); + const remainder = ranked.slice(MAX_TOOL_ROWS); + const remainingToolBytes = remainder.reduce((carry, tool) => carry + tool.bytes, 0); + + return { + segments: folded, + ...(tools.length > 0 ? { tools } : {}), + ...(remainder.length > 0 + ? { remainingTools: { count: remainder.length, bytes: remainingToolBytes } } + : {}), + ...(unlabelledToolBytes > 0 ? { unlabelledToolBytes } : {}), + }; +} + +/** + * The diagnostic append that carries the segments, alongside the durable + * metering record on the same run stream. + */ +export const PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE = 'provider_request_attempt_recorded'; + +/** + * Reads one run event into the composition of the request it describes. + * + * Returns undefined for every event that is not a decodable capture, so a + * caller walking the run stream can recognise this alongside the metering + * record without a second read. Absence is the honest outcome: this append is + * best-effort, and a record that will not decode is a composition the reader + * does not have — not a prompt made of nothing. + */ +export function readPromptCompositionEvent(event: { + readonly type: string; + readonly data?: unknown; +}): { attemptId: string; composition: ContextDiagnosticsComposition } | undefined { + if (event.type !== PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE) return undefined; + const data = event.data; + if (!isRecord(data)) return undefined; + const attemptId = data.attemptId; + if (typeof attemptId !== 'string' || attemptId.length === 0) return undefined; + if (!Array.isArray(data.segments)) return undefined; + + const segments: SizedRequestSegment[] = []; + for (const value of data.segments) { + const segment = readSegment(value); + // One unreadable segment makes every share of this request wrong, so the + // whole composition is dropped rather than silently under-counted. + if (!segment) return undefined; + segments.push(segment); + } + + const composition = foldPromptComposition(segments); + return composition ? { attemptId, composition } : undefined; +} + +function readSegment(value: unknown): SizedRequestSegment | undefined { + if (!isRecord(value)) return undefined; + const kind = value.kind; + if (!KIND_ORDER.includes(kind as PreparedRequestSegmentKind)) return undefined; + if (!isNonNegativeInteger(value.bytes)) return undefined; + if (value.label !== undefined && typeof value.label !== 'string') return undefined; + return { + kind: kind as PreparedRequestSegmentKind, + bytes: value.bytes, + ...(typeof value.label === 'string' ? { label: value.label } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +/** + * How many tools the fold names individually. + * + * Generous enough that a normal registry is listed whole, small enough that a + * pathological one cannot make this record unbounded. The panel shows fewer + * still; this is the bound on what crosses a wire and sits in a projection. + */ +const MAX_TOOL_ROWS = 64; + +const KIND_ORDER: readonly PreparedRequestSegmentKind[] = [ + 'system_prompt', + 'tool_schema', + 'message', + 'provider_options', +]; + +/** + * The CLI's `/context` vocabulary, reused rather than re-invented: the same + * four buckets already fold the same segments for `readLatestContextDiagnostics` + * (#1580), and two names for one fact is how two surfaces start disagreeing. + */ +const PART_KINDS: Record = { + system_prompt: 'system_instructions', + tool_schema: 'tool_definitions', + message: 'messages', + provider_options: 'other', +}; diff --git a/packages/runtime/src/provider-request-telemetry.ts b/packages/runtime/src/provider-request-telemetry.ts index 3dfbc87e3d..9b6a2c2efa 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -11,6 +11,9 @@ import { type PreparedRequestSegment, } from './request-shape.js'; import { rawFinishReasonString } from './model-protocol.js'; +import { latestContextProjectionInput } from './latest-context-snapshot.js'; +import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; +import type { ModelCallCommit } from '@maka/core/agent-run'; export type ProviderRequestCacheValueSource = 'provider' | 'derived'; @@ -149,7 +152,12 @@ export interface ModelCallAccountingInput { */ providerId?: string; callKind: ModelCallKind; - record: (attempt: ModelCallAttempt) => void | Promise; + /** + * Commits the attempt, and with it the derived latest-context row when this + * request is one that answers "what is the context made of" (#2323). One + * call, so the two cannot fail or arrive independently. + */ + record: (commit: ModelCallCommit) => void | Promise; /** Resolves cost at settlement time; absent means the price is unknown. */ resolveCost?: (usage: ProviderRequestUsage) => ResolvedModelCallCost | undefined; /** @@ -174,11 +182,23 @@ export interface TrackProviderStreamInput { params: Record; abortSignal?: AbortSignal; doStream: () => PromiseLike; + /** + * The compaction boundary THIS request's prompt was built from (#2323). + * + * Per request rather than per tracker: one tracker spans every physical + * request of a send, and mid-turn compaction or overflow recovery can + * prepare the next request against a different boundary. Read at settlement + * it would be whatever the session holds by then — a boundary this prompt + * may never have seen. + */ + historyCompactBoundary?: ContextDiagnosticsCompaction; } export interface TrackProviderGenerateInput { providerId: string; modelId: string; + /** As `TrackProviderStreamInput.historyCompactBoundary`. */ + historyCompactBoundary?: ContextDiagnosticsCompaction; params: Record; abortSignal?: AbortSignal; doGenerate: () => PromiseLike; @@ -401,7 +421,7 @@ export class ProviderRequestTracker { capture: StoredCapture, input: Pick< TrackProviderStreamInput | TrackProviderGenerateInput, - 'providerId' | 'modelId' | 'abortSignal' + 'providerId' | 'modelId' | 'abortSignal' | 'historyCompactBoundary' >, ): { observeOutput(): void; @@ -481,6 +501,10 @@ export class ProviderRequestTracker { logicalCallId, usage, contextWindow, + // Frozen when THIS request was prepared, so a checkpoint published + // mid-flight by another turn cannot be sealed into a prompt built + // before it existed. + historyCompactBoundary: input.historyCompactBoundary, }); }); await accountingSettlement; @@ -518,6 +542,7 @@ export class ProviderRequestTracker { logicalCallId: string; usage: ProviderRequestUsage | undefined; contextWindow: number | undefined; + historyCompactBoundary: ContextDiagnosticsCompaction | undefined; }, ): Promise { const accounting = this.input.accounting; @@ -574,8 +599,16 @@ export class ProviderRequestTracker { : {}), }; + // Only a completed MAIN call describes the conversation's own context, so + // only that one carries the derived row. A failed, aborted or compaction + // call commits its metering alone and leaves the last answer standing. + const latestContext = + attempt.callKind === 'main' && attempt.status === 'completed' + ? latestContextProjectionInput(attempt, record.segments, context.historyCompactBoundary) + : undefined; + try { - await accounting.record(attempt); + await accounting.record({ attempt, ...(latestContext ? { latestContext } : {}) }); } catch { // Reported through the run's accounting-incomplete signal by the sink // itself. Settlement must not fail the turn the call already completed. diff --git a/packages/runtime/src/request-shape.ts b/packages/runtime/src/request-shape.ts index a94858f702..960c3a8877 100644 --- a/packages/runtime/src/request-shape.ts +++ b/packages/runtime/src/request-shape.ts @@ -62,6 +62,16 @@ export interface PreparedRequestSegment { hash: string; bytes: number; role?: string; + /** + * What this segment is, when the seam can name it. Set for `tool_schema` from + * the tool's own name, which the provider payload already carries. + * + * Present so a size can be acted on: "tool definitions are 40% of the prompt" + * names no tool to remove, and every segment kind but this one is already a + * single thing (#2323). Optional because a payload that names nothing is a + * shape this capture still has to describe. + */ + label?: string; } export interface PreparedProviderRequestInput { @@ -199,7 +209,7 @@ export function capturePreparedProviderRequest( const segments: PreparedRequestSegment[] = []; for (const [index, tool] of (input.tools ?? []).entries()) { - segments.push(preparedSegment('tool_schema', index, tool, true)); + segments.push(preparedSegment('tool_schema', index, tool, true, undefined, toolLabel(tool))); } if (input.instructions !== undefined) { const instructions = Array.isArray(input.instructions) @@ -440,6 +450,7 @@ function preparedSegment( value: unknown, cacheable: boolean, role?: string, + label?: string, ): PreparedRequestSegment { const serialized = stableStringify(value); return { @@ -449,9 +460,22 @@ function preparedSegment( hash: stableHash(value), bytes: Buffer.byteLength(serialized, 'utf8'), ...(role !== undefined ? { role } : {}), + ...(label !== undefined ? { label } : {}), }; } +/** + * The tool's own name as the payload carries it. + * + * Read off the serialized tool rather than the registry: this capture describes + * what crossed the wire, so a name that is not in the payload is not a name this + * segment can claim. + */ +function toolLabel(tool: unknown): string | undefined { + if (!isObjectLike(tool)) return undefined; + return typeof tool.name === 'string' && tool.name.length > 0 ? tool.name : undefined; +} + export function stableHash(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(stableStringify(value)).digest('hex')}`; } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 3e60cd4c62..16fc50cbe4 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2820,9 +2820,9 @@ export class RuntimeKernel implements RuntimeKernelLike { }, // Resolved by runId rather than turnId: the canonical record names // the run it belongs to, so it needs no turn-to-run indirection. - recordModelCallAttempt: (attempt) => { - const run = resolveActive()?.activeRuns.get(attempt.runId); - return run?.recordModelCallAttempt(attempt) ?? Promise.resolve(); + recordModelCallAttempt: (commit) => { + const run = resolveActive()?.activeRuns.get(commit.attempt.runId); + return run?.recordModelCallAttempt(commit) ?? Promise.resolve(); }, recordRunComposition: (runId, snapshot) => { const run = resolveActive()?.activeRuns.get(runId); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index bc920939c3..ad538960cb 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -155,6 +155,7 @@ import type { ProviderRequestCaptureLedgerRecord, } from './provider-request-telemetry.js'; import { readLatestContextDiagnostics, type ContextDiagnostics } from './context-diagnostics.js'; +import type { ModelCallCommit } from '@maka/core/agent-run'; import type { ShellRunProcessManager } from './shell-run-manager.js'; import type { ActiveFullCompactBlock } from './active-full-compact.js'; import type { SemanticCompactBlock } from './semantic-compact.js'; @@ -748,7 +749,7 @@ export interface BackendFactoryContext { * physical provider call. Distinct from the diagnostic row above: this one is * the metering source of truth (#1679). */ - recordModelCallAttempt?: (attempt: ModelCallAttempt) => Promise; + recordModelCallAttempt?: (commit: ModelCallCommit) => Promise; /** Immutable Run policy snapshot; provider dispatch waits for this durable commit. */ recordRunComposition?: (runId: string, snapshot: RunCompositionSnapshot) => Promise; loadHistoryCompactCheckpoint?: () => Promise; diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 6cd4265ff5..291681f571 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -36,12 +36,18 @@ import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; import { isTerminalRuntimeEvent, type RuntimeEvent } from '@maka/core/runtime-event'; import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { + LATEST_CONTEXT_PROJECTION_TYPE, + supersedesLatestContext, + type AgentRunProjectionKey, + type AgentRunAppendOptions, + type LatestContextProjectionInput, type AgentRunEvent, type AgentRunEventType, type AgentRunHeader, type AgentRunStore, type EmittedAgentRunEvent, type RootExecutionDescriptor, + isSessionInlineRun, } from '@maka/core/agent-run'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import { @@ -178,11 +184,11 @@ export interface DurableAgentRunStore readEventsForEvidence(sessionId: string, runId: string): Promise; readEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): Promise; repairEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, event: AgentRunEvent | null, options?: { replaceEventId?: string }, ): Promise; @@ -413,7 +419,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { sessionId: string, runId: string, event: EmittedAgentRunEvent, - _options: { durable?: boolean } = {}, + options: AgentRunAppendOptions = {}, ): Promise { assertSafeId(sessionId, 'Invalid session id'); assertSafeId(runId, 'Invalid run id'); @@ -424,20 +430,73 @@ class SqliteAgentRunStore implements DurableAgentRunStore { runId, turnId: header.turnId, }); - const projection = - normalized.type === 'history_compact_checkpoint_recorded' - ? readSqliteAgentRunProjection(this.#lease.database, sessionId, normalized.type) - : undefined; + const type = normalized.type as AgentRunEventType; + const projectsCheckpoint = type === 'history_compact_checkpoint_recorded'; + const projection = projectsCheckpoint + ? readSqliteAgentRunProjection(this.#lease.database, sessionId, type) + : undefined; insertAgentRunEvent(this.#lease.database, normalized); - if (normalized.type === 'history_compact_checkpoint_recorded') { - const projected = shouldPreserveCheckpointProjectionDuringAppend(projection, normalized) + if (projectsCheckpoint) { + const row = shouldPreserveCheckpointProjectionDuringAppend(projection, normalized) ? projection! : normalized; - writeSqliteAgentRunProjection(this.#lease.database, sessionId, normalized.type, projected); + writeSqliteAgentRunProjection(this.#lease.database, sessionId, type, row); + } + // Derived state, committed with the event that authorises it (#2323). + // Inside THIS transaction, so the projection cannot outlive a metering + // append that failed, nor describe a request the ledger never recorded. + // + // Skipped for a subagent's run: those requests are real, but presenting + // one as the SESSION's latest context attributes another agent's prompt + // to this one. The header is already loaded here, so the check is free. + const latestContext = options.latestContext; + if (latestContext && isSessionInlineRun(header)) { + this.#writeLatestContextProjection(sessionId, normalized, latestContext); } }); } + /** + * Monotonic by the request's own completion, not by arrival. + * + * Overlapping turns append on independent queues, so a request that finished + * at 10 can arrive after one that finished at 20. Taking the newest arrival + * would move the answer backwards and leave a warm read disagreeing with a + * cold rebuild of the same ledger. Ties break on `attemptId` so two requests + * sharing a millisecond still order the same way everywhere. + */ + #writeLatestContextProjection( + sessionId: string, + event: AgentRunEvent, + latest: LatestContextProjectionInput, + ): void { + const existing = readSqliteAgentRunProjection( + this.#lease.database, + sessionId, + LATEST_CONTEXT_PROJECTION_TYPE, + ); + // Compared against the stored row's own completion, which the snapshot + // carries — not against an ordering field the row does not have, which is + // how the first version of this guard silently never fired. The rule + // itself is shared with the cold rebuild, so the two cannot disagree about + // which request is the latest one. + const current = existing?.data as { completedAt?: unknown; attemptId?: unknown } | undefined; + if (current && typeof current.completedAt === 'number') { + const incumbent = { + completedAt: current.completedAt, + attemptId: String(current.attemptId ?? ''), + }; + const arriving = { completedAt: latest.orderedAt, attemptId: String(latest.attemptId) }; + if (!supersedesLatestContext(arriving, incumbent)) return; + } + writeSqliteAgentRunProjection(this.#lease.database, sessionId, LATEST_CONTEXT_PROJECTION_TYPE, { + ...event, + type: LATEST_CONTEXT_PROJECTION_TYPE, + id: `latest-context-${latest.attemptId}`, + data: latest.snapshot, + }); + } + async readEvents(sessionId: string, runId: string): Promise { return this.readEventsForRecovery(sessionId, runId); } @@ -479,7 +538,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { async readEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): Promise { assertSafeId(sessionId, 'Invalid session id'); return readSqliteAgentRunProjection(this.#lease.database, sessionId, type); @@ -487,7 +546,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { async repairEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, event: AgentRunEvent | null, options: { replaceEventId?: string } = {}, ): Promise { @@ -857,7 +916,9 @@ function insertAgentRunEvent(db: DatabaseSync, event: AgentRunEvent): void { function readSqliteAgentRunProjection( db: DatabaseSync, sessionId: string, - type: AgentRunEventType, + // A projection key, not necessarily an event type: `latest_context` names a + // derived row nothing ever appends under (#2323). + type: string, ): AgentRunEvent | null | undefined { const row = db .prepare(` @@ -881,7 +942,7 @@ function readSqliteAgentRunProjection( function writeSqliteAgentRunProjection( db: DatabaseSync, sessionId: string, - type: AgentRunEventType, + type: string, event: AgentRunEvent | null, ): void { db.prepare(` @@ -1073,7 +1134,7 @@ function shouldPreserveCheckpointProjectionDuringAppend( function shouldPreserveProjectionDuringRepair( current: AgentRunEvent | null | undefined, candidate: AgentRunEvent | null, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): boolean { if (!current) return false; if (type !== 'history_compact_checkpoint_recorded') return true; @@ -1141,7 +1202,7 @@ function historyCompactProjectionCoverage(event: AgentRunEvent): number | undefi function isProjectedAgentRunEvent( value: unknown, sessionId: string, - type: AgentRunEventType, + type: string, ): value is AgentRunEvent { if (!value || typeof value !== 'object') return false; const event = value as Partial; diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 2c5bf4b529..c71ec849e0 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -1,4 +1,9 @@ -import type { AgentRunEvent, AgentRunEventType, AgentRunHeader } from '@maka/core/agent-run'; +import type { + AgentRunEvent, + AgentRunEventType, + AgentRunHeader, + AgentRunProjectionKey, +} from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; @@ -161,7 +166,7 @@ export interface ExecutionAgentRunReader { ): Promise>; readEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; readRootTurnSourceMessageReceipt( From c383a0651a4543e3cb64b03d94bf9ac6fb66a531 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 13 Aug 2026 11:31:30 +0800 Subject: [PATCH 2/3] fix(storage): let a damaged latest-context row be replaced by its rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic repair policy preserved any existing projection row for every key except the checkpoint. `readLatestContextDiagnostics()` deliberately treats an undecodable `latest_context` row as unanswered and rebuilds from the canonical ledger — but the repair that followed always kept the damaged row, and the call passes no `replaceEventId`, so a long session rescanned its entire ledger on every Inspector refresh. Repair now uses the same `(completedAt, attemptId)` rule as the append-time guard, and treats an incumbent whose order cannot be read as replaceable. A readable row is still never overwritten by an unreadable one — which is why the regression seeds the damage through `replaceEventId`: the rule refuses to create that state through the normal path, so the test says so rather than pretending otherwise. Verified by removing the branch from the compiled output and re-running: the new test fails without it and passes with it. --- .../src/__tests__/context-diagnostics.test.ts | 54 +++++++++++++++++++ packages/storage/src/agent-run-store.ts | 27 ++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index bc8932b32a..eabe07ec0d 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -459,6 +459,60 @@ test('a request that finished earlier cannot move the answer backwards', async ( } }); +test('a damaged projection is repaired, not preserved forever', async () => { + // The reader treats an undecodable row as unanswered and rebuilds from the + // ledger — but the generic repair policy used to preserve any existing row, + // so the rebuilt answer could never replace the damaged one and every later + // refresh rescanned the whole session (#2323). + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200), + { durable: true, latestContext: latestContext('attempt-1', 10) }, + ); + // Damage the row in place. `replaceEventId` is the seam for replacing a + // known row, which is what corruption of the stored snapshot looks like — + // the ordering rule itself refuses to overwrite a readable row with an + // unreadable one, so this cannot be seeded through the normal path. + await writer.repairEventProjection( + 'session-1', + 'latest_context', + { + type: 'latest_context', + id: 'latest-context-damaged', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-run-1', + ts: 10, + data: { schemaVersion: 1, damaged: true }, + }, + { replaceEventId: 'latest-context-attempt-1' }, + ); + + let scanned = 0; + const counted = countingStore(createSqliteAgentRunStore(root), () => { + scanned += 1; + }); + + const first = await readLatestContextDiagnostics(counted, 'session-1'); + assert.equal(first.status, 'available'); + if (first.status !== 'available') return; + assert.equal(first.modelId, 'model', 'the damaged row does not answer'); + assert.ok(scanned > 0, 'the first read rebuilds from the ledger'); + + scanned = 0; + const second = await readLatestContextDiagnostics(counted, 'session-1'); + assert.equal(second.status, 'available'); + assert.equal(scanned, 0, 'and the rebuild replaced the damaged row'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function countingStore( reader: ReturnType, onScan: () => void, diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 291681f571..b200155f64 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -38,6 +38,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { LATEST_CONTEXT_PROJECTION_TYPE, supersedesLatestContext, + type LatestContextOrder, type AgentRunProjectionKey, type AgentRunAppendOptions, type LatestContextProjectionInput, @@ -1137,6 +1138,19 @@ function shouldPreserveProjectionDuringRepair( type: AgentRunProjectionKey, ): boolean { if (!current) return false; + if (type === LATEST_CONTEXT_PROJECTION_TYPE) { + // Same ordering rule as the append-time guard, so repair and write cannot + // disagree about which request is the latest one. An incumbent whose order + // cannot be read is NOT preserved: the reader already treats an + // undecodable row as unanswered and rebuilds from the ledger, so keeping + // it would make that rebuild unwritable and leave every later refresh + // rescanning the whole session (#2323). + const incumbent = latestContextOrder(current); + if (!incumbent) return false; + const arriving = candidate && latestContextOrder(candidate); + if (!arriving) return true; + return !supersedesLatestContext(arriving, incumbent); + } if (type !== 'history_compact_checkpoint_recorded') return true; const currentSourceBound = historyCompactProjectionIsSourceBound(current); const candidateSourceBound = candidate ? historyCompactProjectionIsSourceBound(candidate) : false; @@ -1151,6 +1165,19 @@ function shouldPreserveProjectionDuringRepair( ); } +/** + * The ordering facts a stored latest-context row carries, or `undefined` when + * the row cannot state them — a damaged snapshot, or one written by a shape + * this build does not understand. + */ +function latestContextOrder(event: AgentRunEvent): LatestContextOrder | undefined { + const data = event.data as { completedAt?: unknown; attemptId?: unknown } | undefined; + if (!data || typeof data.completedAt !== 'number' || typeof data.attemptId !== 'string') { + return undefined; + } + return { completedAt: data.completedAt, attemptId: data.attemptId }; +} + function historyCompactProjectionIsSourceBound(event: AgentRunEvent): boolean { const checkpoint = event.data?.checkpoint; if (!checkpoint || typeof checkpoint !== 'object') return false; From cfac1f176310fdcfa3bc6d5725e82e4b2ebe81d0 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 13 Aug 2026 11:45:00 +0800 Subject: [PATCH 3/3] test(stories): report a cache figure in the populated context fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar splits the prompt only when the snapshot carries a cache read, and every provider that caches counts the hits — so a fixture without one showed the unsplit fallback as if it were the ordinary case. This is also the state the PR's screenshot documents. --- apps/desktop/stories/session-workbar.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 91944dc4c9..8e9f7cb81c 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -417,6 +417,9 @@ const populatedContext: ContextDiagnosticsResult = { modelId: 'glm-5.1', completedAt: NOW + 42_900, inputTokens: 18_900, + // Providers that cache always count the hits, and most real sessions carry + // one — the bar splits the prompt only when the snapshot reports it. + cacheReadInputTokens: 15_200, contextWindow: 200_000, composition: { segments: [