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 ea0a0500f4..6440ac89c5 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 { @@ -803,6 +804,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 d8976e9996..1979f253b2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -110,6 +110,7 @@ import { isSessionTrace, type SessionTrace, } from '@maka/core/session-trace'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { DAILY_REVIEW_RANGES, normalizeDailyReviewConfig, @@ -1422,6 +1423,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..8e9f7cb81c 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,69 @@ const populatedTrace: SessionTrace = { }, }; +const populatedContext: ContextDiagnosticsResult = { + status: 'available', + providerId: 'zai', + 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: [ + { 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 +512,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 +547,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 +656,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 +665,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 c9368cca50..3e6539a78d 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2875,7 +2875,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.' @@ -2920,18 +2920,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`, ); } } @@ -2950,6 +2974,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 917d501bcd..88443d9fa7 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -612,13 +612,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..eabe07ec0d 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); - const header = runHeader('run-1', 1); - await writer.createRun(header); + 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); + 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,106 @@ 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 }); } }); +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, +): 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 +620,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 33814c3cde..ef72a73ad7 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -103,6 +103,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'; @@ -149,6 +150,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, @@ -798,7 +803,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 @@ -1650,6 +1660,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 () => { @@ -2111,6 +2142,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, @@ -2140,6 +2175,7 @@ export class AiSdkBackend implements AgentBackend { providerRequestAbortController.signal, ]), ...(providerRequestTracker ? { providerRequestTracker } : {}), + ...(historyCompactBoundary ? { historyCompactBoundary } : {}), continuationKey: scope.turnId, }); @@ -3322,7 +3358,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 ?? @@ -3364,6 +3404,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, @@ -3393,7 +3437,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, @@ -3553,6 +3600,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 @@ -3570,7 +3624,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3586,7 +3640,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3598,7 +3652,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(true), }; } @@ -3614,7 +3668,7 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), + ...replayBoundary(Boolean(input.continuation)), }; } @@ -3625,7 +3679,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 cb02bdd141..eee32bfdea 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -47,6 +47,7 @@ import { withProviderGenerateTracking, type ProviderRequestTracker, } from './provider-request-telemetry.js'; +import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; import { createOpenAiChatReasoningTransportState, openAiChatReasoningFieldProviderOptions, @@ -136,6 +137,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; } @@ -235,6 +244,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 fdc27fc699..c08d4dd900 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -36,12 +36,19 @@ 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 LatestContextOrder, + 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 +185,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; @@ -431,7 +438,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'); @@ -442,17 +449,70 @@ 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, }); } @@ -497,7 +557,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); @@ -505,7 +565,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { async repairEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, event: AgentRunEvent | null, options: { replaceEventId?: string } = {}, ): Promise { @@ -875,7 +935,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(` @@ -899,7 +961,7 @@ function readSqliteAgentRunProjection( function writeSqliteAgentRunProjection( db: DatabaseSync, sessionId: string, - type: AgentRunEventType, + type: string, event: AgentRunEvent | null, ): void { db.prepare(` @@ -1091,9 +1153,22 @@ function shouldPreserveCheckpointProjectionDuringAppend( function shouldPreserveProjectionDuringRepair( current: AgentRunEvent | null | undefined, candidate: AgentRunEvent | null, - type: AgentRunEventType, + 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; @@ -1108,6 +1183,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; @@ -1159,7 +1247,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 0d5f41e28b..dbdda2c171 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'; @@ -167,7 +172,7 @@ export interface ExecutionAgentRunReader { ): Promise>; readEventProjection( sessionId: string, - type: AgentRunEventType, + type: AgentRunProjectionKey, ): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; readRootTurnSourceMessageReceipt(