diff --git a/apps/desktop/src/main/__tests__/session-inspector-composition.test.ts b/apps/desktop/src/main/__tests__/session-inspector-composition.test.ts index 03adbe1432..e218c4897e 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-composition.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-composition.test.ts @@ -21,7 +21,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { InspectorCompositionSection } from '../../renderer/features/workbar/testing.js'; +import { + InspectorCompositionSection, + InspectorRequestPrefixTag, +} from '../../renderer/features/workbar/testing.js'; import { getDesktopConversationCopy } from '../../renderer/locales/conversation-copy.js'; test('maps each request-composition category to the same colour in the chart and legend', () => { @@ -60,3 +63,20 @@ test('maps each request-composition category to the same colour in the chart and ); } }); + +test('renders the Host request-prefix verdict as one compact Inspector badge', () => { + const markup = renderToStaticMarkup( + createElement(InspectorRequestPrefixTag, { + copy: getDesktopConversationCopy('en').inspector, + requestPrefix: { + status: 'diverged', + previousSegmentCount: 8, + preservedSegmentCount: 3, + firstDivergentSegment: { kind: 'message', index: 2, role: 'user' }, + }, + }), + ); + + assert.match(markup, /data-maka-contract="session-inspector-request-prefix"/); + assert.match(markup, /Request prefix 3\/8 · diverged at message 3/); +}); diff --git a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts index e301fd4ec7..a98393959a 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts @@ -137,6 +137,112 @@ test('does not estimate a cache-hit ratio from partial usage', () => { }); assert.equal(overview.cacheHitRate, undefined); + assert.equal(overview.providerCacheUsage, undefined); +}); + +test('does not present absent provider cache fields as reported zeroes', () => { + const overview = deriveInspectorOverviewModel(undefined, { + range: { from: 0, to: 1 }, + totalRequests: 1, + totalCostUsd: 0, + totalTokens: { + input: 10, + output: 1, + cacheMiss: 10, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 11, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + provenance: { + coverage: { + attempts: 1, + pricedAttempts: 1, + unpricedAttempts: 0, + usageReportedAttempts: 1, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 0, + unreadableRecords: 0, + pendingRepairs: 0, + }, + }); + + assert.equal(overview.providerCacheUsage, undefined); +}); + +test('passes through the Host request-prefix verdict independently of cache usage', () => { + const requestPrefix = { + status: 'diverged' as const, + previousSegmentCount: 8, + preservedSegmentCount: 3, + firstDivergentSegment: { kind: 'message' as const, index: 2, role: 'user' }, + }; + + const overview = deriveInspectorOverviewModel( + { + status: 'available', + providerId: 'anthropic', + modelId: 'claude', + completedAt: 10, + requestPrefix, + }, + { + range: { from: 0, to: 10 }, + totalRequests: 1, + totalCostUsd: 0, + totalTokens: { + input: 10, + output: 1, + cacheMiss: 5, + cacheRead: 5, + cacheWrite: 2, + reasoning: 0, + total: 11, + }, + cacheHitRequests: 1, + cacheCreateRequests: 1, + errorRequests: 0, + provenance: { + coverage: { + attempts: 1, + pricedAttempts: 1, + unpricedAttempts: 0, + usageReportedAttempts: 1, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 0, + unreadableRecords: 0, + pendingRepairs: 0, + }, + }, + ); + + assert.deepEqual(overview.requestPrefix, requestPrefix); + assert.equal(overview.cacheHitRate, 0.5); + assert.deepEqual(overview.providerCacheUsage, { read: 5, write: 2 }); + + const stale = deriveInspectorOverviewModel( + { + status: 'available', + providerId: 'anthropic', + modelId: 'claude', + completedAt: 10, + inputTokens: 10, + contextWindow: 100, + requestPrefix, + }, + undefined, + { contextCurrent: false }, + ); + assert.equal(stale.context, undefined); + assert.equal(stale.composition, undefined); + assert.equal(stale.requestPrefix, undefined); }); test('derives per-turn cost only from priced model-call step totals', () => { const cases: readonly { 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 06d0b5eed1..0dde50d6db 100644 --- a/apps/desktop/src/main/__tests__/use-session-trace.test.ts +++ b/apps/desktop/src/main/__tests__/use-session-trace.test.ts @@ -26,6 +26,7 @@ import { } from '@maka/core/session-trace'; import type { SessionEvent } from '@maka/core/events'; import type { Result } from '@maka/core/result'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeWorkbarServices, @@ -120,6 +121,10 @@ function createTraceHarness( sessionId: string, readIndex: number, ) => Promise>; + context?: ( + sessionId: string, + readIndex: number, + ) => Promise>; } = {}, ): TraceHarness { const handlers = new Set<(event: SessionEvent) => void>(); @@ -168,6 +173,7 @@ function createTraceHarness( // TRACE is re-read, and an enrichment read must not move them. context: async (sessionId: string) => { harness.contextReads.push(sessionId); + if (options.context) return options.context(sessionId, harness.contextReads.length); return { ok: true as const, data: { @@ -682,4 +688,110 @@ describe('useSessionTrace', () => { assert.equal(snapshot?.summary, undefined); assert.equal(snapshot?.summaryError, true); }); + + it('marks an old context snapshot non-current while its refresh is pending or failed', async () => { + const { root } = installReactRenderer(); + let resolveRefresh: + | ((result: Result) => void) + | undefined; + const refresh = new Promise>((resolve) => { + resolveRefresh = resolve; + }); + const harness = createTraceHarness({ + context: async (_sessionId, readIndex) => + readIndex === 1 + ? { + ok: true, + data: { + status: 'available', + providerId: 'anthropic', + modelId: 'claude', + completedAt: 1, + requestPrefix: { + status: 'preserved', + previousSegmentCount: 8, + preservedSegmentCount: 8, + }, + }, + } + : refresh, + }); + let snapshot: ReturnType | undefined; + await act(async () => { + root.render( + createElement(Probe, { + services: harness.services, + sessionId: 'session-1', + active: true, + onHookSnapshot: (value) => { + snapshot = value; + }, + }), + ); + }); + assert.equal(snapshot?.context?.status, 'available'); + assert.equal(snapshot?.contextLoading, false); + + await act(async () => harness.emit(event('complete'))); + await flushRefresh(); + + assert.equal(snapshot?.context?.status, 'available', 'the prior answer remains inspectable'); + assert.equal(snapshot?.contextLoading, true, 'but it is no longer marked current'); + assert.equal(snapshot?.contextError, undefined); + + await act(async () => { + resolveRefresh?.({ ok: false, error: { code: 'FAILED', message: 'failed' } }); + }); + assert.equal(snapshot?.contextLoading, false); + assert.equal(snapshot?.contextError, true); + }); + + it('invalidates context before an inactive Inspector can be reactivated', async () => { + const { root } = installReactRenderer(); + const pendingRefresh = new Promise>(() => undefined); + const harness = createTraceHarness({ + context: async (_sessionId, readIndex) => + readIndex === 1 + ? { + ok: true, + data: { + status: 'available', + providerId: 'anthropic', + modelId: 'claude', + completedAt: 1, + requestPrefix: { + status: 'preserved', + previousSegmentCount: 8, + preservedSegmentCount: 8, + }, + }, + } + : pendingRefresh, + }); + let snapshot: ReturnType | undefined; + const render = async (active: boolean) => { + await act(async () => { + root.render( + createElement(Probe, { + services: harness.services, + sessionId: 'session-1', + active, + onHookSnapshot: (value) => { + snapshot = value; + }, + }), + ); + }); + }; + + await render(true); + assert.equal(snapshot?.context?.status, 'available'); + + await render(false); + assert.equal(snapshot?.context, undefined); + + await render(true); + assert.equal(snapshot?.context, undefined); + assert.equal(snapshot?.contextLoading, true); + }); }); diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 9ecf0928f5..8ff638ad2c 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -32,7 +32,11 @@ export * from './model/workbar-tool-definitions.js'; export * from './tools/artifacts/artifact-list-keyboard.js'; export * from './tools/artifacts/artifact-visibility.js'; export * from './tools/inspector/session-inspector-panel-model.js'; -export { compactNumberFormatter, InspectorCompositionSection } from './tools/inspector/session-inspector-panel.js'; +export { + compactNumberFormatter, + InspectorCompositionSection, + InspectorRequestPrefixTag, +} from './tools/inspector/session-inspector-panel.js'; export * from './tools/inspector/session-inspector-overview-model.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts index 36e835d81f..928f13fd69 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts @@ -18,6 +18,7 @@ */ import type { + ContextDiagnosticsRequestPrefix, ContextDiagnosticsResult, ContextDiagnosticsSegment, } from '@maka/runtime-host/protocol'; @@ -135,11 +136,19 @@ export interface InspectorOverviewModel { * Absent when no input was metered at all — a rate over nothing is not * zero, it is unknown. * - * The only token figure the panel keeps. The raw totals it used to carry - * were priced by `totals.costUsd`, sized by the context bar and audited in - * the run ledger; three statements of the same tokens is two too many. + * Kept beside the provider's reported cache read/write counts, never used to + * infer the semantic request-prefix verdict. */ cacheHitRate?: number; + /** Positive provider cache token usage; never inferred from semantic continuity. */ + providerCacheUsage?: { readonly read?: number; readonly write?: number }; + /** Runtime/Host conclusion, passed through without local comparison. */ + requestPrefix?: ContextDiagnosticsRequestPrefix; +} + +export interface InspectorOverviewOptions { + /** False while the Host snapshot is refreshing or its refresh failed. */ + contextCurrent?: boolean; } export function estimatedSessionCost( @@ -173,19 +182,52 @@ export function hasUnavailableSessionUsage( export function deriveInspectorOverviewModel( diagnostics?: ContextDiagnosticsResult, usage?: SessionUsageSummary, + options?: InspectorOverviewOptions, ): 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); + const currentDiagnostics = options?.contextCurrent === false ? undefined : diagnostics; + const composition = compositionState(currentDiagnostics); + const context = contextBudget(currentDiagnostics); const cacheHitRate = usageCacheHitRate(usage); + const providerCacheUsage = completeProviderCacheUsage(usage); + const requestPrefix = + currentDiagnostics?.status === 'available' ? currentDiagnostics.requestPrefix : undefined; return { ...(context ? { context } : {}), ...(composition ? { composition } : {}), ...(cacheHitRate !== undefined ? { cacheHitRate } : {}), + ...(providerCacheUsage ? { providerCacheUsage } : {}), + ...(requestPrefix ? { requestPrefix } : {}), + }; +} + +function completeProviderCacheUsage( + usage: SessionUsageSummary | undefined, +): InspectorOverviewModel['providerCacheUsage'] { + const provenance = usage?.provenance; + if (!usage || !provenance) return undefined; + const coverage = provenance.coverage; + if ( + coverage.attempts === 0 || + coverage.usageReportedAttempts !== coverage.attempts || + coverage.usagePartialAttempts > 0 || + coverage.usageMissingAttempts > 0 || + provenance.legacyRecords > 0 || + provenance.unreadableRecords > 0 || + provenance.pendingRepairs > 0 + ) { + return undefined; + } + const read = usage.totalTokens.cacheRead > 0 ? usage.totalTokens.cacheRead : undefined; + const write = usage.totalTokens.cacheWrite > 0 ? usage.totalTokens.cacheWrite : undefined; + if (read === undefined && write === undefined) return undefined; + return { + ...(read !== undefined ? { read } : {}), + ...(write !== undefined ? { write } : {}), }; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx index 86749b04dc..266ab60938 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx @@ -19,6 +19,7 @@ import { type ReactNode, useMemo } from 'react'; import { Banner } from '@astryxdesign/core/Banner'; +import { Badge } from '@astryxdesign/core/Badge'; import { Button } from '@astryxdesign/core/Button'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Heading } from '@astryxdesign/core/Heading'; @@ -71,8 +72,16 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea }); const model = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]); const overview = useMemo( - () => deriveInspectorOverviewModel(snapshot.context, snapshot.summary), - [snapshot.context, snapshot.summary], + () => + deriveInspectorOverviewModel(snapshot.context, snapshot.summary, { + contextCurrent: !snapshot.contextLoading && !snapshot.contextError, + }), + [ + snapshot.context, + snapshot.contextError, + snapshot.contextLoading, + snapshot.summary, + ], ); async function copyPricingKey(key: string) { @@ -91,7 +100,9 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea className="maka-inspector-panel" data-maka-contract="session-inspector" aria-label={copy.ariaLabel} - aria-busy={snapshot.loading || snapshot.summaryLoading || undefined} + aria-busy={ + snapshot.loading || snapshot.summaryLoading || snapshot.contextLoading || undefined + } > {/* 24px between blocks against 8px inside one: proximity is the only grouping tool a panel without boxes has, and it used to spend the @@ -148,7 +159,7 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea {copy.summaryUnavailable} )} - {(snapshot.summary || overview.context || overview.composition) && ( + {(snapshot.summary || overview.context || overview.composition || overview.requestPrefix) && ( + {overview.requestPrefix && ( + + )} + {props.showTotals && ( )} + {overview.providerCacheUsage && ( + <> + {overview.providerCacheUsage.read !== undefined && ( + + )} + {overview.providerCacheUsage.write !== undefined && ( + + )} + + )} {copy.costEstimateHelp} @@ -310,6 +338,50 @@ function InspectorOverview(props: { ); } +export function InspectorRequestPrefixTag(props: { + copy: InspectorCopy; + requestPrefix: NonNullable['requestPrefix']>; +}) { + const { requestPrefix, copy } = props; + const label = (() => { + switch (requestPrefix.status) { + case 'no_predecessor': + return copy.overview.requestPrefix.noPredecessor; + case 'unavailable': + return copy.overview.requestPrefix.unavailable; + case 'preserved': + return copy.overview.requestPrefix.preserved( + requestPrefix.preservedSegmentCount, + requestPrefix.previousSegmentCount, + ); + case 'unknown': + return copy.overview.requestPrefix.unknown( + requestPrefix.preservedSegmentCount, + requestPrefix.previousSegmentCount, + ); + case 'diverged': { + const segment = requestPrefix.firstDivergentSegment; + return copy.overview.requestPrefix.diverged( + requestPrefix.preservedSegmentCount, + requestPrefix.previousSegmentCount, + copy.overview.requestPrefix.segment(segment.kind, segment.index, segment.label), + ); + } + } + })(); + const variant = + requestPrefix.status === 'preserved' + ? 'success' + : requestPrefix.status === 'diverged' + ? 'warning' + : 'neutral'; + return ( +
+ +
+ ); +} + /** * One overview total on the same title/readout rhythm as the sections below. * These figures answer parallel questions, so changing typography between the diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts index 5632085f8f..7a36d581a9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts @@ -50,6 +50,8 @@ interface SessionTraceState { loading: boolean; summaryLoading?: boolean; summaryError?: boolean; + contextLoading?: boolean; + contextError?: boolean; loadingEarlier?: boolean; error?: string; } @@ -255,26 +257,40 @@ export function useSessionTrace( }, [inspector]); const readContext = useCallback((targetSessionId: string) => { - const contextRevision = ++contextRevisionRef.current; - // 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 inspector.context(targetSessionId).then( - (result) => { - if (contextRevision !== contextRevisionRef.current) return; - setState((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. - }, - ); - }, [inspector]); + const contextRevision = ++contextRevisionRef.current; + setState((current) => + current.sessionId === targetSessionId + ? { ...current, contextLoading: true, contextError: undefined } + : { sessionId: targetSessionId, loading: false, contextLoading: true }, + ); + // 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 inspector.context(targetSessionId).then( + (result) => { + if (contextRevision !== contextRevisionRef.current) return; + setState((current) => + current.sessionId === targetSessionId + ? { + ...current, + ...(result.ok + ? { context: result.data, contextError: undefined } + : { contextError: true }), + contextLoading: false, + } + : current, + ); + }, + () => { + if (contextRevision !== contextRevisionRef.current) return; + setState((current) => + current.sessionId === targetSessionId + ? { ...current, contextLoading: false, contextError: true } + : current, + ); + }, + ); + }, [inspector]); const load = useCallback( (targetSessionId: string) => { @@ -294,6 +310,17 @@ export function useSessionTrace( traceWindowRef.current = undefined; desiredPageCountRef.current = undefined; setState(EMPTY_STATE); + } else { + setState((current) => + current.sessionId === sessionId + ? { + ...current, + context: undefined, + contextLoading: false, + contextError: undefined, + } + : current, + ); } return; } diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 918f95f605..b6a948711a 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -257,8 +257,18 @@ export interface DesktopConversationCopy { used: string; free: string; }; - /** The three figures a reader opens this tab for, as headline stats. */ + /** Provider usage figures shown independently from semantic continuity. */ cacheHit: string; + cacheRead: string; + cacheWrite: string; + requestPrefix: { + noPredecessor: string; + unavailable: string; + preserved: (preserved: number, previous: number) => string; + diverged: (preserved: number, previous: number, segment: string) => string; + unknown: (preserved: number, previous: number) => string; + segment: (kind: string, index: number, label?: string) => string; + }; /** Heading over the causal record. */ timelineTab: string; /** @@ -592,6 +602,26 @@ const COPY = { free: '剩余', }, cacheHit: '缓存命中率', + cacheRead: 'Provider 缓存读取', + cacheWrite: 'Provider 缓存写入', + requestPrefix: { + noPredecessor: '请求前缀 · 没有前驱', + unavailable: '请求前缀 · 不可用', + preserved: (preserved, previous) => `请求前缀 ${preserved}/${previous} · 已保持`, + diverged: (preserved, previous, segment) => + `请求前缀 ${preserved}/${previous} · 从${segment}开始分歧`, + unknown: (preserved, previous) => `请求前缀 ${preserved}/${previous} · 未知`, + segment: (kind, index, label) => + label ?? + `${ + { + message: '消息', + system_prompt: '系统提示', + tool_schema: '工具定义', + provider_options: 'Provider 参数', + }[kind] ?? kind + } ${index + 1}`, + }, timelineTab: '时间轴', composition: { title: '构成估算', @@ -826,6 +856,28 @@ const COPY = { free: 'Remaining', }, cacheHit: 'Cache hit rate', + cacheRead: 'Provider cache read', + cacheWrite: 'Provider cache write', + requestPrefix: { + noPredecessor: 'Request prefix · no predecessor', + unavailable: 'Request prefix · unavailable', + preserved: (preserved, previous) => + `Request prefix ${preserved}/${previous} · preserved`, + diverged: (preserved, previous, segment) => + `Request prefix ${preserved}/${previous} · diverged at ${segment}`, + unknown: (preserved, previous) => + `Request prefix ${preserved}/${previous} · unknown`, + segment: (kind, index, label) => + label ?? + `${ + { + message: 'message', + system_prompt: 'system prompt', + tool_schema: 'tool schema', + provider_options: 'provider options', + }[kind] ?? kind + } ${index + 1}`, + }, timelineTab: 'Timeline', composition: { title: 'Estimated composition', diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 8df2bef6ba..e8a6eb00f3 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -657,6 +657,11 @@ const populatedContext: ContextDiagnosticsResult = { // one — the bar splits the prompt only when the snapshot reports it. cacheReadInputTokens: 15_200, contextWindow: 200_000, + requestPrefix: { + status: 'preserved', + previousSegmentCount: 8, + preservedSegmentCount: 8, + }, composition: { segments: [ { kind: 'system_instructions', bytes: 12_000 }, @@ -710,6 +715,11 @@ const unrecordedContext: ContextDiagnosticsResult = { completedAt: NOW + 42_900, inputTokens: 18_900, contextWindow: 200_000, + requestPrefix: { + status: 'unavailable', + previousSegmentCount: 0, + preservedSegmentCount: 0, + }, }; const emptyTrace: SessionTrace = { diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ed9b810a0d..25a6f8fe18 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -60,7 +60,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned | -| `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned | +| `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner, Spinner | aligned — uses Astryx (Banner, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned | diff --git a/packages/core/src/__tests__/model-call-attempt.test.ts b/packages/core/src/__tests__/model-call-attempt.test.ts index f6b9e3389d..7c8cf4ad23 100644 --- a/packages/core/src/__tests__/model-call-attempt.test.ts +++ b/packages/core/src/__tests__/model-call-attempt.test.ts @@ -59,6 +59,85 @@ function attempt(overrides: Partial = {}): ModelCallAttempt { } describe('ModelCallAttempt codec', () => { + test('accepts one bounded prepared-request observation on the canonical attempt', () => { + const decoded = decodeModelCallAttempt({ + ...attempt(), + requestObservation: { + schemaVersion: 1, + digest: `sha256:${'a'.repeat(64)}`, + bytes: 42, + segments: [ + { + kind: 'message', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'b'.repeat(64)}`, + bytes: 21, + role: 'user', + }, + ], + }, + }); + + assert.equal(decoded.requestObservation?.segments[0]?.comparison, 'exact'); + }); + + test('rejects a prepared-request observation whose semantic segments are out of order', () => { + assert.throws(() => + decodeModelCallAttempt({ + ...attempt(), + requestObservation: { + schemaVersion: 1, + digest: `sha256:${'a'.repeat(64)}`, + bytes: 42, + segments: [ + { + kind: 'message', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'b'.repeat(64)}`, + bytes: 21, + }, + { + kind: 'system_prompt', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'c'.repeat(64)}`, + bytes: 21, + }, + ], + }, + }), + ); + }); + + test('accepts one explicit semantic-prefix predecessor on a main attempt', () => { + const decoded = decodeModelCallAttempt( + attempt({ + requestPrefixPredecessor: { kind: 'attempt', attemptId: 'attempt-0' }, + }), + ); + + assert.deepEqual(decoded.requestPrefixPredecessor, { + kind: 'attempt', + attemptId: 'attempt-0', + }); + }); + + test('accepts one non-secret request-prefix domain fingerprint', () => { + const requestPrefixDomain = `sha256:${'c'.repeat(64)}` as const; + const decoded = decodeModelCallAttempt( + attempt({ + requestPrefixDomain, + }), + ); + + assert.equal(decoded.requestPrefixDomain, requestPrefixDomain); + }); + test('accepts bounded provider failure diagnostics on history compaction calls', () => { const decoded = decodeModelCallAttempt( attempt({ diff --git a/packages/core/src/model-call-attempt.ts b/packages/core/src/model-call-attempt.ts index fad17c4ed2..10b577bc85 100644 --- a/packages/core/src/model-call-attempt.ts +++ b/packages/core/src/model-call-attempt.ts @@ -79,6 +79,74 @@ export type HistoryCompactRoute = (typeof HISTORY_COMPACT_ROUTES)[number]; /** Hard bound for provider-supplied diagnostic identifiers stored on an attempt. */ export const MODEL_CALL_DIAGNOSTIC_FIELD_MAX_LENGTH = 256; +export const PREPARED_REQUEST_OBSERVATION_SCHEMA_VERSION = 1 as const; +export const PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS = 256; +export const PREPARED_REQUEST_OBSERVATION_TEXT_MAX_LENGTH = 256; + +export type PreparedRequestObservationSegmentKind = + | 'tool_schema' + | 'system_prompt' + | 'message' + | 'provider_options'; + +/** + * One ordered semantic part of what Maka handed to the AI SDK model-call seam. + * + * `opaque` means the digest is useful for identity and auditing but MUST NOT be + * used to claim exact equality. It covers redacted content and bounded + * remainders that intentionally summarize more than one source segment. + */ +export interface PreparedRequestObservationSegment { + kind: PreparedRequestObservationSegmentKind; + index: number; + cacheable: boolean; + comparison: 'exact' | 'opaque'; + digest: string; + bytes: number; + representedSegments?: number; + role?: string; + label?: string; +} + +/** + * Bounded, secret-free observation of one prepared semantic model request. + * + * This is not the provider wire body. The full secret-free serialization stays + * in the private request artifact referenced by `captureArtifactId` when that + * sink is available. + */ +export interface PreparedRequestObservation { + schemaVersion: typeof PREPARED_REQUEST_OBSERVATION_SCHEMA_VERSION; + /** + * Identity of the complete secret-free normalized serialization. It does not + * prove semantic equality when any segment is `opaque`; continuity consumers + * must compare the ordered segment identity and each segment's `comparison`. + */ + digest: string; + bytes: number; + segments: PreparedRequestObservationSegment[]; +} + +/** + * Durable causal predecessor selected for semantic request-prefix comparison. + * + * The selection is stored on the canonical physical attempt so projections do + * not have to reconstruct causality from append order or timestamps. Absence + * is reserved for historical records written before this contract existed. + */ +export type RequestPrefixPredecessor = + | { kind: 'none' } + | { kind: 'unavailable' } + | { kind: 'attempt'; attemptId: string }; + +/** + * Secret-free identity of the effective provider/cache domain for one request. + * + * The Host derives this from facts known at dispatch. The digest qualifies + * whether two observations may be compared; it is not another observation. + */ +export type RequestPrefixDomainFingerprint = `sha256:${string}`; + export interface ModelCallAttempt { schemaVersion: typeof MODEL_CALL_ATTEMPT_SCHEMA_VERSION; @@ -91,7 +159,7 @@ export interface ModelCallAttempt { logicalCallId: string; /** Idempotency key: appending the same `attemptId` twice records once. */ attemptId: string; - /** Tracker instance id, retained to join request-shape capture artifacts. */ + /** Tracker instance id, retained to join private prepared-request artifacts. */ traceId: string; /** @@ -116,8 +184,14 @@ export interface ModelCallAttempt { providerId: string; modelId: string; contextWindow?: number; - /** Join key for request-shape diagnostics; absent when capture is disabled. */ + /** Join key for the private prepared-request artifact, when best-effort persistence won the race. */ captureArtifactId?: string; + /** Semantic request actually prepared for this dispatched physical attempt. */ + requestObservation?: PreparedRequestObservation; + /** Effective non-secret request domain captured by the dispatch authority. */ + requestPrefixDomain?: RequestPrefixDomainFingerprint; + /** Explicit predecessor authority for main-lane semantic prefix continuity. */ + requestPrefixPredecessor?: RequestPrefixPredecessor; startedAt: number; completedAt: number; @@ -175,6 +249,9 @@ const MODEL_CALL_ATTEMPT_SHAPE = defineObjectShape()( 'historyCompactRoute', 'contextWindow', 'captureArtifactId', + 'requestObservation', + 'requestPrefixDomain', + 'requestPrefixPredecessor', 'timeToFirstTokenMs', 'finishReason', 'errorClass', @@ -203,6 +280,24 @@ const TOKEN_FIELDS = [ 'reasoningTokens', ] as const satisfies readonly (keyof ModelCallAttempt)[]; +const PREPARED_REQUEST_OBSERVATION_SHAPE = defineObjectShape()( + ['schemaVersion', 'digest', 'bytes', 'segments'], + [], +); + +const PREPARED_REQUEST_OBSERVATION_SEGMENT_SHAPE = + defineObjectShape()( + ['kind', 'index', 'cacheable', 'comparison', 'digest', 'bytes'], + ['representedSegments', 'role', 'label'], + ); + +const PREPARED_REQUEST_SEGMENT_KINDS: readonly PreparedRequestObservationSegmentKind[] = [ + 'tool_schema', + 'system_prompt', + 'message', + 'provider_options', +]; + function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } @@ -235,6 +330,81 @@ function isOptionalHttpStatus(value: unknown): boolean { ); } +function isSha256Digest(value: unknown): value is string { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function isOptionalBoundedText(value: unknown): boolean { + return ( + value === undefined || + (typeof value === 'string' && value.length <= PREPARED_REQUEST_OBSERVATION_TEXT_MAX_LENGTH) + ); +} + +function isPreparedRequestObservationSegment( + value: unknown, +): value is PreparedRequestObservationSegment { + if (!isRecord(value) || !hasExactShape(value, PREPARED_REQUEST_OBSERVATION_SEGMENT_SHAPE)) { + return false; + } + return ( + PREPARED_REQUEST_SEGMENT_KINDS.includes(value.kind as PreparedRequestObservationSegmentKind) && + isNonNegativeInteger(value.index) && + typeof value.cacheable === 'boolean' && + (value.comparison === 'exact' || value.comparison === 'opaque') && + isSha256Digest(value.digest) && + isNonNegativeInteger(value.bytes) && + (value.representedSegments === undefined || + (typeof value.representedSegments === 'number' && + Number.isSafeInteger(value.representedSegments) && + value.representedSegments > 0)) && + (value.representedSegments === undefined || + value.representedSegments === 1 || + value.comparison === 'opaque') && + isOptionalBoundedText(value.role) && + isOptionalBoundedText(value.label) + ); +} + +function isPreparedRequestObservation(value: unknown): value is PreparedRequestObservation { + if (!isRecord(value) || !hasExactShape(value, PREPARED_REQUEST_OBSERVATION_SHAPE)) return false; + return ( + value.schemaVersion === PREPARED_REQUEST_OBSERVATION_SCHEMA_VERSION && + isSha256Digest(value.digest) && + isNonNegativeInteger(value.bytes) && + Array.isArray(value.segments) && + value.segments.length <= PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS && + value.segments.every(isPreparedRequestObservationSegment) && + hasOrderedPreparedRequestSegments(value.segments) + ); +} + +function hasOrderedPreparedRequestSegments( + segments: readonly PreparedRequestObservationSegment[], +): boolean { + let previousKind = -1; + let previousIndex = -1; + for (const segment of segments) { + const kind = PREPARED_REQUEST_SEGMENT_KINDS.indexOf(segment.kind); + if (kind < previousKind) return false; + if (kind === previousKind && segment.index <= previousIndex) return false; + if (kind !== previousKind) previousIndex = -1; + previousKind = kind; + previousIndex = segment.index; + } + return true; +} + +function isRequestPrefixPredecessor(value: unknown): value is RequestPrefixPredecessor { + if (!isRecord(value)) return false; + if (value.kind === 'none' || value.kind === 'unavailable') { + return Object.keys(value).length === 1; + } + return ( + value.kind === 'attempt' && Object.keys(value).length === 2 && isNonEmptyString(value.attemptId) + ); +} + const PRICING_RATES_SHAPE = defineObjectShape()( ['modelKey', 'inputUsdPer1M', 'outputUsdPer1M'], ['cacheReadUsdPer1M', 'cacheWriteUsdPer1M'], @@ -286,6 +456,11 @@ export function decodeModelCallAttempt(value: unknown): ModelCallAttempt { isNonEmptyString(value.modelId) && isOptionalNonNegativeNumber(value.contextWindow) && isOptionalString(value.captureArtifactId) && + (value.requestObservation === undefined || + isPreparedRequestObservation(value.requestObservation)) && + (value.requestPrefixDomain === undefined || isSha256Digest(value.requestPrefixDomain)) && + (value.requestPrefixPredecessor === undefined || + isRequestPrefixPredecessor(value.requestPrefixPredecessor)) && isFiniteNumber(value.startedAt) && isFiniteNumber(value.completedAt) && isNonNegativeNumber(value.latencyMs) && @@ -313,6 +488,12 @@ export function decodeModelCallAttempt(value: unknown): ModelCallAttempt { if (value.historyCompactRoute !== undefined && value.callKind !== 'history_compact') { throw new Error('ModelCallAttempt non-compaction call carries historyCompactRoute'); } + if (value.requestPrefixPredecessor !== undefined && value.callKind !== 'main') { + throw new Error('ModelCallAttempt non-main call carries requestPrefixPredecessor'); + } + if (value.requestPrefixDomain !== undefined && value.callKind !== 'main') { + throw new Error('ModelCallAttempt non-main call carries requestPrefixDomain'); + } // `costBasis` and `costUsd` travel together in both directions. A price we // could not resolve must never be published as an amount, and a priced record // must carry one — otherwise coverage counts it as priced while the sum skips diff --git a/packages/runtime-host/src/__tests__/context-protocol.test.ts b/packages/runtime-host/src/__tests__/context-protocol.test.ts index 2e555ff508..7c48502f87 100644 --- a/packages/runtime-host/src/__tests__/context-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/context-protocol.test.ts @@ -71,6 +71,12 @@ test('context operations preserve bounded exact wire values', () => { turnCount: 2, estimatedTokens: 12, }, + requestPrefix: { + status: 'diverged', + previousSegmentCount: 8, + preservedSegmentCount: 3, + firstDivergentSegment: { kind: 'message', index: 2, role: 'user' }, + }, }, }), { @@ -96,6 +102,12 @@ test('context operations preserve bounded exact wire values', () => { turnCount: 2, estimatedTokens: 12, }, + requestPrefix: { + status: 'diverged', + previousSegmentCount: 8, + preservedSegmentCount: 3, + firstDivergentSegment: { kind: 'message', index: 2, role: 'user' }, + }, }, }, ); @@ -193,6 +205,42 @@ test('context operations reject open shapes and invalid diagnostics', () => { }), isProtocolError, ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-query-missing-prefix', + operation: 'context.diagnostics.query', + ok: true, + result: { + status: 'available', + providerId: 'openrouter', + modelId: 'openrouter/free', + completedAt: 10, + }, + }), + isProtocolError, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-query-prefix', + operation: 'context.diagnostics.query', + ok: true, + result: { + status: 'available', + providerId: 'openrouter', + modelId: 'openrouter/free', + completedAt: 10, + requestPrefix: { + status: 'preserved', + previousSegmentCount: 4, + preservedSegmentCount: 4, + firstDivergentSegment: { kind: 'message', index: 0 }, + }, + }, + }), + isProtocolError, + ); }); function isProtocolError(error: unknown): boolean { diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index e7a74b52a6..7697bddb64 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -611,6 +611,127 @@ test('provider dispatch fails closed when the Run Composition commit fails', asy } }); +test('canonical attempts carry the dispatch-owned credential and transport domain', async () => { + const provider = await startProvider(); + let sendIndex = 0; + type NetworkProxy = ReturnType['networkProxy']; + const proxy = (overrides: Partial = {}): NetworkProxy => ({ + enabled: true, + protocol: 'http', + host: 'proxy.example', + port: 8080, + authEnabled: false, + username: '', + bypassList: [], + autoBypassDomains: [], + ...overrides, + }); + const send = async ( + customization: Parameters[1] = {}, + ): Promise => { + sendIndex += 1; + const attempts: ModelCallAttempt[] = []; + const backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => + readyExecutionConnection(provider.baseUrl, customization), + readPricing: async () => ({ revision: 0, overrides: [] }), + recordModelCallAttempt: async ({ attempt }) => { + attempts.push(attempt); + }, + createFetchTransport: () => ({ + fetch: globalThis.fetch, + close: async () => undefined, + }), + }), + ); + try { + for await (const _event of backend.send({ + invocationId: `domain-invocation-${sendIndex}`, + runId: `domain-run-${sendIndex}`, + turnId: `domain-turn-${sendIndex}`, + text: 'Keep this semantic request stable.', + context: [], + })) { + // Drain the request so canonical settlement runs. + } + } finally { + await backend.dispose(); + } + assert.equal(attempts.length, 1); + return attempts[0]!; + }; + + try { + const before = await send(); + const afterCredentialRotation = await send({ credentialRevision: 2 }); + const afterRequestOverlay = await send({ + connectionRevision: 2, + requestBodyOverlay: { partition: 'other' }, + }); + const beforeRequestOverlayReorder = await send({ + connectionRevision: 3, + requestBodyOverlay: { required: ['alpha', 'beta'] }, + }); + const afterRequestOverlayReorder = await send({ + connectionRevision: 4, + requestBodyOverlay: { required: ['beta', 'alpha'] }, + }); + const afterProxyChange = await send({ + networkProxy: proxy(), + }); + const beforeProxyCredentialRotation = await send({ + networkProxy: proxy({ authEnabled: true, username: 'proxy-user' }), + proxyCredentialRevision: 1, + }); + const afterProxyCredentialRotation = await send({ + networkProxy: proxy({ authEnabled: true, username: 'proxy-user' }), + proxyCredentialRevision: 2, + }); + const dormantDirectProxy = await send({ + networkProxy: proxy({ + enabled: false, + host: 'dormant.example', + port: 9000, + username: 'ignored', + }), + }); + const beforeEquivalentProxyNormalization = await send({ + networkProxy: proxy({ + username: 'ignored-a', + bypassList: [' LOCALHOST ', '*.Example'], + autoBypassDomains: ['localhost'], + }), + }); + const afterEquivalentProxyNormalization = await send({ + networkProxy: proxy({ + username: 'ignored-b', + bypassList: ['*.example', 'localhost'], + }), + }); + assert.match(before.requestPrefixDomain ?? '', /^sha256:[a-f0-9]{64}$/); + assert.notEqual(afterCredentialRotation.requestPrefixDomain, before.requestPrefixDomain); + assert.notEqual(afterRequestOverlay.requestPrefixDomain, before.requestPrefixDomain); + assert.notEqual( + afterRequestOverlayReorder.requestPrefixDomain, + beforeRequestOverlayReorder.requestPrefixDomain, + ); + assert.notEqual(afterProxyChange.requestPrefixDomain, before.requestPrefixDomain); + assert.notEqual( + afterProxyCredentialRotation.requestPrefixDomain, + beforeProxyCredentialRotation.requestPrefixDomain, + ); + assert.equal(dormantDirectProxy.requestPrefixDomain, before.requestPrefixDomain); + assert.equal( + afterEquivalentProxyNormalization.requestPrefixDomain, + beforeEquivalentProxyNormalization.requestPrefixDomain, + ); + } finally { + await provider.close(); + } +}); + test('Codex OAuth history compaction falls back to a text checkpoint after native rejection', async () => { const modelId = 'gpt-5.6-sol'; const requests: Array<{ url: string; body: Record }> = []; @@ -1458,6 +1579,7 @@ test('production Host executes a canonical ai-sdk Session against a real provide assert.equal(webSearchEnabled.kind, 'committed'); const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const usageStores = await openInteractiveUsageStoresForWrite(owner.lease); const session = await execution.sessionStore.create({ cwd: root, llmConnectionId: connection.connectionId, @@ -1634,14 +1756,30 @@ test('production Host executes a canonical ai-sdk Session against a real provide assert.equal(compactUsage.inputTokens, 7); assert.equal(compactUsage.outputTokens, 3); const capturedRequestCount = mainRequests.length + compactRequests.length; - const evidence = await waitForProviderEvidence(execution, session.id, capturedRequestCount); - assert.equal(evidence.captures.length, capturedRequestCount); - assert.equal(evidence.attempts.length, capturedRequestCount); + const attempts = await waitForCanonicalAttempts(usageStores, session.id, capturedRequestCount); + assert.equal(attempts.length, capturedRequestCount); + assert.ok(attempts.every((attempt) => attempt.requestObservation)); + const contextDiagnostics = await composition.handlers['context.diagnostics.query']( + { sessionId: session.id }, + connectionContext, + ); + assert.equal(contextDiagnostics.ok, true); + if (contextDiagnostics.ok) { + assert.equal(contextDiagnostics.result.status, 'available'); + if (contextDiagnostics.result.status === 'available') { + assert.ok( + contextDiagnostics.result.composition?.segments.some( + (segment) => segment.kind === 'messages', + ), + ); + } + } const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); - const artifactPage = await artifacts.listPage(session.id, { offset: 0, limit: 100 }); - const captureArtifacts = artifactPage.records.filter( - (artifact) => artifact.source === 'provider_request_capture', + const captureArtifacts = await waitForCaptureArtifacts( + artifacts, + session.id, + capturedRequestCount, ); assert.equal(captureArtifacts.length, capturedRequestCount); let summaryCaptureFound = false; @@ -1671,9 +1809,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide failedStart, connectionContext, ); - assert.equal(failedTerminal.status, 'failed'); - assert.equal(provider.requests.length, requestsBeforeArtifactFailure); - assert.equal(drainRequests, 1); + assert.equal(failedTerminal.status, 'completed'); + assert.equal(provider.requests.length, requestsBeforeArtifactFailure + 1); + assert.equal(drainRequests, 0); } finally { try { await composition?.close(); @@ -3449,36 +3587,48 @@ async function waitForUsage( throw new Error('Hosted real-model usage attribution was not persisted'); } -async function waitForProviderEvidence( - execution: Awaited>, +async function waitForCanonicalAttempts( + usage: InteractiveUsageStoresWriter, sessionId: string, expectedRequests: number, -): Promise<{ captures: unknown[]; attempts: unknown[] }> { +): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { - const runs = await execution.agentRunStore.listSessionRuns(sessionId); - const events = ( - await Promise.all(runs.map((run) => execution.agentRunStore.readEvents(sessionId, run.runId))) - ).flat(); - const captures = events.filter((event) => event.type === 'provider_request_captured'); - const attempts = events.filter((event) => event.type === 'provider_request_attempt_recorded'); - if (captures.length >= expectedRequests && attempts.length >= expectedRequests) { - return { captures, attempts }; - } + const page = await usage.modelCalls.modelCallAttempts( + { from: 0, to: Number.MAX_SAFE_INTEGER }, + sessionId, + ); + if (page.attempts.length >= expectedRequests) return page.attempts; await new Promise((resolve) => setTimeout(resolve, 10)); } - const runs = await execution.agentRunStore.listSessionRuns(sessionId); - const events = ( - await Promise.all(runs.map((run) => execution.agentRunStore.readEvents(sessionId, run.runId))) - ).flat(); + const page = await usage.modelCalls.modelCallAttempts( + { from: 0, to: Number.MAX_SAFE_INTEGER }, + sessionId, + ); throw new Error( - `Hosted provider request evidence was not persisted: ${JSON.stringify({ + `Hosted canonical model-call attempts were not persisted: ${JSON.stringify({ expectedRequests, - captures: events.filter((event) => event.type === 'provider_request_captured').length, - attempts: events.filter((event) => event.type === 'provider_request_attempt_recorded').length, + attempts: page.attempts.length, + unreadableRecords: page.unreadableRecords, })}`, ); } +async function waitForCaptureArtifacts( + artifacts: Awaited>, + sessionId: string, + expectedRequests: number, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const page = await artifacts.listPage(sessionId, { offset: 0, limit: 100 }); + const captures = page.records.filter( + (artifact) => artifact.source === 'provider_request_capture', + ); + if (captures.length >= expectedRequests) return captures; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Hosted request artifacts did not reach ${expectedRequests}`); +} + async function waitForAutomaticMemoryRequestsToSettle( requests: readonly ProviderRequest[], ): Promise { @@ -3667,11 +3817,17 @@ function readyExecutionConnection( customization: { readonly requestHeaders?: Readonly>; readonly requestBodyOverlay?: Readonly>; + readonly connectionRevision?: number; + readonly credentialRevision?: number; + readonly networkProxy?: ReturnType['networkProxy']; + readonly proxyCredentialRevision?: number; } = {}, ) { return { kind: 'ready', connection: { + connectionId: '11111111-1111-4111-8111-111111111111', + revision: customization.connectionRevision ?? 1, slug: 'backend-creation-connection', providerType: 'moonshot', ...(baseUrl ? { baseUrl } : {}), @@ -3688,12 +3844,42 @@ function readyExecutionConnection( }, ], }, - networkProxy: { enabled: false }, + networkProxy: customization.networkProxy ?? { enabled: false }, secretMaterial: { - connection: { secret: API_KEY }, + connection: { + secret: API_KEY, + credentialId: '22222222-2222-4222-8222-222222222222', + revision: customization.credentialRevision ?? 1, + locator: { + scope: 'connection', + connectionId: '11111111-1111-4111-8111-111111111111', + kind: 'api_key', + }, + }, ...(customization.requestHeaders - ? { requestHeaders: { secret: JSON.stringify(customization.requestHeaders) } } + ? { + requestHeaders: { + secret: JSON.stringify(customization.requestHeaders), + credentialId: '33333333-3333-4333-8333-333333333333', + revision: 1, + locator: { + scope: 'connection', + connectionId: '11111111-1111-4111-8111-111111111111', + kind: 'request_headers', + }, + }, + } : {}), + ...(customization.proxyCredentialRevision === undefined + ? {} + : { + networkProxy: { + secret: 'proxy-password', + credentialId: '44444444-4444-4444-8444-444444444444', + revision: customization.proxyCredentialRevision, + locator: { scope: 'network_proxy' }, + }, + }), }, }; } diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 86af74820d..641dab2dc8 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -221,6 +221,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 53); }); + test('publishes a new compatibility epoch for semantic request-prefix diagnostics', () => { + // Epoch 78 Clients reject requestPrefix on the closed context diagnostics + // result, so mixed-version peers must fail during the handshake instead. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 78); + }); + test('publishes a new compatibility epoch for queued message editing', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); }); diff --git a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts index 2502955ae0..7c537f5504 100644 --- a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts +++ b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts @@ -43,6 +43,7 @@ test('poisons a Session after an ambiguous durable admission failure', async () } return result; }, + importConversationCopyRootTurn: (input) => durableStore.importConversationCopyRootTurn(input), readRootTurnAdmission: (sessionId, turnId) => durableStore.readRootTurnAdmission(sessionId, turnId), readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => @@ -194,6 +195,7 @@ test('snapshots recovered admissions without retaining mutable caller references const admission = mutableAdmission(); const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission }), + importConversationCopyRootTurn: async () => ({ kind: 'admitted', admission }), readRootTurnAdmission: async () => admission, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [admission], @@ -276,6 +278,10 @@ test('returns an owned admission instead of retaining the mutable store result', const durableAdmission = mutableAdmission(); const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission: durableAdmission }), + importConversationCopyRootTurn: async () => ({ + kind: 'admitted', + admission: durableAdmission, + }), readRootTurnAdmission: async () => durableAdmission, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [], diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index f4bd6ec3b5..fb219292c4 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1114,6 +1114,7 @@ test('idle Skill admission persists a canonical draft without history before roo admitRootTurn: async () => { throw new Error('injected root admission failure'); }, + importConversationCopyRootTurn: (input) => store.importConversationCopyRootTurn(input), readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), @@ -3031,6 +3032,7 @@ test('successor admission failure retains the terminal transition and its confir } return store.admitRootTurn(input); }, + importConversationCopyRootTurn: (input) => store.importConversationCopyRootTurn(input), readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), @@ -3139,6 +3141,7 @@ test('shutdown contains a successor backend start rejected by Interaction drain' } return store.admitRootTurn(input); }, + importConversationCopyRootTurn: (input) => store.importConversationCopyRootTurn(input), readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 7ba9c351de..0128c6d188 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -430,6 +430,17 @@ async function verifyConcurrentRevisionAuthority( }), { kind: 'session', session: null }, ); + const retriedAbandonedRevision = await desktop.request('session.revision.create', { + ...revisionInput, + targetSessionId: abandonedTargetId, + }); + assert.equal(retriedAbandonedRevision.kind, 'committed'); + assert.deepEqual( + await desktop.request('session.revision.abandon', { + targetSessionId: abandonedTargetId, + }), + { kind: 'abandoned', sessionId: abandonedTargetId }, + ); const sourceAfterRevision = await querySession(tui, sourceSessionId); const staleExpectedRevision = sourceAfterRevision.revision + 1; @@ -635,6 +646,11 @@ async function verifyRestartRecoveryAndAdmission( expectedSourceRevision: lineageSource.revision, }); assert.equal(lineageBranch.kind, 'committed'); + await restarted.request('turn.start', { + sessionId: LINEAGE_BRANCH_TARGET_ID, + turnId: 'lineage-branch-first-new-turn', + content: { text: 'continue from the copied causal tip' }, + }); assert.deepEqual( await restarted.request('session.revision.abandon', { targetSessionId: LINEAGE_REVISION_TARGET_ID, @@ -1024,6 +1040,45 @@ async function seedSource( for (const event of sourceRuntimeEvents) { await execution.runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); } + const firstAdmission = await execution.agentRunStore.admitRootTurn({ + sessionId: source.id, + turnId: 'turn-1', + proposedRunId: 'run-turn-1', + proposedUserMessageId: 'user-1', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { + text: 'first', + attachments: [ + { + kind: 'code', + name: 'source.txt', + mimeType: 'text/plain', + bytes: 14, + ref: { + kind: 'session_file', + sessionId: source.id, + relativePath: artifact.relativePath, + }, + }, + ], + }, + sourceMessages: [], + admittedAt: 1, + }); + assert.equal(firstAdmission.kind, 'admitted'); + const secondAdmission = await execution.agentRunStore.admitRootTurn({ + sessionId: source.id, + turnId: 'turn-2', + proposedRunId: 'run-turn-2', + proposedUserMessageId: 'user-2', + execution: { kind: 'external_message' }, + previousRootTurnId: 'turn-1', + normalizedInput: { text: 'second' }, + sourceMessages: [], + admittedAt: 3, + }); + assert.equal(secondAdmission.kind, 'admitted'); const graphId = agentGraphIdForRootSession(linkedChildSource.id); const graphWorkId = `graph_work_${'1'.repeat(32)}`; const graphOperatorId = `graph_operator_${'2'.repeat(32)}`; @@ -1570,6 +1625,18 @@ async function verifyDurableBranch( (await execution.sessionStore.readHeaderSnapshot(lineageBranchTargetId)).parentSessionId, lineageRevisionTargetId, ); + const copiedLineage = + await execution.agentRunStore.listRootTurnAdmissionsForRecovery(lineageBranchTargetId); + assert.deepEqual( + copiedLineage.map((admission) => ({ + turnId: admission.turnId, + previousRootTurnId: admission.previousRootTurnId, + })), + [ + { turnId: 'turn-1', previousRootTurnId: null }, + { turnId: 'lineage-branch-first-new-turn', previousRootTurnId: 'turn-1' }, + ], + ); const sideConversationHeader = await execution.sessionStore.readHeaderSnapshot( graphSideConversationTargetId, ); diff --git a/packages/runtime-host/src/protocol/context.ts b/packages/runtime-host/src/protocol/context.ts index 6d0e18c56c..cecd170548 100644 --- a/packages/runtime-host/src/protocol/context.ts +++ b/packages/runtime-host/src/protocol/context.ts @@ -71,6 +71,26 @@ export interface ContextDiagnosticsComposition { readonly unlabelledToolBytes?: number; } +export interface ContextDiagnosticsRequestPrefixSegment { + readonly kind: 'tool_schema' | 'system_prompt' | 'message' | 'provider_options'; + readonly index: number; + readonly role?: string; + readonly label?: string; +} + +export type ContextDiagnosticsRequestPrefix = + | { + readonly status: 'no_predecessor' | 'preserved' | 'unknown' | 'unavailable'; + readonly previousSegmentCount: number; + readonly preservedSegmentCount: number; + } + | { + readonly status: 'diverged'; + readonly previousSegmentCount: number; + readonly preservedSegmentCount: number; + readonly firstDivergentSegment: ContextDiagnosticsRequestPrefixSegment; + }; + export type ContextDiagnosticsResult = | { readonly status: 'unavailable'; @@ -98,6 +118,8 @@ export type ContextDiagnosticsResult = readonly turnCount: number; readonly estimatedTokens: number; }; + /** Runtime-owned semantic verdict; distinct from provider cache usage. */ + readonly requestPrefix: ContextDiagnosticsRequestPrefix; }; const QUERY_ERRORS = [ @@ -174,6 +196,7 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul 'contextWindow', 'composition', 'compaction', + 'requestPrefix', ], ); if (record.status === 'unavailable') { @@ -195,7 +218,7 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul const available = requireShapedRecord( record, 'Available context diagnostics', - ['status', 'providerId', 'modelId', 'completedAt'], + ['status', 'providerId', 'modelId', 'completedAt', 'requestPrefix'], ['inputTokens', 'cacheReadInputTokens', 'contextWindow', 'composition', 'compaction'], ); return { @@ -223,6 +246,7 @@ function decodeContextDiagnosticsResult(value: unknown): ContextDiagnosticsResul ...(available.compaction === undefined ? {} : { compaction: decodeContextDiagnosticsCompaction(available.compaction) }), + requestPrefix: decodeContextDiagnosticsRequestPrefix(available.requestPrefix), }; } @@ -322,6 +346,88 @@ function decodeContextDiagnosticsCompaction( }; } +function decodeContextDiagnosticsRequestPrefix(value: unknown): ContextDiagnosticsRequestPrefix { + const prefix = requireShapedRecord( + value, + 'Context diagnostics request prefix', + ['status', 'previousSegmentCount', 'preservedSegmentCount'], + ['firstDivergentSegment'], + ); + const previousSegmentCount = requireCount(prefix.previousSegmentCount, 'previousSegmentCount'); + const preservedSegmentCount = requireCount(prefix.preservedSegmentCount, 'preservedSegmentCount'); + if (preservedSegmentCount > previousSegmentCount) { + throw invalidProtocolFrame('Invalid context diagnostics request prefix counts'); + } + if (prefix.status === 'diverged') { + if (prefix.firstDivergentSegment === undefined) { + throw invalidProtocolFrame('Missing first divergent request segment'); + } + return { + status: 'diverged', + previousSegmentCount, + preservedSegmentCount, + firstDivergentSegment: decodeContextDiagnosticsRequestPrefixSegment( + prefix.firstDivergentSegment, + ), + }; + } + if ( + prefix.status !== 'no_predecessor' && + prefix.status !== 'preserved' && + prefix.status !== 'unknown' && + prefix.status !== 'unavailable' + ) { + throw invalidProtocolFrame('Invalid context diagnostics request prefix status'); + } + if (prefix.firstDivergentSegment !== undefined) { + throw invalidProtocolFrame('Unexpected first divergent request segment'); + } + if ( + ((prefix.status === 'no_predecessor' || prefix.status === 'unavailable') && + (previousSegmentCount !== 0 || preservedSegmentCount !== 0)) || + (prefix.status === 'preserved' && preservedSegmentCount !== previousSegmentCount) + ) { + throw invalidProtocolFrame('Invalid context diagnostics request prefix counts'); + } + return { status: prefix.status, previousSegmentCount, preservedSegmentCount }; +} + +function decodeContextDiagnosticsRequestPrefixSegment( + value: unknown, +): ContextDiagnosticsRequestPrefixSegment { + const segment = requireShapedRecord( + value, + 'Context diagnostics request prefix segment', + ['kind', 'index'], + ['role', 'label'], + ); + if ( + segment.kind !== 'tool_schema' && + segment.kind !== 'system_prompt' && + segment.kind !== 'message' && + segment.kind !== 'provider_options' + ) { + throw invalidProtocolFrame('Invalid context diagnostics request prefix segment kind'); + } + return { + kind: segment.kind, + index: requireCount(segment.index, 'requestPrefixSegmentIndex'), + ...(segment.role === undefined + ? {} + : { role: requireBoundedText(segment.role, 'requestPrefixSegmentRole') }), + ...(segment.label === undefined + ? {} + : { label: requireBoundedText(segment.label, 'requestPrefixSegmentLabel') }), + }; +} + +function requireBoundedText(value: unknown, name: string): string { + if (typeof value !== 'string' || value.length > 256) { + throw invalidProtocolFrame(`Invalid ${name}`); + } + return value; +} + function requirePositiveCount(value: unknown, name: string): number { const count = requireCount(value, name); if (count === 0) throw invalidProtocolFrame(`Invalid ${name}`); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index c02211477c..09fe22644b 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 78 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 79 as const; +// 79: Context diagnostics may carry Runtime-owned semantic request-prefix +// continuity. Older Clients reject the added field on the closed result shape. // 78: OAuth login targets explicit create/existing Connection entities and // returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d98a8de3be..8ef9ee5150 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1459,6 +1459,7 @@ export async function createExecutionRuntimeHostComposition( manager, admission: sessionAdmission, continuity: continuityCoordinator, + rootAdmissions: rootAdmissionOwner, graph: requireGraphCoordinator(graphCoordinator), isSessionActive: (sessionId) => coordinator.readRootState(sessionId).kind !== 'idle', requestDrain: context.requestDrain, diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 9cc68188b3..0397d862b8 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -67,7 +67,11 @@ import { type HostOAuthExecutionAuthority, type HostOAuthExecutionBinding, } from './oauth-execution-authority.js'; -import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; +import { + runtimePolicyProxyDomainBasis, + toRuntimePolicyProxy, + type RuntimePolicyProxyDomainBasis, +} from './runtime-policy-proxy.js'; export interface HostGoalEvaluatorInput { readonly runtimePolicy: RuntimePolicyStoresWriter; @@ -737,6 +741,23 @@ interface ResolvedExecutionTarget { readonly oauthBinding?: HostOAuthExecutionBinding; readonly networkProxy: RuntimePolicy['networkProxy']; readonly proxySecret?: string; + readonly requestPrefixDomainBasis: { + readonly connectionId: string; + readonly requestBodyOverlayRevision: number | null; + readonly connectionCredential: { + readonly credentialId: string; + readonly revision: number; + } | null; + readonly requestHeadersCredential: { + readonly credentialId: string; + readonly revision: number; + } | null; + readonly networkProxyCredential: { + readonly credentialId: string; + readonly revision: number; + } | null; + readonly networkProxy: RuntimePolicyProxyDomainBasis; + }; } async function resolveDailyReviewHeader( @@ -852,6 +873,18 @@ export async function resolveExecutionTarget( const requestHeaders = resolved.secretMaterial.requestHeaders ? parseRequestHeaders(resolved.secretMaterial.requestHeaders.secret) : {}; + const requestPrefixDomainBasis = { + connectionId: resolved.connection.connectionId, + requestBodyOverlayRevision: + resolved.connection.requestBodyOverlay === undefined ? null : resolved.connection.revision, + connectionCredential: credentialVersionBasis(resolved.secretMaterial.connection), + requestHeadersCredential: credentialVersionBasis(resolved.secretMaterial.requestHeaders), + networkProxyCredential: + resolved.networkProxy.enabled && resolved.networkProxy.authEnabled + ? credentialVersionBasis(resolved.secretMaterial.networkProxy) + : null, + networkProxy: runtimePolicyProxyDomainBasis(resolved.networkProxy), + }; if (provider.authKind === 'oauth_token') { const material = resolved.secretMaterial.connection; if (!material) { @@ -876,6 +909,7 @@ export async function resolveExecutionTarget( createRefreshTransport: () => createFetchTransport(refreshProxy), }), networkProxy: resolved.networkProxy, + requestPrefixDomainBasis, ...(resolved.secretMaterial.networkProxy ? { proxySecret: resolved.secretMaterial.networkProxy.secret } : {}), @@ -888,12 +922,19 @@ export async function resolveExecutionTarget( apiKey: resolved.secretMaterial.connection?.secret ?? '', requestHeaders, networkProxy: resolved.networkProxy, + requestPrefixDomainBasis, ...(resolved.secretMaterial.networkProxy ? { proxySecret: resolved.secretMaterial.networkProxy.secret } : {}), }; } +function credentialVersionBasis( + material: { readonly credentialId: string; readonly revision: number } | undefined, +): { readonly credentialId: string; readonly revision: number } | null { + return material ? { credentialId: material.credentialId, revision: material.revision } : null; +} + export function readDuringBackendCreation( read: () => Promise, abortSignal?: AbortSignal, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 454aa2338a..f61d97b8c1 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -36,7 +36,7 @@ import { } from '@maka/runtime/openai-codex-history-compactor'; import { buildPricingLookup, recordToolInvocation } from '@maka/runtime/telemetry'; import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; -import { createProviderRequestCaptureRecorder } from '@maka/runtime/provider-request-telemetry'; +import { resolveModelRuntime } from '@maka/runtime/model-runtime'; import { createProxiedFetchTransport, type ProxiedFetchProxy, @@ -155,6 +155,20 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom target.model, input.context.header.thinkingLevel, ); + const effectiveModelRuntime = resolveModelRuntime(target.connection, target.model); + const requestPrefixDomain = stableHash({ + schemaVersion: 1, + connectionId: target.requestPrefixDomainBasis.connectionId, + providerType: target.connection.providerType, + modelId: target.model, + endpoint: effectiveModelRuntime.baseUrl, + wire: effectiveModelRuntime.wire, + requestBodyOverlayRevision: target.requestPrefixDomainBasis.requestBodyOverlayRevision, + networkProxy: target.requestPrefixDomainBasis.networkProxy, + connectionCredential: target.requestPrefixDomainBasis.connectionCredential, + requestHeadersCredential: target.requestPrefixDomainBasis.requestHeadersCredential, + networkProxyCredential: target.requestPrefixDomainBasis.networkProxyCredential, + }); const contextWindow = resolveSelectedModelContextWindow(target.connection, target.model); let modelComposition: HostRunComposer; try { @@ -268,32 +282,22 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom throw new Error('Canonical model-call accounting authority is unavailable'); } }; - let artifactDrainRequested = false; - const providerRequestCapture = input.context.recordProviderRequestCapture - ? createProviderRequestCaptureRecorder({ - persistArtifact: async (capture) => { - try { - const artifact = await persistProviderRequestCaptureArtifact(input.artifacts, { - sessionId: input.context.sessionId, - turnId: capture.turnId, - captureId: capture.captureId, - step: capture.step, - serializedRequest: capture.serializedRequest, - now: Date.now(), - }); - return { artifactId: artifact.id }; - } catch (error) { - if (!artifactDrainRequested) { - artifactDrainRequested = true; - input.requestDrain(); - } - throw error; - } - }, - recordLedger: input.context.recordProviderRequestCapture, - }) - : undefined; - const recordProviderRequestAttempt = input.context.recordProviderRequestAttempt ?? (() => {}); + const persistPreparedRequestArtifact = async (capture: { + turnId: string; + captureId: string; + step: number; + serializedRequest: string; + }): Promise<{ artifactId: string }> => { + const artifact = await persistProviderRequestCaptureArtifact(input.artifacts, { + sessionId: input.context.sessionId, + turnId: capture.turnId, + captureId: capture.captureId, + step: capture.step, + serializedRequest: capture.serializedRequest, + now: Date.now(), + }); + return { artifactId: artifact.id }; + }; const resolveRunPrompt = async (context: { readonly turnId: string; readonly emitSkillCatalogTrace?: (message: string, data?: Record) => void; @@ -437,19 +441,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom }, lookupPricing: pricing, recordModelCallAttempt, + requestPrefixDomain, assertModelCallAccountingReady, + persistPreparedRequestArtifact, recordToolInvocation: (event) => recordToolInvocation({ repo: telemetry }, event), ...(input.runtimeCommitSink ? { runtimeCommitSink: input.runtimeCommitSink } : {}), - ...(providerRequestCapture - ? { - recordProviderRequestCapture: providerRequestCapture, - ...(input.context.recordProviderRequestAttempt - ? { - recordProviderRequestAttempt, - } - : {}), - } - : {}), newId: randomUUID, now: Date.now, }, diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts index 759fa3ee2c..b022cc3a1d 100644 --- a/packages/runtime-host/src/server/root-admission-owner.ts +++ b/packages/runtime-host/src/server/root-admission-owner.ts @@ -60,6 +60,13 @@ export class RootAdmissionOwner { } } + /** Releases process-local state after the durable Session is discarded. */ + retireSession(sessionId: string): void { + this.#admissionsBySession.delete(sessionId); + this.#tips.delete(sessionId); + this.#poisonedSessions.delete(sessionId); + } + async recoverSession(sessionId: string): Promise { if (this.#admissionsBySession.has(sessionId)) { throw new Error(`Root Turn recovery chain was already installed for Session ${sessionId}`); diff --git a/packages/runtime-host/src/server/runtime-policy-proxy.ts b/packages/runtime-host/src/server/runtime-policy-proxy.ts index 20b0b8195c..00c288b8ed 100644 --- a/packages/runtime-host/src/server/runtime-policy-proxy.ts +++ b/packages/runtime-host/src/server/runtime-policy-proxy.ts @@ -20,20 +20,59 @@ import type { RuntimePolicy } from '@maka/core/runtime-policy'; import type { ProxiedFetchProxy } from '@maka/runtime/network/scoped-fetch-transport'; +export type RuntimePolicyProxyDomainBasis = + | { readonly kind: 'direct' } + | { + readonly kind: 'proxy'; + readonly type: RuntimePolicy['networkProxy']['protocol']; + readonly host: string; + readonly port: number; + readonly authentication: + | { readonly kind: 'none' } + | { readonly kind: 'credentials'; readonly username: string }; + readonly bypassList: readonly string[]; + }; + +/** The secret-free transport semantics shared by dispatch and diagnostics. */ +export function runtimePolicyProxyDomainBasis( + proxy: RuntimePolicy['networkProxy'], +): RuntimePolicyProxyDomainBasis { + if (!proxy.enabled) return { kind: 'direct' }; + return { + kind: 'proxy', + type: proxy.protocol, + host: proxy.host.trim().toLowerCase(), + port: proxy.port, + authentication: proxy.authEnabled + ? { kind: 'credentials', username: proxy.username } + : { kind: 'none' }, + bypassList: [ + ...new Set( + [...proxy.bypassList, ...proxy.autoBypassDomains] + .map((pattern) => pattern.trim().toLowerCase()) + .filter((pattern) => pattern.length > 0), + ), + ].sort(), + }; +} + export function toRuntimePolicyProxy( proxy: RuntimePolicy['networkProxy'], password: string | undefined, ): ProxiedFetchProxy | null { - if (!proxy.enabled) return null; - if (proxy.authEnabled && password === undefined) { + const basis = runtimePolicyProxyDomainBasis(proxy); + if (basis.kind === 'direct') return null; + if (basis.authentication.kind === 'credentials' && password === undefined) { throw new Error('Network proxy execution admission omitted its credential'); } return { enabled: true, - type: proxy.protocol, - host: proxy.host, - port: proxy.port, - ...(proxy.authEnabled ? { username: proxy.username, password } : {}), - bypassList: [...new Set([...proxy.bypassList, ...proxy.autoBypassDomains])], + type: basis.type, + host: basis.host, + port: basis.port, + ...(basis.authentication.kind === 'credentials' + ? { username: basis.authentication.username, password } + : {}), + bypassList: [...basis.bypassList], }; } diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 328e280676..3e935b8c44 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -19,6 +19,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { isDeepResearchSession } from '@maka/core/deep-research'; +import { messageContentDigest, type MessageContent } from '@maka/core/events'; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; @@ -37,6 +38,8 @@ import { collectConversationCopyLinkedChildReferences, createConversationCopySlice, prepareConversationRuntimeLedgerCopy, + rewriteConversationCopyMessageContent, + type ConversationCopyArtifactReferenceMap, type ConversationRuntimeLedgerCopyPlan, } from '@maka/runtime/conversation-copy'; import { isArchivedToolResultPlaceholder } from '@maka/runtime/context-budget'; @@ -49,6 +52,7 @@ import { authenticateExecutionStoresWriter, isSessionNotFoundError, type ExecutionStoresWriter, + type RootTurnAdmission, } from '@maka/storage/execution-stores'; import { authenticateInteractiveTaskLedgerWriter, @@ -68,6 +72,7 @@ import type { import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; import { projectSessionCatalogRecord } from './session-catalog-coordinator.js'; import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; +import type { RootAdmissionOwner } from './root-admission-owner.js'; import { agentGraphRevisionAdmissionSessionIds, prepareAgentGraphRevisionReferences, @@ -102,6 +107,7 @@ export interface HostSessionRevisionCoordinatorOptions { readonly manager: SessionManager; readonly admission: SessionAdmissionGate; readonly continuity: SessionContinuityCoordinator; + readonly rootAdmissions: RootAdmissionOwner; readonly graph: Pick< import('@maka/runtime/stream-graph-coordinator').AgentGraphCoordinator, 'readGraphState' | 'readSessionState' @@ -532,6 +538,13 @@ export class HostSessionRevisionCoordinator { runtimeEventStore: this.#stores.runtimeEventStore, newId: randomUUID, }); + await this.#copyRootTurnAdmissions({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + copyTurnIds, + runIdMap: runtimeCopy.runIdMap, + references, + }); const copiedMessages = runtimeCopy.copiedMessages; await this.#taskLedger.copyConversationTaskLedger({ sourceSessionId: input.sourceSessionId, @@ -560,11 +573,13 @@ export class HostSessionRevisionCoordinator { copiedMessages.some((message) => message.type === 'user'), }); await this.options.continuity.refreshCanonical(input.targetSessionId, lease); + const session = projectSessionCatalogRecord( + await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), + ); + await this.options.rootAdmissions.recoverSession(input.targetSessionId); return copySuccess({ kind: 'committed', - session: projectSessionCatalogRecord( - await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), - ), + session, }); } catch (error) { console.error( @@ -579,6 +594,59 @@ export class HostSessionRevisionCoordinator { } } + async #copyRootTurnAdmissions(input: { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly copyTurnIds: readonly string[]; + readonly runIdMap: readonly { + readonly sourceRunId: string; + readonly targetRunId: string; + }[]; + readonly references: ConversationCopyArtifactReferenceMap; + }): Promise { + const retainedTurns = new Set(input.copyTurnIds); + const runIds = new Map( + input.runIdMap.map(({ sourceRunId, targetRunId }) => [sourceRunId, targetRunId]), + ); + const sourceAdmissions = await this.#stores.agentRunStore.listRootTurnAdmissionsForRecovery( + input.sourceSessionId, + ); + for (const source of sourceAdmissions) { + if (!retainedTurns.has(source.turnId)) continue; + const targetRunId = runIds.get(source.runId); + if (!targetRunId) { + throw new Error(`Conversation copy is missing root AgentRun ${source.runId}`); + } + const normalizedInput = rewriteOptionalRootMessageContent( + source.normalizedInput, + input.references, + ); + const result = await this.#stores.agentRunStore.importConversationCopyRootTurn({ + sessionId: input.targetSessionId, + turnId: source.turnId, + proposedRunId: targetRunId, + proposedUserMessageId: source.userMessageId, + execution: rewriteRootExecution(source, normalizedInput, input.targetSessionId, runIds), + previousRootTurnId: + source.previousRootTurnId && retainedTurns.has(source.previousRootTurnId) + ? source.previousRootTurnId + : null, + normalizedInput, + ...(source.turnOrchestration ? { turnOrchestration: source.turnOrchestration } : {}), + ...(source.skillInvocation ? { skillInvocation: source.skillInvocation } : {}), + ...(source.authorization ? { authorization: source.authorization } : {}), + sourceMessages: source.sourceMessages.map((message) => ({ + ...message, + content: rewriteConversationCopyMessageContent(message.content, input.references), + })), + admittedAt: source.admittedAt, + }); + if (result.kind !== 'admitted') { + throw new Error(`Conversation copy root Turn ${source.turnId} was not admitted`); + } + } + } + async #readArchivedToolResults( sourceSessionId: string, sourceEvents: readonly RuntimeEvent[], @@ -812,15 +880,14 @@ export class HostSessionRevisionCoordinator { header.id, copy.requestFingerprint, ); + this.options.rootAdmissions.retireSession(header.id); } async #hasAdmittedRevisionTurn(sessionId: string): Promise { - if ( - (await this.#stores.agentRunStore.listRootTurnAdmissionsForRecovery(sessionId)).length > 0 - ) { - return true; - } - const messages = await this.#stores.sessionStore.readMessagesForRecovery(sessionId); + const [admissions, messages] = await Promise.all([ + this.#stores.agentRunStore.listRootTurnAdmissionsForRecovery(sessionId), + this.#stores.sessionStore.readMessagesForRecovery(sessionId), + ]); let boundary = -1; for (let index = 0; index < messages.length; index += 1) { const message = messages[index]!; @@ -832,7 +899,18 @@ export class HostSessionRevisionCoordinator { boundary = index; } } - return boundary >= 0 && messages.slice(boundary + 1).some((message) => message.type === 'user'); + if (boundary < 0) return admissions.length > 0; + const copiedTurnIds = new Set( + messages + .slice(0, boundary) + .flatMap((message) => + 'turnId' in message && typeof message.turnId === 'string' ? [message.turnId] : [], + ), + ); + return ( + admissions.some((admission) => !copiedTurnIds.has(admission.turnId)) || + messages.slice(boundary + 1).some((message) => message.type === 'user') + ); } async #hasCommittedConversationCopyDependent(sessionId: string): Promise { @@ -895,6 +973,62 @@ function conversationCopyStartNote( }; } +function rewriteOptionalRootMessageContent( + content: MessageContent | null, + references: ConversationCopyArtifactReferenceMap, +): MessageContent | null { + return content === null ? null : rewriteConversationCopyMessageContent(content, references); +} + +function rewriteRootExecution( + source: RootTurnAdmission, + normalizedInput: MessageContent | null, + targetSessionId: string, + runIds: ReadonlyMap, +): RootTurnAdmission['execution'] { + const execution = source.execution; + switch (execution.kind) { + case 'external_message': + return execution.inputDigest && normalizedInput + ? { ...execution, inputDigest: messageContentDigest(normalizedInput) } + : execution; + case 'workhub_coordination': + if (!normalizedInput) throw new Error('Copied WorkHub root has no normalized input'); + return { ...execution, inputDigest: messageContentDigest(normalizedInput) }; + case 'linked_child_resume': + case 'linked_child_provider_retry': + return { + ...execution, + sourceRunId: requiredCopiedRootRunId(execution.sourceRunId, runIds), + }; + case 'claimed_agent_graph_intent': + return { + ...execution, + claim: { + ...execution.claim, + targetSessionId, + targetRunId: requiredCopiedRootRunId(execution.claim.targetRunId, runIds), + }, + }; + case 'safe_boundary_continuation': + throw new Error('Conversation copy cannot import safe-boundary root lineage'); + case 'regenerate': + case 'context_compact': + case 'scheduled_task': + case 'legacy_automation': + case 'goal': + case 'agent_graph_supervisor_wake': + case 'linked_child_initial': + return execution; + } +} + +function requiredCopiedRootRunId(sourceRunId: string, runIds: ReadonlyMap): string { + const targetRunId = runIds.get(sourceRunId); + if (!targetRunId) throw new Error(`Conversation copy is missing root AgentRun ${sourceRunId}`); + return targetRunId; +} + function conversationCopySemanticKind( kind: ConversationCopyKind, input: SessionConversationCopyInput, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 8ac41d9d43..10c0f7bfb7 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -58,11 +58,7 @@ import { import type { DurableSessionEventSink, MakaTool, ToolRuntime } from '../tool-runtime.js'; import { TOOL_SEARCH_NAME } from '../tool-availability.js'; import { buildNativeWebSearchTool } from '../native-web-search-tool.js'; -import { - canonicalizeToolSet, - computeRequestShapeDiagnostic, - findFirstChangedCacheableSegment, -} from '../request-shape.js'; +import { canonicalizeToolSet } from '../request-shape.js'; import { ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, ARCHIVED_TOOL_RESULT_REWRITE_VERSION, @@ -83,10 +79,7 @@ import { } from '../sandbox-boundary-declaration.js'; import { FilesystemWorkerClientError } from '../filesystem-worker/client.js'; import { RunTrace } from '../run-trace.js'; -import type { - ProviderRequestAttemptRecord, - ProviderRequestCaptureRecord, -} from '../provider-request-telemetry.js'; +import type { PreparedRequestArtifactInput } from '../provider-request-telemetry.js'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; @@ -5415,14 +5408,7 @@ describe('AiSdkBackend model history', () => { type FingerprintCase = { name: string; expectedCalls: number; - prepare?: (input: AiSdkBackendInput, backend: AiSdkBackend) => void; - change?: (input: AiSdkBackendInput, backend: AiSdkBackend) => void; - }; - const setRequestShapeHash = (backend: AiSdkBackend, requestShapeHash: string): void => { - const internals = backend as unknown as { - priorRequestShape: { requestShapeHash: string } | undefined; - }; - internals.priorRequestShape = { requestShapeHash }; + change?: (input: AiSdkBackendInput) => void; }; const cases = [ { @@ -5454,10 +5440,11 @@ describe('AiSdkBackend model history', () => { }, }, { - name: 'request shape change retries', + name: 'compaction route change retries', expectedCalls: 2, - prepare: (_input, backend) => setRequestShapeHash(backend, 'request-shape-before'), - change: (_input, backend) => setRequestShapeHash(backend, 'request-shape-after'), + change: (input) => { + input.historyCompactRoute = 'text_summary'; + }, }, ] satisfies readonly FingerprintCase[]; @@ -5507,14 +5494,12 @@ describe('AiSdkBackend model history', () => { text: 'recent', }), ]; - fingerprintCase.prepare?.(backendInput, backend); - const first = await backend.compactHistory({ turnId: 'turn-config-compact-1', runId: 'run-1', runtimeContext: history, }); - fingerprintCase.change?.(backendInput, backend); + fingerprintCase.change?.(backendInput); const repeated = await backend.compactHistory({ turnId: 'turn-config-compact-2', runId: 'run-2', @@ -8152,7 +8137,7 @@ describe('AiSdkBackend usage telemetry', () => { }, } as never); - await drain(backend.send({ turnId: 'turn-1', text: 'hi', context: [] })); + await drain(backend.send({ turnId: 'turn-1', runId: 'run-1', text: 'hi', context: [] })); assert.equal(usageCheckpoints.length, 1); assert.equal(usageCheckpoints[0]?.costUsd, undefined); @@ -8593,6 +8578,7 @@ describe('AiSdkBackend usage telemetry', () => { prefixChangeReason?: string; requestShapeHash?: string; requestShapeChangeReason?: string; + promptSegments?: unknown[]; } | undefined; const usageEvent = events.find((event) => event.type === 'token_usage') as @@ -8614,13 +8600,7 @@ describe('AiSdkBackend usage telemetry', () => { assert.equal(usageMessage?.reasoning, 2); assert.equal(usageMessage?.total, 17); assert.equal(usageMessage?.rawFinishReason, 'stop'); - assert.equal(usageMessage?.systemPromptHash, usageEvent?.systemPromptHash); - assert.ok(usageMessage?.systemPromptHash); assert.equal(usageMessage?.costUsd, expectedCostUsd); - assert.equal(usageMessage?.prefixChangeReason, 'first_turn'); - assert.equal(usageMessage?.requestShapeChangeReason, 'first_turn'); - assert.ok(usageMessage?.prefixHash); - assert.ok(usageMessage?.requestShapeHash); assert.equal(usageEvent?.input, 10); assert.equal(usageEvent?.output, 7); assert.equal(usageEvent?.cacheHitInput, 3); @@ -8632,156 +8612,24 @@ describe('AiSdkBackend usage telemetry', () => { assert.equal(usageEvent?.reasoning, 2); assert.equal(usageEvent?.total, 17); assert.equal(usageEvent?.rawFinishReason, 'stop'); - assert.equal(usageEvent?.systemPromptHash, usageMessage?.systemPromptHash); assert.equal(usageEvent?.costUsd, expectedCostUsd); - assert.equal(usageEvent?.prefixChangeReason, 'first_turn'); - assert.equal(usageEvent?.requestShapeChangeReason, 'first_turn'); - assert.ok(usageEvent?.prefixHash); - assert.ok(usageEvent?.requestShapeHash); - assert.equal(startTrace?.data?.prefixChangeReason, 'first_turn'); - assert.equal(startTrace?.data?.requestShapeChangeReason, 'first_turn'); - assert.ok(startTrace?.data?.prefixHash); - assert.ok(startTrace?.data?.requestShapeHash); - assert.equal(startTrace?.data?.systemPromptHash, usageMessage?.systemPromptHash); + for (const currentWriter of [ + usageMessage as Record, + usageEvent as unknown as Record, + startTrace?.data, + ]) { + assert.equal(currentWriter?.systemPromptHash, undefined); + assert.equal(currentWriter?.prefixHash, undefined); + assert.equal(currentWriter?.prefixChangeReason, undefined); + assert.equal(currentWriter?.requestShapeHash, undefined); + assert.equal(currentWriter?.requestShapeChangeReason, undefined); + assert.equal(currentWriter?.promptSegments, undefined); + } assert.equal(pricingLookupCalls, 1); }); }); -describe('AiSdkBackend request-shape diagnostics', () => { - test('classifies targeted request-shape changes', () => { - const tools = canonicalizeToolSet( - [testTool('Read', z.object({ path: z.string() }))], - testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })), - ); - const baseInput = { - connection: connection(), - modelId: 'mock-model-id', - systemPrompt: 'durable system', - providerOptions: { temperature: 0 }, - providerTools: tools.providerTools, - activeTools: tools.activeTools, - priorMessages: [{ role: 'user' as const, content: 'hello' }], - }; - const base = computeRequestShapeDiagnostic(baseInput, undefined); - - assert.equal( - computeRequestShapeDiagnostic( - { - ...baseInput, - systemPrompt: 'changed system', - }, - base, - ).prefixChangeReason, - 'system_prompt_changed', - ); - assert.equal( - computeRequestShapeDiagnostic( - { - ...baseInput, - providerTools: canonicalizeToolSet( - [testTool('Read', z.object({ path: z.string(), offset: z.number().optional() }))], - testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })), - ).providerTools, - }, - base, - ).prefixChangeReason, - 'tool_schema_changed', - ); - assert.equal( - computeRequestShapeDiagnostic( - { - ...baseInput, - providerOptions: { temperature: 1 }, - }, - base, - ).prefixChangeReason, - 'provider_options_changed', - ); - assert.equal( - computeRequestShapeDiagnostic( - { - ...baseInput, - modelId: 'other-model', - }, - base, - ).prefixChangeReason, - 'model_or_provider_changed', - ); - const historyChanged = computeRequestShapeDiagnostic( - { - ...baseInput, - priorMessages: [{ role: 'assistant' as const, content: 'hello' }], - }, - base, - ); - assert.equal(historyChanged.prefixChangeReason, 'stable'); - assert.equal(historyChanged.prefixHash, base.prefixHash); - assert.equal(historyChanged.requestShapeChangeReason, 'history_projection_changed'); - assert.notEqual(historyChanged.requestShapeHash, base.requestShapeHash); - }); - - test('tool-result output hydration changes request shape without changing durable prefix', () => { - const tools = canonicalizeToolSet( - [testTool('Read', z.object({ path: z.string() }))], - testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })), - ); - const toolCallMessage: ModelMessage = { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'tool-1', - toolName: 'Read', - input: { path: 'archive.txt' }, - }, - ], - }; - const placeholderToolResult: ModelMessage = { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'tool-1', - toolName: 'Read', - output: { type: 'text', value: '[archived placeholder]' }, - }, - ], - }; - const hydratedToolResult: ModelMessage = { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'tool-1', - toolName: 'Read', - output: { type: 'text', value: 'hydrated archive payload '.repeat(20) }, - }, - ], - }; - const baseInput = { - connection: connection(), - modelId: 'mock-model-id', - systemPrompt: 'durable system', - providerOptions: { temperature: 0 }, - providerTools: tools.providerTools, - activeTools: tools.activeTools, - priorMessages: [toolCallMessage, placeholderToolResult], - }; - const placeholder = computeRequestShapeDiagnostic(baseInput, undefined); - const hydrated = computeRequestShapeDiagnostic( - { - ...baseInput, - priorMessages: [toolCallMessage, hydratedToolResult], - }, - placeholder, - ); - - assert.equal(hydrated.prefixChangeReason, 'stable'); - assert.equal(hydrated.prefixHash, placeholder.prefixHash); - assert.equal(hydrated.requestShapeChangeReason, 'history_projection_changed'); - assert.notEqual(hydrated.requestShapeHash, placeholder.requestShapeHash); - }); - +describe('AiSdkBackend tool availability diagnostics', () => { test('tool canonicalization is independent of registration order and places invalid last', () => { const invalid = testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })); const first = canonicalizeToolSet( @@ -8808,87 +8656,6 @@ describe('AiSdkBackend request-shape diagnostics', () => { second.providerTools.map((tool) => tool.name), ['Read', 'Write', INVALID_TOOL_NAME], ); - assert.equal( - computeRequestShapeDiagnostic( - { - connection: connection(), - modelId: 'mock-model-id', - providerTools: first.providerTools, - activeTools: first.activeTools, - priorMessages: [], - }, - undefined, - ).componentHashes.toolSchemaHash, - computeRequestShapeDiagnostic( - { - connection: connection(), - modelId: 'mock-model-id', - providerTools: second.providerTools, - activeTools: second.activeTools, - priorMessages: [], - }, - undefined, - ).componentHashes.toolSchemaHash, - ); - }); - - test('classifies strict enabled-group expansion as tool_source_enabled', () => { - const invalid = testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })); - const initialTools = canonicalizeToolSet( - [ - testTool('Read', z.object({ path: z.string() })), - testTool(TOOL_SEARCH_NAME, z.object({ query: z.string() })), - ], - invalid, - ); - const expandedTools = canonicalizeToolSet( - [ - testTool('Read', z.object({ path: z.string() })), - testTool('WebFetch', z.object({ url: z.string() })), - testTool(TOOL_SEARCH_NAME, z.object({ query: z.string() })), - ], - invalid, - ); - const groupCatalog = { web: ['WebFetch'] }; - const first = computeRequestShapeDiagnostic( - { - connection: connection(), - modelId: 'mock-model-id', - providerTools: initialTools.providerTools, - activeTools: initialTools.activeTools, - priorMessages: [], - toolAvailability: { - mode: 'search', - enabledSourceIds: [], - availableSourceIds: ['web'], - connectorToolName: TOOL_SEARCH_NAME, - visibleToolNamesBySource: groupCatalog, - }, - }, - undefined, - ); - const second = computeRequestShapeDiagnostic( - { - connection: connection(), - modelId: 'mock-model-id', - providerTools: expandedTools.providerTools, - activeTools: expandedTools.activeTools, - priorMessages: [], - toolAvailability: { - mode: 'search', - enabledSourceIds: ['web'], - availableSourceIds: [], - connectorToolName: TOOL_SEARCH_NAME, - visibleToolNamesBySource: groupCatalog, - }, - }, - first, - ); - - assert.equal(second.prefixChangeReason, 'tool_schema_changed'); - assert.equal(second.requestShapeChangeReason, 'tool_schema_changed'); - assert.equal(second.toolSchemaChangeReason, 'tool_source_enabled'); - assert.notEqual(second.prefixHash, first.prefixHash); }); test('backend full mode keeps the complete tool surface and omits the connector', async () => { @@ -8917,14 +8684,11 @@ describe('AiSdkBackend request-shape diagnostics', () => { assert.deepEqual(modelToolNames(model), sortedModelToolNames(['Read', 'WebFetch'])); assert.equal(modelToolNames(model).includes(TOOL_SEARCH_NAME), false); - // toolCount tracks the model-visible (active) tools — the two real tools. - // The invalid fallback lives in providerTools but is never advertised, so - // it is not counted (toolCount is the wire-visible subset). const usageEvent = events.find( (event): event is Extract => event.type === 'token_usage', ); - assert.equal(toolSchemaPromptSegment(usageEvent)?.toolCount, 2); + assert.equal(usageEvent?.promptSegments, undefined); }); test('preserves the tool-call provider prefix across user turns', async () => { @@ -9061,7 +8825,7 @@ describe('AiSdkBackend context budget and prompt attribution', () => { ); }); - test('usage events include prompt segments and context budget diagnostics', async () => { + test('usage events keep context budget diagnostics without live prompt estimates', async () => { const model = completionModel(); const events: SessionEvent[] = []; const backend = createTestAiSdkBackend({ @@ -9136,30 +8900,15 @@ describe('AiSdkBackend context budget and prompt attribution', () => { assert.ok(usage); assert.equal(usage.contextBudget?.policyName, 'test-budget'); assert.equal(usage.contextBudget?.droppedTurns, 0); - assert.equal( - usage.promptSegments?.some((segment) => segment.kind === 'prior_history'), - true, - ); - assert.equal( - usage.promptSegments?.some((segment) => segment.kind === 'tool_schema'), - true, - ); - assert.equal( - usage.promptSegments?.some((segment) => segment.kind === 'current_user'), - true, - ); - assert.equal( - usage.promptSegments?.some((segment) => segment.kind === 'turn_tail'), - false, - ); + assert.equal(usage.promptSegments, undefined); }); }); describe('AiSdkBackend RunTrace', () => { for (const protocol of ['openai-compatible', 'anthropic-compatible'] as const) { test(`records ${protocol} multi-step requests and reconciles complete attempt usage`, async () => { - const captures: ProviderRequestCaptureRecord[] = []; - const attempts: ProviderRequestAttemptRecord[] = []; + const captures: PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; const durable = durableTurnHarness('turn-1', 'hi'); let calls = 0; const usageFor = (step: number) => { @@ -9258,32 +9007,31 @@ describe('AiSdkBackend RunTrace', () => { loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async (capture) => { + persistPreparedRequestArtifact: async (capture) => { captures.push(capture); return { artifactId: `artifact-${captures.length}` }; }, - recordProviderRequestAttempt: (attempt) => { + recordModelCallAttempt: ({ attempt }) => { attempts.push(attempt); }, }); - const events = await drainDurably(backend.send(durable.input()), durable); + const events = await drainDurably(backend.send(durable.input({ runId: 'run-1' })), durable); assert.equal(captures.length, 2); assert.deepEqual( attempts.map(({ step, attempt, status }) => ({ step, attempt, status })), [ - { step: 0, attempt: 1, status: 'completed' }, - { step: 1, attempt: 1, status: 'completed' }, + { step: 0, attempt: 0, status: 'completed' }, + { step: 1, attempt: 0, status: 'completed' }, ], ); - assert.equal(findFirstChangedCacheableSegment(captures[1]!, captures[0]!)?.kind, 'message'); const aggregate = events.find( (event): event is Extract => event.type === 'token_usage', ); assert.ok(aggregate); - const sum = (field: keyof ProviderRequestAttemptRecord) => + const sum = (field: keyof ModelCallAttempt) => attempts.reduce( (total, attempt) => total + ((attempt[field] as number | undefined) ?? 0), 0, @@ -9296,12 +9044,16 @@ describe('AiSdkBackend RunTrace', () => { }); } - test('captures the prepared request before the provider call and records its physical attempt', async () => { - const captures: ProviderRequestCaptureRecord[] = []; - const attempts: ProviderRequestAttemptRecord[] = []; + test('observes the prepared request at dispatch and records its canonical attempt', async () => { + const captures: PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; const model = new MockLanguageModelV4({ doStream: async () => { - assert.equal(captures.length, 1, 'capture must be durable before provider dispatch'); + assert.equal( + captures.length, + 1, + 'artifact persistence must start before provider dispatch', + ); return { stream: simulateReadableStream({ chunks: [ @@ -9340,28 +9092,32 @@ describe('AiSdkBackend RunTrace', () => { tools: [], newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async (capture) => { + persistPreparedRequestArtifact: async (capture) => { captures.push(capture); return { artifactId: `artifact-${captures.length}` }; }, - recordProviderRequestAttempt: async (attempt) => { + recordModelCallAttempt: async ({ attempt }) => { attempts.push(attempt); }, }); const events: SessionEvent[] = []; - for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + for await (const event of backend.send({ + turnId: 'turn-1', + runId: 'run-1', + text: 'hi', + context: [], + })) { events.push(event); } assert.equal(captures.length, 1); assert.equal(attempts.length, 1); assert.equal(attempts[0]?.step, 0); - assert.equal(attempts[0]?.attempt, 1); + assert.equal(attempts[0]?.attempt, 0); assert.equal(attempts[0]?.status, 'completed'); assert.equal(attempts[0]?.contextWindow, 200_000); - assert.equal(attempts[0]?.captureId, captures[0]?.captureId); - assert.equal(attempts[0]?.cacheMissInputSource, 'derived'); + assert.equal(attempts[0]?.cacheMissInputTokens, 4); assert.equal( events.find((event) => event.type === 'token_usage')?.providerRequestTraceId, captures[0]?.traceId, @@ -9470,7 +9226,7 @@ describe('AiSdkBackend RunTrace', () => { assert.equal(stored?.type === 'token_usage' && 'contextRemaining' in stored, false); }); - test('does not call the provider when prepared-request persistence fails', async () => { + test('continues the canonical call when private request persistence fails', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -9483,10 +9239,9 @@ describe('AiSdkBackend RunTrace', () => { tools: [], newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async () => { + persistPreparedRequestArtifact: async () => { throw new Error('capture unavailable'); }, - recordProviderRequestAttempt: () => {}, }); const events: SessionEvent[] = []; @@ -9494,14 +9249,14 @@ describe('AiSdkBackend RunTrace', () => { events.push(event); } - assert.equal(model.doStreamCalls.length, 0); + assert.equal(model.doStreamCalls.length, 1); assert.equal(events.at(-1)?.type, 'complete'); - assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); }); test('disables hidden AI SDK retries and traces the one explicit Runtime retry', async () => { - const captures: ProviderRequestCaptureRecord[] = []; - const attempts: ProviderRequestAttemptRecord[] = []; + const captures: PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; let calls = 0; const model = new MockLanguageModelV4({ doStream: async () => { @@ -9545,25 +9300,25 @@ describe('AiSdkBackend RunTrace', () => { tools: [], newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async (capture) => { + persistPreparedRequestArtifact: async (capture) => { captures.push(capture); return { artifactId: 'artifact-1' }; }, - recordProviderRequestAttempt: (attempt) => { + recordModelCallAttempt: ({ attempt }) => { attempts.push(attempt); }, providerRetrySleep: async () => {}, }); - await drain(backend.send({ turnId: 'turn-1', text: 'hi', context: [] })); + await drain(backend.send({ turnId: 'turn-1', runId: 'run-1', text: 'hi', context: [] })); assert.equal(calls, 2); assert.equal(captures.length, 1); assert.deepEqual( attempts.map(({ attempt, status }) => ({ attempt, status })), [ - { attempt: 1, status: 'failed' }, - { attempt: 2, status: 'completed' }, + { attempt: 0, status: 'failed' }, + { attempt: 1, status: 'completed' }, ], ); }); @@ -15689,12 +15444,6 @@ function sortedModelToolNames(toolNames: readonly string[]): string[] { }); } -function toolSchemaPromptSegment( - carrier: { promptSegments?: readonly { kind: string; toolCount?: number }[] } | undefined, -): { toolCount?: number } | undefined { - return carrier?.promptSegments?.find((segment) => segment.kind === 'tool_schema'); -} - function sha256(text: string): string { return createHash('sha256').update(text).digest('hex'); } diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index cbac5ac106..3a077e7006 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -27,6 +27,7 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { SessionEvent } from '@maka/core/events'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import { AiSdkBackend } from '../ai-sdk-backend.js'; import { @@ -35,10 +36,7 @@ import { type CuObservation, } from '../computer-use-tools.js'; import { buildProviderOptions, getAIModel } from '../model-factory.js'; -import type { - ProviderRequestAttemptRecord, - ProviderRequestCaptureRecord, -} from '../provider-request-telemetry.js'; +import type { PreparedRequestArtifactInput } from '../provider-request-telemetry.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; import { createDurableTurnHarness } from './durable-turn-harness.js'; import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; @@ -105,8 +103,8 @@ describe('Anthropic-compatible Computer Use product loops', () => { text: 'Set the fixture field to provider-loop.', }); const requestBodies: Array> = []; - const captures: ProviderRequestCaptureRecord[] = []; - const attempts: ProviderRequestAttemptRecord[] = []; + const captures: PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; const server = await startJsonServer(async (request, response) => { assert.equal(request.method, 'POST'); assert.equal(request.url, provider.expectedPath); @@ -163,16 +161,16 @@ describe('Anthropic-compatible Computer Use product loops', () => { loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async (capture) => { + persistPreparedRequestArtifact: async (capture) => { captures.push(capture); return { artifactId: `capture-artifact-${captures.length}` }; }, - recordProviderRequestAttempt: (attempt) => { + recordModelCallAttempt: ({ attempt }) => { attempts.push(attempt); }, }); - for await (const event of runtime.send(durable.sendInput())) { + for await (const event of runtime.send(durable.sendInput({ runId: 'run-1' }))) { durable.record(event); events.push(event); if (event.type === 'tool_result') { @@ -215,11 +213,8 @@ describe('Anthropic-compatible Computer Use product loops', () => { assert.equal(attempt.status, 'completed'); assert.equal(attempt.inputTokens, 15); assert.equal(attempt.cacheReadInputTokens, 4); - assert.equal(attempt.cacheReadInputSource, 'provider'); assert.equal(attempt.cacheWriteInputTokens, 1); - assert.equal(attempt.cacheWriteInputSource, 'provider'); assert.equal(attempt.cacheMissInputTokens, 10); - assert.equal(attempt.cacheMissInputSource, 'provider'); assert.equal(attempt.outputTokens, 5); } for (const body of requestBodies) { @@ -658,8 +653,8 @@ describe('OpenAI-compatible product loops', () => { text: 'Set the fixture field to provider-loop.', }); const requestBodies: Array> = []; - const captures: ProviderRequestCaptureRecord[] = []; - const attempts: ProviderRequestAttemptRecord[] = []; + const captures: PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; const server = await startJsonServer(async (request, response) => { assert.equal(request.method, 'POST'); assert.equal(request.url, '/coding/v1/chat/completions'); @@ -708,16 +703,16 @@ describe('OpenAI-compatible product loops', () => { loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, newId: idGenerator(), now: monotonicClock(), - recordProviderRequestCapture: async (capture) => { + persistPreparedRequestArtifact: async (capture) => { captures.push(capture); return { artifactId: `capture-artifact-${captures.length}` }; }, - recordProviderRequestAttempt: (attempt) => { + recordModelCallAttempt: ({ attempt }) => { attempts.push(attempt); }, }); - for await (const event of runtime.send(durable.sendInput())) { + for await (const event of runtime.send(durable.sendInput({ runId: 'run-1' }))) { durable.record(event); events.push(event); } diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index d5e84bcacc..767c318022 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -54,12 +54,55 @@ test('serves the sealed snapshot without reading a single run', async () => { assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; assert.deepEqual(diagnostics.composition?.tools, [{ name: 'Bash', bytes: 800 }]); + assert.deepEqual(diagnostics.requestPrefix, { + status: 'unavailable', + previousSegmentCount: 0, + preservedSegmentCount: 0, + }); assert.equal(scanned, 0, 'a sealed snapshot is one projection read'); } finally { await rm(root, { recursive: true, force: true }); } }); +test('does not trust a pre-observation projection over its canonical attempt', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); + try { + const writer = createSqliteAgentRunStore(root); + await writer.createRun(runHeader('run-1', 1)); + const oldProjection = latestContext('attempt-1', 10); + oldProjection.snapshot.schemaVersion = 1; + oldProjection.snapshot.composition = { + segments: [{ kind: 'messages', bytes: 999 }], + tools: [], + }; + await writer.appendEvent( + 'session-1', + 'run-1', + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200), + { durable: true, latestContext: oldProjection }, + ); + + const reader = createSqliteAgentRunStore(root); + const warm = await readLatestContextDiagnostics(reader, 'session-1'); + 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.composition, undefined); + assert.equal(cold.composition, undefined); + } 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 { @@ -133,9 +176,10 @@ test("a subagent's run never becomes the session's context", async () => { } }); -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. +test('rebuilds a canonical observation, then repairs it so the next read scans nothing', async () => { + // The event is durable but predates a sealed projection. The first read + // rebuilds from its canonical observation; proving that happens once needs + // two reads. const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); @@ -143,7 +187,19 @@ test('rebuilds a legacy ledger, then repairs it so the next read scans nothing', await writer.appendEvent( 'session-1', 'run-1', - meteringEvent('run-1', 'attempt-1', 20, 'model-new', 40, 200), + meteringEvent('run-1', 'attempt-1', 20, 'model-new', 40, 200, { + requestObservation: requestObservation([ + { + kind: 'tool_schema', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'a'.repeat(64)}`, + bytes: 800, + label: 'Bash', + }, + ]), + }), ); await writer.appendEvent( 'session-1', @@ -197,6 +253,11 @@ test('reads a provider-only ledger that predates canonical metering', async () = if (diagnostics.status !== 'available') return; assert.equal(diagnostics.modelId, 'model-old'); assert.deepEqual(diagnostics.composition?.tools, [{ name: 'Bash', bytes: 800 }]); + assert.deepEqual(diagnostics.requestPrefix, { + status: 'unavailable', + previousSegmentCount: 0, + preservedSegmentCount: 0, + }); }); test('a canonical record on the ledger keeps the legacy path out of it', async () => { @@ -219,6 +280,89 @@ test('a canonical record on the ledger keeps the legacy path out of it', async ( assert.equal(diagnostics.modelId, 'model-canonical'); }); +test('cold rebuild takes composition from the canonical attempt observation', async () => { + const store = runStore([ + { + header: runHeader('run-1', 1), + events: [ + meteringEvent('run-1', 'attempt-1', 10, 'model-canonical', 40, 200, { + requestObservation: requestObservation([ + { + kind: 'tool_schema', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'a'.repeat(64)}`, + bytes: 800, + label: 'CanonicalTool', + }, + ]), + }), + attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model-canonical', 40, 200, [ + { + kind: 'tool_schema', + index: 0, + cacheable: true, + hash: 'legacy', + bytes: 999, + label: 'BestEffortTool', + }, + ]), + ], + }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.deepEqual(diagnostics.composition?.tools, [{ name: 'CanonicalTool', bytes: 800 }]); +}); + +test('cold rebuild derives request-prefix continuity from the persisted attempt link', async () => { + const observed = requestObservation([ + { + kind: 'message', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'a'.repeat(64)}`, + bytes: 12, + role: 'user', + }, + ]); + const store = runStore([ + { + header: runHeader('run-1', 1), + events: [ + meteringEvent('run-1', 'attempt-1', 10, 'model', 40, 200, { + requestObservation: observed, + requestPrefixPredecessor: { kind: 'none' }, + }), + ], + }, + { + header: runHeader('run-2', 2), + events: [ + meteringEvent('run-2', 'attempt-2', 20, 'model', 50, 200, { + requestObservation: observed, + requestPrefixPredecessor: { kind: 'attempt', attemptId: 'attempt-1' }, + }), + ], + }, + ]); + + const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + + assert.equal(diagnostics.status, 'available'); + if (diagnostics.status !== 'available') return; + assert.deepEqual(diagnostics.requestPrefix, { + status: 'preserved', + previousSegmentCount: 1, + preservedSegmentCount: 1, + }); +}); + test('a legacy request whose capture is missing reports no composition, not an older one', async () => { const store = runStore([ { @@ -408,8 +552,8 @@ test('a session confirmed to have nothing is answered from the projection, not r }); 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. + // Historical provider-only ledgers can contain unbounded segment arrays. + // Their reader still bounds the diagnostic instead of rejecting the row. const segments = Array.from({ length: 300 }, (_, index) => ({ kind: 'tool_schema', index, @@ -421,10 +565,7 @@ test('names at most the bounded number of tools, and accounts for the rest', asy 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), - ], + events: [attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model', 40, 200, segments)], }, ]); @@ -532,6 +673,53 @@ test('a damaged projection is repaired, not preserved forever', async () => { } }); +test('an old readable-order projection is upgraded after one cold rebuild', 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) }, + ); + await writer.repairEventProjection( + 'session-1', + 'latest_context', + { + type: 'latest_context', + id: 'latest-context-attempt-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-run-1', + ts: 10, + data: { + ...latestContext('attempt-1', 10).snapshot, + schemaVersion: 1, + composition: { segments: [{ kind: 'tool_definitions', bytes: 999 }] }, + }, + }, + { 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'); + assert.ok(scanned > 0, 'the old schema requires one canonical rebuild'); + + scanned = 0; + const second = await readLatestContextDiagnostics(counted, 'session-1'); + assert.equal(second.status, 'available'); + assert.equal(scanned, 0, 'the rebuilt current schema replaces the old row'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function countingStore( reader: ReturnType, onScan: () => void, @@ -557,7 +745,7 @@ function latestContext(attemptId: string, completedAt: number, modelId = 'model' attemptId, orderedAt: completedAt, snapshot: { - schemaVersion: 1, + schemaVersion: 2, attemptId, providerId: 'anthropic', modelId, @@ -640,9 +828,8 @@ 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). + * The durable canonical record. Identity, provider-reported numbers, and the + * prepared-request observation all describe this one dispatched attempt. */ function meteringEvent( runId: string, @@ -674,6 +861,7 @@ function meteringEvent( callKind: 'main', providerId: 'anthropic', modelId, + requestPrefixDomain: `sha256:${'d'.repeat(64)}`, startedAt: completedAt - 1, completedAt, latencyMs: 1, @@ -687,6 +875,18 @@ function meteringEvent( }; } +function requestObservation(segments: Array>) { + return { + schemaVersion: 1, + digest: `sha256:${'f'.repeat(64)}`, + bytes: segments.reduce( + (total, segment) => total + (typeof segment.bytes === 'number' ? segment.bytes : 0), + 0, + ), + segments, + }; +} + function checkpointEvent( runId: string, ts: number, diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 7d7db16e9e..28caa9bb40 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1530,6 +1530,7 @@ test('conversation copy rewrites the nested identity of a model call attempt', a providerId: 'provider', modelId: 'model', captureArtifactId: 'artifact-source', + requestPrefixPredecessor: { kind: 'none' }, startedAt: 1, completedAt: 2, latencyMs: 1, @@ -1541,6 +1542,38 @@ test('conversation copy rewrites the nested identity of a model call attempt', a costUsd: 0.01, }, }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'model_call_attempt_recorded', + id: 'attempt-source-2', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 3, + data: { + schemaVersion: 1, + logicalCallId: 'logical-source-2', + attemptId: 'attempt-source-2', + traceId: 'trace-source-2', + sessionId: 'session-source', + runId: 'run-source', + turnId: 'turn-1', + step: 1, + attempt: 0, + callKind: 'main', + providerId: 'provider', + modelId: 'model', + requestPrefixPredecessor: { kind: 'attempt', attemptId: 'attempt-source' }, + startedAt: 2, + completedAt: 3, + latencyMs: 1, + status: 'completed', + usageBasis: 'reported', + inputTokens: 12, + outputTokens: 5, + costBasis: 'priced', + costUsd: 0.01, + }, + }); const source = await new RuntimeReadModel({ runStore, runtimeEventStore, @@ -1563,8 +1596,13 @@ test('conversation copy rewrites the nested identity of a model call attempt', a const [targetRun] = await runStore.listSessionRuns('session-target'); assert.ok(targetRun); const targetEvents = await runStore.readEvents('session-target', targetRun.runId); - const attempt = targetEvents.find((event) => event.type === 'model_call_attempt_recorded'); + const copiedAttempts = targetEvents.filter( + (event) => event.type === 'model_call_attempt_recorded', + ); + const attempt = copiedAttempts.find((event) => event.data?.step === 0); + const successor = copiedAttempts.find((event) => event.data?.step === 1); assert.ok(attempt); + assert.ok(successor); // The envelope moved to the target session/run. assert.equal(attempt.sessionId, 'session-target'); assert.equal(attempt.runId, targetRun.runId); @@ -1585,6 +1623,10 @@ test('conversation copy rewrites the nested identity of a model call attempt', a assert.equal(decoded.sessionId, attempt.sessionId); assert.equal(decoded.runId, attempt.runId); assert.equal(decoded.attemptId, attempt.id); + assert.deepEqual(decodeModelCallAttempt(successor.data).requestPrefixPredecessor, { + kind: 'attempt', + attemptId: attempt.id, + }); } finally { await rm(root, { recursive: true, force: true }); } @@ -1884,6 +1926,30 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi latencyMs: 0.5, }, }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'provider_request_attempt_recorded', + id: 'attempt-without-capture-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.55, + data: { + traceId: 'provider-trace-without-capture-source', + attemptId: 'attempt-without-capture-source', + turnId: 'turn-1', + step: 2, + attempt: 1, + providerId: 'provider-without-capture', + modelId: 'model', + requestHash: 'request-hash-without-capture', + requestBytes: 13, + segments: [], + startedAt: 2.5, + completedAt: 2.55, + status: 'completed', + latencyMs: 0.05, + }, + }); // A legacy event from the retired active-full writer is treated like any // other event this build cannot emit and is therefore not copied. await runStore.appendEvent('session-source', 'run-source', { @@ -2086,6 +2152,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi [ 'provider_request_captured', 'provider_request_attempt_recorded', + 'provider_request_attempt_recorded', 'history_compact_checkpoint_recorded', 'run_completed', ], @@ -2094,10 +2161,17 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi (event) => event.type === 'provider_request_captured', ); const targetAttempt = targetOperationalEvents.find( - (event) => event.type === 'provider_request_attempt_recorded', + (event) => + event.type === 'provider_request_attempt_recorded' && event.data?.providerId === 'provider', + ); + const targetAttemptWithoutCapture = targetOperationalEvents.find( + (event) => + event.type === 'provider_request_attempt_recorded' && + event.data?.providerId === 'provider-without-capture', ); assert.ok(targetCapture); assert.ok(targetAttempt); + assert.ok(targetAttemptWithoutCapture); assert.equal(targetCapture.data?.captureId, targetCapture.id); assert.equal(targetCapture.data?.artifactId, 'artifact-target'); assert.notEqual(targetCapture.data?.traceId, 'provider-trace-source'); @@ -2105,6 +2179,8 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi assert.equal(targetAttempt.data?.captureId, targetCapture.id); assert.equal(targetAttempt.data?.captureArtifactId, 'artifact-target'); assert.equal(targetAttempt.data?.traceId, targetCapture.data?.traceId); + assert.equal(targetAttemptWithoutCapture.data?.captureId, undefined); + assert.equal(targetAttemptWithoutCapture.data?.captureArtifactId, undefined); assert.equal(targetEvents[1]?.refs?.providerRequestTraceId, targetCapture.data?.traceId); assert.equal(targetEvents[1]?.refs?.traceEventId, targetCapture.id); assert.doesNotMatch(JSON.stringify(targetOperationalEvents), /OPAQUE_SOURCE_COMPACTION_STATE/); diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 65900de151..a8136c5939 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -36,6 +36,9 @@ type TestAiSdkBackendInput = Omit & export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBackend { return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + requestPrefixDomain: + input.requestPrefixDomain ?? + 'sha256:7d796c7f324369cc3cf7b86bcb87ffa59c0c8b964e347c57ca9e6e16618df443', ...input, }); } diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts index ea7e88175e..76d0860506 100644 --- a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -135,8 +135,7 @@ describe('buildLlmHistorySummarizer', () => { return now; }, newId: () => 'trace-id', - persistCapture: async () => ({ artifactId: 'artifact-1' }), - recordAttempt: () => {}, + persistArtifact: async () => ({ artifactId: 'artifact-1' }), accounting: { sessionId: 'sess-1', resolveRunId: () => 'run-1', @@ -200,8 +199,7 @@ describe('buildLlmHistorySummarizer', () => { turnId: 'turn-1', now: () => 100 + id, newId: () => `request-${++id}`, - persistCapture: async () => ({ artifactId: `artifact-${id}` }), - recordAttempt: () => {}, + persistArtifact: async () => ({ artifactId: `artifact-${id}` }), accounting: { sessionId: 'sess-1', resolveRunId: () => 'run-1', diff --git a/packages/runtime/src/__tests__/latest-context-commit.test.ts b/packages/runtime/src/__tests__/latest-context-commit.test.ts index e1903d94a3..d5972662b5 100644 --- a/packages/runtime/src/__tests__/latest-context-commit.test.ts +++ b/packages/runtime/src/__tests__/latest-context-commit.test.ts @@ -36,8 +36,11 @@ 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 { + decodeModelCallAttempt, + PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSessionStore } from '@maka/storage/session-store'; @@ -45,7 +48,7 @@ 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 () => { +test('a real send seals its observation into SQLite and reconstructs it after restart', 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 @@ -99,12 +102,46 @@ test('a real send seals its row all the way into SQLite, with nothing injected', 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. - } + const sendAdmittedTurn = async ( + turnId: string, + runId: string, + userMessageId: string, + text: string, + previousRootTurnId: string | null, + ) => { + for await (const _event of manager.sendMessage( + session.id, + { turnId, text }, + { + runId, + userMessageId, + admitTurn: async () => { + const result = await runStore.admitRootTurn({ + sessionId: session.id, + turnId, + proposedRunId: runId, + proposedUserMessageId: userMessageId, + execution: { kind: 'external_message' }, + previousRootTurnId, + normalizedInput: { text }, + sourceMessages: [], + admittedAt: now(), + }); + return result.kind === 'conflict' ? 'cancelled' : 'admitted'; + }, + }, + )) { + // Drain the causal turn so its run reaches the durable ledger. + } + }; + await sendAdmittedTurn('turn-1', 'run-1', 'message-1', 'what is my context made of?', null); + await sendAdmittedTurn( + 'turn-2', + 'run-2', + 'message-2', + 'and did the existing request prefix stay intact?', + 'turn-1', + ); let scanned = 0; const diagnostics = await readLatestContextDiagnostics( @@ -130,28 +167,162 @@ test('a real send seals its row all the way into SQLite, with nothing injected', diagnostics.composition?.segments.some((segment) => segment.kind === 'messages'), 'and the request describes what it was made of', ); + const recordedAttempts = ( + await Promise.all( + ( + await runStore.listSessionRuns(session.id) + ).map((run) => runStore.readEvents(session.id, run.runId)), + ) + ) + .flat() + .filter((event) => event.type === 'model_call_attempt_recorded') + .map((event) => event.data); + assert.deepEqual( + recordedAttempts.map((attempt) => attempt?.requestPrefixPredecessor), + [{ kind: 'none' }, { kind: 'attempt', attemptId: recordedAttempts[0]?.attemptId }], + ); + assert.deepEqual(diagnostics.requestPrefix, { + status: 'preserved', + previousSegmentCount: 1, + preservedSegmentCount: 1, + }); assert.equal(scanned, 0, 'the row was committed by the send, not rebuilt by the read'); await manager.stopSession(session.id, { source: 'stop_button' }); + runStore.close?.(); + + const reopened = createSqliteAgentRunStore(root); + try { + const runs = await reopened.listSessionRuns(session.id); + const canonicalAttempts = ( + await Promise.all( + runs.map(async (run) => { + const events = await reopened.readEvents(session.id, run.runId); + return events + .filter((event) => event.type === 'model_call_attempt_recorded') + .map((event) => decodeModelCallAttempt(event.data)); + }), + ) + ).flat(); + assert.equal(canonicalAttempts.length, 2); + for (const attempt of canonicalAttempts) { + const observation = attempt.requestObservation; + assert.ok(observation); + assert.ok(observation.segments.length <= PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS); + assert.ok(observation.segments.length > 0); + assert.ok(observation.segments.every((segment) => segment.comparison === 'exact')); + } + + let coldScans = 0; + const cold = await readLatestContextDiagnostics( + { + listSessionRuns: (sessionId) => reopened.listSessionRuns(sessionId), + readEvents: async (sessionId, runId) => { + coldScans += 1; + return reopened.readEvents(sessionId, runId); + }, + repairEventProjection: (sessionId, type, event, options) => + reopened.repairEventProjection(sessionId, type, event, options), + }, + session.id, + ); + + assert.ok(coldScans > 0, 'omitting the projection reader forces a restart-safe ledger fold'); + assert.equal(cold.status, 'available'); + if (cold.status !== 'available') return; + assert.deepEqual(cold.composition, diagnostics.composition); + assert.deepEqual(cold.requestPrefix, diagnostics.requestPrefix); + } finally { + reopened.close?.(); + } } 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; +test('an artifact captured before abort does not create a canonical sent attempt', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-aborted-request-chain-')); + try { + const sessionStore = createSessionStore(root); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const backends = new BackendRegistry(); + let ids = 0; + const newId = () => `abort-chain-${++ids}`; + let providerCalls = 0; + let artifactWrites = 0; + + backends.register('ai-sdk', (ctx) => { + let backend!: ReturnType; + backend = 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: () => + new MockLanguageModelV4({ + doStream: async () => { + providerCalls += 1; + return { stream: simulateReadableStream({ chunks: [] }) }; + }, + }), + tools: [], + persistPreparedRequestArtifact: async () => { + artifactWrites += 1; + void backend.stop('user_stop'); + return { artifactId: 'abandoned-artifact' }; + }, + ...(ctx.recordModelCallAttempt + ? { recordModelCallAttempt: ctx.recordModelCallAttempt } + : {}), + newId, + now: () => 1_000 + ids, + }); + return backend; + }); - const forwarded = forward(commit); + const manager = new SessionManager({ + store: sessionStore, + runStore, + runtimeEventStore, + backends, + newId, + now: () => 1_000 + ids, + }); + const session = await manager.createSession({ + cwd: root, + llmConnectionSlug: 'mock-main', + permissionMode: 'bypass', + }); + for await (const _event of manager.sendMessage(session.id, { + turnId: 'turn-aborted-before-dispatch', + text: 'abort after preparing the request', + })) { + // Drain the aborted turn through the real AgentRun store. + } - assert.equal(forwarded.latestContext?.attemptId, 'a-1', 'the derived row survives the hop'); - assert.equal(forwarded.attempt.attemptId, 'a-1'); + const runs = await runStore.listSessionRuns(session.id); + const events = ( + await Promise.all(runs.map((run) => runStore.readEvents(session.id, run.runId))) + ).flat(); + assert.equal(artifactWrites, 1); + assert.equal(providerCalls, 0); + assert.equal(events.filter((event) => event.type === 'model_call_attempt_recorded').length, 0); + assert.deepEqual(await readLatestContextDiagnostics(runStore, session.id), { + status: 'unavailable', + reason: 'no_completed_request', + }); + await manager.stopSession(session.id, { source: 'stop_button' }); + } finally { + await rm(root, { recursive: true, force: true }); + } }); function answeringModel(): MockLanguageModelV4 { 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 12e9de8ac6..3342589670 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -522,7 +522,9 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { }, ...(options.meteredSummarizer ? { - recordProviderRequestCapture: async () => ({ artifactId: 'artifact-mid-turn-capture' }), + persistPreparedRequestArtifact: async () => ({ + artifactId: 'artifact-mid-turn-capture', + }), recordModelCallAttempt: (commit: ModelCallCommit) => { commits.push(commit); modelCalls.push(commit.attempt); diff --git a/packages/runtime/src/__tests__/prompt-composition.test.ts b/packages/runtime/src/__tests__/prompt-composition.test.ts index 5ad9077bf0..1fb1e610b3 100644 --- a/packages/runtime/src/__tests__/prompt-composition.test.ts +++ b/packages/runtime/src/__tests__/prompt-composition.test.ts @@ -30,7 +30,7 @@ import { PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, } from '../prompt-composition.js'; import type { SizedRequestSegment } from '../prompt-composition.js'; -import { capturePreparedProviderRequest } from '../request-shape.js'; +import { prepareRequestObservation } from '../request-shape.js'; function segment(overrides: Partial = {}): SizedRequestSegment { return { kind: 'message', bytes: 10, ...overrides }; @@ -81,6 +81,17 @@ describe('foldPromptComposition', () => { assert.deepEqual(composition?.segments, [{ kind: 'tool_definitions', bytes: 750 }]); }); + test('preserves the count carried by a bounded tool remainder', () => { + const composition = foldPromptComposition([ + segment({ kind: 'tool_schema', bytes: 500, label: 'Bash' }), + segment({ kind: 'tool_schema', bytes: 9_000, representedSegments: 748 }), + ]); + + assert.deepEqual(composition?.tools, [{ name: 'Bash', bytes: 500 }]); + assert.deepEqual(composition?.remainingTools, { count: 748, bytes: 9_000 }); + assert.equal(composition?.unlabelledToolBytes, undefined); + }); + test('drops a kind nothing contributed to instead of showing it as zero', () => { const composition = foldPromptComposition([ segment({ kind: 'system_prompt', bytes: 400 }), @@ -102,18 +113,18 @@ describe('foldPromptComposition', () => { }); }); -describe('a real capture survives the whole chain into one fold', () => { - test('capture -> JSON -> decode -> fold keeps the same breakdown', () => { +describe('a real observation survives the whole chain into one fold', () => { + test('prepare -> canonical segments -> 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' }], + const material = prepareRequestObservation({ + prompt: [ + { role: 'system', content: 'you are a helpful assistant' }, + { role: 'user', content: 'hello' }, + ], tools: [ { name: 'Bash', description: 'Run a command', inputSchema: { type: 'object' } }, { name: 'Read', inputSchema: { type: 'object' } }, @@ -121,7 +132,7 @@ describe('a real capture survives the whole chain into one fold', () => { providerOptions: { anthropic: { thinking: { type: 'enabled' } } }, }); - const composition = foldPromptComposition(capture.segments); + const composition = foldPromptComposition(material.observation.segments); assert.deepEqual( composition?.tools?.map((tool) => tool.name), @@ -138,23 +149,6 @@ describe('a real capture survives the whole chain into one fold', () => { 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', - ); }); }); diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index 308787e7f0..607e39dffc 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -244,94 +244,17 @@ describe('strict provider-request usage', () => { }); }); -describe('provider request capture commit', () => { - test('links body-free metadata and returns the committed artifact reference', async () => { - const ledgerCaptures: Array> = []; - const recordCapture = telemetry.createProviderRequestCaptureRecorder({ - persistArtifact: async () => ({ artifactId: 'artifact-capture-1' }), - recordLedger: async (capture) => { - ledgerCaptures.push(capture as unknown as Record); - }, - }); - - const result = await recordCapture({ - schemaVersion: 2, - traceId: 'trace-1', - captureId: 'capture-1', - turnId: 'turn-1', - step: 0, - providerId: 'openai', - modelId: 'gpt-test', - requestHash: 'sha256:request', - requestPayloadWithoutProviderOptionsHash: 'sha256:shared-request', - requestBytes: 2, - segments: [], - serializedRequest: '{}', - }); - - assert.deepEqual(result, { artifactId: 'artifact-capture-1' }); - assert.equal(ledgerCaptures.length, 1); - assert.equal(ledgerCaptures[0]?.artifactId, 'artifact-capture-1'); - assert.equal(Object.hasOwn(ledgerCaptures[0]!, 'serializedRequest'), false); - }); - - test('retains the request artifact when a failed ledger append may have landed', async () => { - const ledgerError = new Error('capture ledger append failed'); - const ledgerCaptures: Array> = []; - const persistedArtifactIds = new Set(); - const createRecorder = Reflect.get( - telemetry, - 'createProviderRequestCaptureRecorder', - ) as unknown as - | ((input: Record) => (capture: Record) => Promise) - | undefined; - assert.equal(typeof createRecorder, 'function'); - const recordCapture = createRecorder!({ - persistArtifact: async () => { - persistedArtifactIds.add('artifact-capture-1'); - return { artifactId: 'artifact-capture-1' }; - }, - recordLedger: async (capture: Record) => { - ledgerCaptures.push(capture); - throw ledgerError; - }, - }); - - await assert.rejects( - recordCapture({ - schemaVersion: 2, - traceId: 'trace-1', - captureId: 'capture-1', - turnId: 'turn-1', - step: 0, - providerId: 'openai', - modelId: 'gpt-test', - requestHash: 'sha256:request', - requestPayloadWithoutProviderOptionsHash: 'sha256:shared-request', - requestBytes: 2, - segments: [], - serializedRequest: '{}', - }), - (error) => error === ledgerError, - ); - assert.equal(ledgerCaptures.length, 1); - assert.deepEqual([...persistedArtifactIds], ['artifact-capture-1']); - }); -}); - describe('provider request tracker', () => { test('records the request model context window on completed attempts', async () => { - const attempts: telemetry.ProviderRequestAttemptRecord[] = []; + const attempts: ModelCallAttempt[] = []; const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-context', turnId: 'turn-context', contextWindow: 200_000, now: () => Date.now(), newId: () => 'id', - persistCapture: async () => ({ artifactId: 'artifact' }), - recordAttempt: async (attempt) => { - attempts.push(attempt); - }, + persistArtifact: async () => ({ artifactId: 'artifact' }), + accounting: canonicalAccounting(attempts), }); const result = await tracker.trackStream({ @@ -346,17 +269,15 @@ describe('provider request tracker', () => { }); test('omits a non-positive request model context window', async () => { - const attempts: telemetry.ProviderRequestAttemptRecord[] = []; + const attempts: ModelCallAttempt[] = []; const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-context', turnId: 'turn-context', contextWindow: 0, now: () => Date.now(), newId: () => 'id', - persistCapture: async () => ({ artifactId: 'artifact' }), - recordAttempt: async (attempt) => { - attempts.push(attempt); - }, + persistArtifact: async () => ({ artifactId: 'artifact' }), + accounting: canonicalAccounting(attempts), }); const result = await tracker.trackStream({ @@ -373,44 +294,20 @@ describe('provider request tracker', () => { test('persists a logical capture before each physical attempt and reuses it for retries', async () => { const captures: Array<{ captureId: string; - requestHash: string; serializedRequest: string; }> = []; - const attempts: Array<{ - step: number; - attempt: number; - status: string; - captureId: string; - }> = []; - const Tracker = Reflect.get(telemetry, 'ProviderRequestTracker') as unknown as - | (new ( - input: Record, - ) => { - setStep(step: number): void; - trackStream(input: Record): Promise<{ stream: ReadableStream }>; - }) - | undefined; - assert.equal(typeof Tracker, 'function'); + const attempts: ModelCallAttempt[] = []; let id = 0; - const tracker = new Tracker!({ + const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-1', turnId: 'turn-1', now: () => Date.now(), newId: () => `id-${++id}`, - persistCapture: async (capture: { - captureId: string; - requestHash: string; - serializedRequest: string; - }) => { + persistArtifact: async (capture) => { captures.push(capture); return { artifactId: `artifact-${captures.length}` }; }, - recordAttempt: async (attempt: { - step: number; - attempt: number; - status: string; - captureId: string; - }) => attempts.push(attempt), + accounting: canonicalAccounting(attempts), }); tracker.setStep(2); const params = preparedParams('hello'); @@ -461,34 +358,34 @@ describe('provider request tracker', () => { assert.equal(captures.length, 1); assert.deepEqual(JSON.parse(captures[0]!.serializedRequest), params); assert.deepEqual( - attempts.map(({ step, attempt, status, captureId }) => ({ + attempts.map(({ step, attempt, status, captureArtifactId }) => ({ step, attempt, status, - captureId, + captureArtifactId, })), [ { step: 2, - attempt: 1, + attempt: 0, status: 'failed', - captureId: captures[0]!.captureId, + captureArtifactId: 'artifact-1', }, { step: 2, - attempt: 2, + attempt: 1, status: 'completed', - captureId: captures[0]!.captureId, + captureArtifactId: 'artifact-1', }, ], ); - assert.equal((attempts[1] as Record).cacheReadInputSource, 'provider'); - assert.equal((attempts[1] as Record).cacheMissInputSource, 'derived'); + assert.equal(attempts[1]?.cacheReadInputTokens, 4); + assert.equal(attempts[1]?.cacheMissInputTokens, 6); }); test('captures and attributes a non-streaming physical provider call', async () => { const captures: Array<{ captureId: string; serializedRequest: string }> = []; - const attempts: Array> = []; + const attempts: ModelCallAttempt[] = []; let providerCalls = 0; let id = 0; const tracker = new telemetry.ProviderRequestTracker({ @@ -496,13 +393,11 @@ describe('provider request tracker', () => { turnId: 'turn-history', now: () => 1_000 + id, newId: () => `history-${++id}`, - persistCapture: async (capture) => { + persistArtifact: async (capture) => { captures.push(capture); return { artifactId: 'history-artifact' }; }, - recordAttempt: (attempt) => { - attempts.push(attempt as unknown as Record); - }, + accounting: canonicalAccounting(attempts), }); const params = preparedParams('history summary'); const result = await tracker.trackGenerate({ @@ -529,12 +424,12 @@ describe('provider request tracker', () => { assert.equal(captures.length, 1); assert.deepEqual(JSON.parse(captures[0]!.serializedRequest), params); assert.deepEqual( - attempts.map(({ status, finishReason, inputTokens, outputTokens, captureId }) => ({ + attempts.map(({ status, finishReason, inputTokens, outputTokens, captureArtifactId }) => ({ status, finishReason, inputTokens, outputTokens, - captureId, + captureArtifactId, })), [ { @@ -542,24 +437,25 @@ describe('provider request tracker', () => { finishReason: 'stop', inputTokens: 7, outputTokens: 3, - captureId: captures[0]!.captureId, + captureArtifactId: 'history-artifact', }, ], ); }); - test('redacts native compaction state from provider request captures', async () => { - const captures: Array<{ serializedRequest: string }> = []; + test('derives the artifact and canonical opaque observation from one redacted request', async () => { + const captures: telemetry.PreparedRequestArtifactInput[] = []; + const attempts: ModelCallAttempt[] = []; const tracker = new telemetry.ProviderRequestTracker({ traceId: 'compaction-trace', turnId: 'turn-compaction', now: () => 1_000, newId: () => 'compaction-id', - persistCapture: async (capture) => { + persistArtifact: async (capture) => { captures.push(capture); return { artifactId: 'compaction-artifact' }; }, - recordAttempt: () => undefined, + accounting: canonicalAccounting(attempts), }); const params = { image: new URL('https://example.com/provider-image.png'), @@ -607,7 +503,11 @@ describe('provider request tracker', () => { }); assert.equal(captures.length, 1); + assert.equal(attempts.length, 1); + assert.deepEqual(attempts[0]?.requestObservation, captures[0]?.observation); + assert.equal(attempts[0]?.requestObservation?.segments[0]?.comparison, 'opaque'); assert.doesNotMatch(captures[0]!.serializedRequest, /cmp_secret|OPAQUE_ENCRYPTED_STATE/); + assert.doesNotMatch(JSON.stringify(attempts[0]), /cmp_secret|OPAQUE_ENCRYPTED_STATE/); assert.deepEqual(JSON.parse(captures[0]!.serializedRequest), { image: 'https://example.com/provider-image.png', prompt: [ @@ -654,11 +554,10 @@ describe('provider request tracker', () => { beforeDispatch: async () => { throw new Error('Run Composition store unavailable'); }, - persistCapture: async () => { + persistArtifact: async () => { captured = true; return { artifactId: 'unreachable-artifact' }; }, - recordAttempt: () => {}, }); await assert.rejects( @@ -678,26 +577,19 @@ describe('provider request tracker', () => { assert.equal(dispatched, false); }); - test('captures a changed logical body separately and blocks provider calls on capture failure', async () => { + test('dispatches with its observation when private artifact persistence fails', async () => { const captures: string[] = []; - const Tracker = Reflect.get(telemetry, 'ProviderRequestTracker') as unknown as new ( - input: Record, - ) => { - setStep(step: number): void; - trackStream(input: Record): Promise<{ stream: ReadableStream }>; - }; let providerCalls = 0; - const tracker = new Tracker({ + const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-2', turnId: 'turn-2', now: () => Date.now(), newId: () => `capture-${captures.length + 1}`, - persistCapture: async (capture: { requestHash: string }) => { - captures.push(capture.requestHash); + persistArtifact: async (capture) => { + captures.push(capture.observation.digest); if (captures.length === 2) throw new Error('capture unavailable'); return { artifactId: 'artifact-1' }; }, - recordAttempt: () => {}, }); tracker.setStep(0); const completed = await tracker.trackStream({ @@ -712,39 +604,73 @@ describe('provider request tracker', () => { }); await drain(completed.stream); - await assert.rejects( - tracker.trackStream({ - providerId: 'anthropic', - modelId: 'claude-test', - params: preparedParams('after'), - abortSignal: new AbortController().signal, - doStream: async () => { - providerCalls += 1; - return { stream: streamOf([finishPart()]) }; - }, - }), - /capture unavailable/, - ); - assert.equal(providerCalls, 1); + const withoutArtifact = await tracker.trackStream({ + providerId: 'anthropic', + modelId: 'claude-test', + params: preparedParams('after'), + abortSignal: new AbortController().signal, + doStream: async () => { + providerCalls += 1; + return { stream: streamOf([finishPart()]) }; + }, + }); + await drain(withoutArtifact.stream); + assert.equal(providerCalls, 2); assert.equal(captures.length, 2); assert.notEqual(captures[0], captures[1]); }); + test('does not wait for private artifact persistence before dispatch or accounting', async () => { + let releaseArtifact!: (value: { artifactId: string }) => void; + const artifactPending = new Promise<{ artifactId: string }>((resolve) => { + releaseArtifact = resolve; + }); + const recorded: ModelCallAttempt[] = []; + let providerCalls = 0; + const tracker = new telemetry.ProviderRequestTracker({ + traceId: 'trace-slow-artifact', + turnId: 'turn-slow-artifact', + now: () => 1_000, + newId: () => 'slow-artifact-id', + persistArtifact: () => artifactPending, + accounting: { + sessionId: 'session-1', + resolveRunId: () => 'run-1', + callKind: 'main', + record: ({ attempt }) => { + recorded.push(attempt); + }, + }, + }); + + const tracked = tracker.trackGenerate({ + providerId: 'anthropic', + modelId: 'claude-test', + params: preparedParams('hello'), + doGenerate: async () => { + providerCalls += 1; + return { finishReason: 'stop' }; + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(providerCalls, 1, 'provider dispatch does not wait for artifact persistence'); + assert.equal(recorded.length, 1, 'canonical accounting does not wait for the artifact either'); + releaseArtifact({ artifactId: 'artifact-late' }); + await tracked; + + assert.equal(recorded[0]?.captureArtifactId, undefined); + assert.ok(recorded[0]?.requestObservation); + }); + test('records an errored stream after output as interrupted', async () => { - const attempts: Array<{ status: string }> = []; - const Tracker = Reflect.get(telemetry, 'ProviderRequestTracker') as unknown as new ( - input: Record, - ) => { - setStep(step: number): void; - trackStream(input: Record): Promise<{ stream: ReadableStream }>; - }; - const tracker = new Tracker({ + const attempts: ModelCallAttempt[] = []; + const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-3', turnId: 'turn-3', now: () => Date.now(), newId: () => 'id', - persistCapture: async () => ({ artifactId: 'artifact' }), - recordAttempt: async (attempt: { status: string }) => attempts.push(attempt), + persistArtifact: async () => ({ artifactId: 'artifact' }), + accounting: canonicalAccounting(attempts), }); tracker.setStep(0); const result = await tracker.trackStream({ @@ -759,17 +685,15 @@ describe('provider request tracker', () => { }); test('records an in-flight attempt as aborted when its signal is cancelled', async () => { - const attempts: Array<{ status: string }> = []; + const attempts: ModelCallAttempt[] = []; const abort = new AbortController(); const tracker = new telemetry.ProviderRequestTracker({ traceId: 'trace-4', turnId: 'turn-4', now: () => Date.now(), newId: () => 'id', - persistCapture: async () => ({ artifactId: 'artifact' }), - recordAttempt: async (attempt) => { - attempts.push(attempt); - }, + persistArtifact: async () => ({ artifactId: 'artifact' }), + accounting: canonicalAccounting(attempts), }); tracker.setStep(0); await tracker.trackStream({ @@ -788,7 +712,7 @@ describe('provider request tracker', () => { test('does not capture or record an attempt when cancellation predates dispatch', async () => { let captures = 0; - let attempts = 0; + const attempts: ModelCallAttempt[] = []; let providerCalls = 0; const abort = new AbortController(); abort.abort(); @@ -797,13 +721,11 @@ describe('provider request tracker', () => { turnId: 'turn-5', now: () => Date.now(), newId: () => 'id', - persistCapture: async () => { + persistArtifact: async () => { captures += 1; return { artifactId: 'artifact' }; }, - recordAttempt: async () => { - attempts += 1; - }, + accounting: canonicalAccounting(attempts), }); await assert.rejects( @@ -821,13 +743,13 @@ describe('provider request tracker', () => { ); assert.equal(captures, 0); - assert.equal(attempts, 0); + assert.equal(attempts.length, 0); assert.equal(providerCalls, 0); }); test('does not dispatch or record an attempt when cancellation happens during capture', async () => { let captures = 0; - let attempts = 0; + const attempts: ModelCallAttempt[] = []; let providerCalls = 0; const abort = new AbortController(); const tracker = new telemetry.ProviderRequestTracker({ @@ -835,14 +757,12 @@ describe('provider request tracker', () => { turnId: 'turn-6', now: () => Date.now(), newId: () => 'id', - persistCapture: async () => { + persistArtifact: async () => { captures += 1; abort.abort(); return { artifactId: 'artifact' }; }, - recordAttempt: async () => { - attempts += 1; - }, + accounting: canonicalAccounting(attempts), }); await assert.rejects( @@ -860,11 +780,22 @@ describe('provider request tracker', () => { ); assert.equal(captures, 1); - assert.equal(attempts, 0); + assert.equal(attempts.length, 0); assert.equal(providerCalls, 0); }); }); +function canonicalAccounting(attempts: ModelCallAttempt[]): telemetry.ModelCallAccountingInput { + return { + sessionId: 'session-1', + resolveRunId: () => 'run-1', + callKind: 'main', + record: ({ attempt }) => { + attempts.push(attempt); + }, + }; +} + function preparedParams(text: string): Record { return { prompt: [ @@ -934,9 +865,9 @@ describe('canonical model-call accounting', () => { resolveRunId?: () => string | undefined; /** Models a deployment with request capture switched off. */ withoutCapture?: boolean; - recordAttempt?: (attempt: telemetry.ProviderRequestAttemptRecord) => void; callKind?: ModelCallAttempt['callKind']; historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; + requestPrefixDomain?: ModelCallAttempt['requestPrefixDomain']; }): telemetry.ProviderRequestTracker { let n = 0; return new telemetry.ProviderRequestTracker({ @@ -946,12 +877,14 @@ describe('canonical model-call accounting', () => { newId: () => `id-${++n}`, ...(overrides.withoutCapture ? {} - : { persistCapture: async () => ({ artifactId: 'artifact-1' }) }), - recordAttempt: overrides.recordAttempt ?? (() => {}), + : { persistArtifact: async () => ({ artifactId: 'artifact-1' }) }), accounting: { sessionId: 'session-1', resolveRunId: overrides.resolveRunId ?? (() => 'run-1'), callKind: overrides.callKind ?? 'main', + ...(overrides.requestPrefixDomain + ? { requestPrefixDomain: overrides.requestPrefixDomain } + : {}), ...(overrides.historyCompactRoute ? { historyCompactRoute: overrides.historyCompactRoute } : {}), @@ -962,6 +895,68 @@ describe('canonical model-call accounting', () => { }); } + test('canonical accounting carries the dispatch-owned request-prefix domain', async () => { + const recorded: ModelCallAttempt[] = []; + const requestPrefixDomain = `sha256:${'d'.repeat(64)}` as const; + const tracker = accountingTracker({ + requestPrefixDomain, + record: ({ attempt }) => { + recorded.push(attempt); + }, + }); + + const result = await tracker.trackStream({ + providerId: 'anthropic', + modelId: 'claude-test', + params: preparedParams('hello'), + doStream: async () => ({ stream: streamOf([finishPart()]) }), + }); + await drain(result.stream); + + assert.equal(recorded[0]?.requestPrefixDomain, requestPrefixDomain); + }); + + test('a capture abandoned before dispatch never enters the canonical sent sequence', async () => { + const recorded: ModelCallAttempt[] = []; + let providerCalls = 0; + const abort = new AbortController(); + const tracker = new telemetry.ProviderRequestTracker({ + traceId: 'trace-abandoned-capture', + turnId: 'turn-abandoned-capture', + now: () => 1_000, + newId: () => 'capture-abandoned', + persistArtifact: async () => { + abort.abort(); + return { artifactId: 'artifact-abandoned' }; + }, + accounting: { + sessionId: 'session-1', + resolveRunId: () => 'run-1', + callKind: 'main', + record: ({ attempt }) => { + recorded.push(attempt); + }, + }, + }); + + await assert.rejects( + tracker.trackGenerate({ + providerId: 'anthropic', + modelId: 'claude-test', + params: preparedParams('hello'), + abortSignal: abort.signal, + doGenerate: async () => { + providerCalls += 1; + return { finishReason: 'stop' }; + }, + }), + { name: 'AbortError' }, + ); + + assert.equal(providerCalls, 0); + assert.deepEqual(recorded, []); + }); + test('emits a decodable priced record for a completed call', async () => { const recorded: ModelCallAttempt[] = []; const tracker = accountingTracker({ @@ -995,16 +990,12 @@ describe('canonical model-call accounting', () => { test('persists a structured failure fingerprint and the selected compaction route', async () => { const recorded: ModelCallAttempt[] = []; - const diagnosticAttempts: telemetry.ProviderRequestAttemptRecord[] = []; const tracker = accountingTracker({ callKind: 'history_compact', historyCompactRoute: 'provider_native', record: ({ attempt }) => { recorded.push(attempt); }, - recordAttempt: (attempt) => { - diagnosticAttempts.push(attempt); - }, }); const providerError = Object.assign(new Error('provider payload must not persist'), { name: 'AI_APICallError', @@ -1035,14 +1026,10 @@ describe('canonical model-call accounting', () => { assert.equal(attempt.providerCode, 'rate_limit_exceeded'); assert.equal(attempt.providerRequestId, 'req-compact-1'); assert.equal(attempt.retryable, false); - assert.deepEqual(diagnosticAttempts[0]?.failure, { - errorClass: 'RateLimit', - httpStatus: 429, - providerCode: 'rate_limit_exceeded', - providerRequestId: 'req-compact-1', - retryable: false, - }); - assert.doesNotMatch(JSON.stringify(attempt), /private|prompt|response body/i); + assert.doesNotMatch( + JSON.stringify(attempt), + /private request body|private response body|private prompt/i, + ); }); test('records the physical route when one compaction call falls back', async () => { @@ -1136,15 +1123,11 @@ describe('canonical model-call accounting', () => { // request body is still a record of a call that really was billed, so the // canonical seam must not be gated on the capture sink being configured. const recorded: ModelCallAttempt[] = []; - const attempts: telemetry.ProviderRequestAttemptRecord[] = []; const tracker = accountingTracker({ withoutCapture: true, record: ({ attempt }) => { recorded.push(attempt); }, - recordAttempt: (a) => { - attempts.push(a); - }, }); const result = await tracker.trackStream({ @@ -1158,12 +1141,8 @@ describe('canonical model-call accounting', () => { const attempt = decodeModelCallAttempt(recorded[0]); assert.equal(attempt.usageBasis, 'reported'); assert.equal(attempt.captureArtifactId, undefined, 'there is no artifact to point at'); - // The request shape is computed locally, so it does not need the sink. - assert.equal(attempts.length, 1); - assert.equal(attempts[0]?.captureId, undefined); - assert.equal(attempts[0]?.captureArtifactId, undefined); - assert.ok((attempts[0]?.requestHash?.length ?? 0) > 0); - assert.ok((attempts[0]?.requestBytes ?? 0) > 0); + assert.match(attempt.requestObservation?.digest ?? '', /^sha256:[a-f0-9]{64}$/); + assert.ok((attempt.requestObservation?.segments.length ?? 0) > 0); }); test('an unresolvable price records unpriced rather than zero', async () => { diff --git a/packages/runtime/src/__tests__/request-shape.test.ts b/packages/runtime/src/__tests__/request-shape.test.ts index 0903f2df46..0286eb02e8 100644 --- a/packages/runtime/src/__tests__/request-shape.test.ts +++ b/packages/runtime/src/__tests__/request-shape.test.ts @@ -17,539 +17,181 @@ * under the License. */ -import { describe, test } from 'node:test'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; import { canonicalizeToolSet, + prepareRequestObservation, toolSchemaCharsForDiagnostics, - computeRequestShapeDiagnostic, } from '../request-shape.js'; -import * as requestShape from '../request-shape.js'; import type { MakaTool } from '../tool-runtime.js'; function tool(name: string): MakaTool { - return { - name, - description: name, - parameters: {}, - impl: () => ({}), - }; + return { name, description: name, parameters: {}, impl: () => ({}) }; } const invalid = tool('invalid'); describe('canonicalizeToolSet active allow-list', () => { - test('a tool absent from the active set is withheld; the set drives visibility', () => { - const { activeTools } = canonicalizeToolSet( + test('withholds inactive tools without removing them from the dispatch registry', () => { + const { providerTools, activeTools } = canonicalizeToolSet( [tool('Read'), tool('Rive'), tool('tool_search')], invalid, new Set(['Read', 'tool_search']), ); - assert.ok(activeTools.includes('Read'), 'Read is in the active set'); - assert.ok(activeTools.includes('tool_search'), 'tool_search is in the active set'); - assert.ok(!activeTools.includes('Rive'), 'Rive is absent from the active set, so hidden'); - }); - - test('a tool becomes active once it is in the active set', () => { - const { activeTools } = canonicalizeToolSet( - [tool('Read'), tool('Rive')], - invalid, - new Set(['Read', 'Rive']), - ); - assert.ok(activeTools.includes('Rive'), 'Rive is now in the active set'); - }); - test('providerTools keeps the full registry for dispatch; invalid present but not advertised', () => { - const { providerTools, activeTools } = canonicalizeToolSet( - [tool('Read'), tool('Rive')], - invalid, - new Set(['Read']), + assert.deepEqual(activeTools, ['Read', 'tool_search']); + assert.deepEqual( + providerTools.map((candidate) => candidate.name), + ['Read', 'Rive', 'tool_search', 'invalid'], ); - const names = providerTools.map((t) => t.name); - assert.ok(names.includes('Read')); - assert.ok(names.includes('Rive'), 'a hidden tool stays dispatchable in providerTools'); - assert.ok(names.includes('invalid'), 'repair target present in providerTools'); - assert.ok(!activeTools.includes('invalid'), 'invalid is never advertised to the model'); }); -}); -describe('diagnostics measure the provider-visible (active) tool subset', () => { - const connection = { providerType: 'openai', slug: 'c' } as never; - - function rich(name: string, schema: unknown): MakaTool { - return { name, description: name, parameters: schema, impl: () => ({}) }; - } - - function diag( - providerTools: MakaTool[], - activeTools: string[], - prior?: ReturnType, - ) { - return computeRequestShapeDiagnostic( - { - connection, - modelId: 'm', - systemPrompt: 's', - providerOptions: {}, - providerTools, - activeTools, - priorMessages: [], - }, - prior, - ); - } + test('measures only the provider-visible tool schemas', () => { + const tools: MakaTool[] = [ + { ...tool('Read'), parameters: { a: 1 } }, + { ...tool('Rive'), parameters: { big: 'x'.repeat(500) } }, + ]; - test('char count excludes an inactive tool schema', () => { - const tools = [rich('Read', { a: 1 }), rich('Rive', { big: 'x'.repeat(500) })]; - const withoutRive = toolSchemaCharsForDiagnostics(tools, ['Read']); - const withRive = toolSchemaCharsForDiagnostics(tools, ['Read', 'Rive']); assert.ok( - withRive > withoutRive + 400, - 'activating Rive should add its schema chars to the count', + toolSchemaCharsForDiagnostics(tools, ['Read', 'Rive']) > + toolSchemaCharsForDiagnostics(tools, ['Read']) + 400, ); }); - - test('toolSchemaHash ignores an INACTIVE tool schema change', () => { - const a = [rich('Read', { a: 1 }), rich('Rive', { v: 1 })]; - const b = [rich('Read', { a: 1 }), rich('Rive', { v: 2 })]; - assert.equal( - diag(a, ['Read']).componentHashes.toolSchemaHash, - diag(b, ['Read']).componentHashes.toolSchemaHash, - 'a change to an unadvertised schema must not move the hash', - ); - }); - - test('activating a hidden tool moves toolSchemaHash and reports tool_schema_changed', () => { - const tools = [rich('Read', { a: 1 }), rich('Rive', { v: 1 })]; - const before = diag(tools, ['Read']); - const after = diag(tools, ['Read', 'Rive'], before); - assert.notEqual(after.componentHashes.toolSchemaHash, before.componentHashes.toolSchemaHash); - assert.equal(after.prefixChangeReason, 'tool_schema_changed'); - }); }); -describe('prepared provider request capture', () => { - test('records cacheable request segments in provider-prefix order', () => { - const capture = Reflect.get(requestShape, 'capturePreparedProviderRequest') as - | ((input: { - providerId: string; - modelId: string; - instructions: string; - messages: Array<{ role: string; content: string }>; - tools: Array>; - providerOptions: Record; - }) => { - requestHash: string; - requestBytes: number; - serializedRequest: string; - segments: Array<{ - kind: string; - index: number; - cacheable: boolean; - hash: string; - bytes: number; - role?: string; - }>; - }) - | undefined; - - assert.equal(typeof capture, 'function'); - const result = capture!({ - providerId: 'anthropic', - modelId: 'claude-test', - instructions: 'system', - messages: [{ role: 'user', content: 'hello' }], - tools: [{ name: 'Bash', description: 'Run a command', inputSchema: { type: 'object' } }], - providerOptions: { anthropic: { thinking: { type: 'enabled', budgetTokens: 1_024 } } }, +describe('prepared request observation', () => { + test('derives the request digest and bytes from the private serialization', () => { + const material = prepareRequestObservation({ + prompt: [{ role: 'user', content: 'hello' }], + maxOutputTokens: 1_024, }); - assert.deepEqual( - result.segments.map(({ kind, index, cacheable, role }) => ({ - kind, - index, - cacheable, - ...(role ? { role } : {}), - })), - [ - { kind: 'tool_schema', index: 0, cacheable: true }, - { kind: 'system_prompt', index: 0, cacheable: true }, - { kind: 'message', index: 0, cacheable: true, role: 'user' }, - { kind: 'provider_options', index: 0, cacheable: false }, - ], + assert.equal( + material.observation.digest, + `sha256:${createHash('sha256').update(material.serializedRequest).digest('hex')}`, ); - assert.match(result.requestHash, /^sha256:[a-f0-9]{64}$/); - assert.equal(result.requestBytes, Buffer.byteLength(result.serializedRequest, 'utf8')); - assert.ok(result.segments.every((segment) => segment.bytes > 0)); - assert.ok(result.segments.every((segment) => /^sha256:[a-f0-9]{64}$/.test(segment.hash))); + assert.equal(material.observation.bytes, Buffer.byteLength(material.serializedRequest, 'utf8')); }); - 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: {}, + test('serializes non-JSON values without collapsing their semantic identity', () => { + const observed = prepareRequestObservation({ + bigint: 42n, + missing: undefined, + createdAt: new Date('2026-08-31T00:00:00.000Z'), + headers: new Map([['x-observation', 'present']]), + }); + const plain = prepareRequestObservation({ + bigint: '42', + missing: '[undefined]', + createdAt: '2026-08-31T00:00:00.000Z', + headers: { 'x-observation': 'present' }, }); - 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], - ]); + assert.doesNotThrow(() => JSON.parse(observed.serializedRequest)); + assert.notEqual(observed.observation.digest, plain.observation.digest); }); - test('versions and hashes non-provider-options request parameters for comparison', () => { - const capture = (providerOptions: Record, maxOutputTokens?: number) => - requestShape.capturePreparedProviderRequest({ - providerId: 'provider', - modelId: 'k3', - instructions: 'system', - messages: [{ role: 'user', content: 'hello' }], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions, - requestPayload: { - prompt: [{ role: 'user', content: 'hello' }], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions, - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - }, + test('preserves the semantic identity of binary request content', () => { + const observe = (byte: number) => + prepareRequestObservation({ + prompt: [ + { + role: 'user', + content: [ + { + type: 'file', + data: { type: 'data', data: new Uint8Array([byte]) }, + mediaType: 'application/octet-stream', + }, + ], + }, + ], }); - const anthropic = capture({ anthropic: { effort: 'max' } }, 131_072); - const openai = capture({ kimiCodingPlan: { reasoningEffort: 'max' } }, 131_072); - const changedSharedParameter = capture({ kimiCodingPlan: { reasoningEffort: 'max' } }, 32_768); - - assert.equal(anthropic.schemaVersion, 2); - assert.equal( - anthropic.requestPayloadWithoutProviderOptionsHash, - openai.requestPayloadWithoutProviderOptionsHash, - ); - assert.notEqual( - anthropic.requestPayloadWithoutProviderOptionsHash, - changedSharedParameter.requestPayloadWithoutProviderOptionsHash, - ); + const first = observe(1); + const second = observe(2); + assert.notEqual(first.serializedRequest, second.serializedRequest); + assert.notEqual(first.observation.digest, second.observation.digest); + assert.notEqual(first.observation.segments[0]?.digest, second.observation.segments[0]?.digest); + assert.equal(first.observation.segments[0]?.comparison, 'exact'); }); - test('keeps reasoning effort across provider namespaces in the protocol-independent hash', () => { - const hash = (providerOptions: Record, maxOutputTokens: number) => - requestShape.capturePreparedProviderRequest({ - providerId: 'kimi-coding-plan', - modelId: 'kimi-for-coding', - messages: [{ role: 'user', content: 'hello' }], - tools: [], - providerOptions, - requestPayload: { - prompt: [{ role: 'user', content: 'hello' }], - maxOutputTokens, - providerOptions, - }, - }).requestPayloadWithoutProviderOptionsHash; - - const anthropicMax = hash( - { - anthropic: { - effort: 'max', - thinking: { type: 'enabled', budgetTokens: 1_024 }, + test('marks redacted compaction content comparison-opaque', () => { + const material = prepareRequestObservation({ + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'custom', + kind: 'openai.compaction', + providerOptions: { openai: { redacted: true } }, + }, + ], }, - }, - 31_744, - ); - const openaiMax = hash({ kimiCodingPlan: { reasoningEffort: 'max' } }, 32_768); - const nativeOpenaiMax = hash({ openai: { reasoningEffort: 'max' } }, 32_768); - const nativeOpenaiHigh = hash({ openai: { reasoningEffort: 'high' } }, 32_768); - const zaiHigh = hash({ zaiCodingPlan: { reasoningEffort: 'high' } }, 32_768); - const zaiLow = hash({ zaiCodingPlan: { reasoningEffort: 'low' } }, 32_768); + ], + }); - assert.equal(anthropicMax, openaiMax); - assert.equal(anthropicMax, nativeOpenaiMax); - assert.equal(nativeOpenaiHigh, zaiHigh); - assert.notEqual(zaiHigh, zaiLow); - assert.notEqual(anthropicMax, hash({ kimiCodingPlan: { reasoningEffort: 'low' } }, 32_768)); - assert.notEqual(anthropicMax, hash({ kimiCodingPlan: { reasoningEffort: 'none' } }, 32_768)); + assert.equal(material.observation.segments[0]?.kind, 'message'); + assert.equal(material.observation.segments[0]?.comparison, 'opaque'); }); - test('normalizes Anthropic thinking budget into the protocol-independent output limit', () => { - const capture = (providerOptions: Record, maxOutputTokens: number) => - requestShape.capturePreparedProviderRequest({ - providerId: 'kimi-coding-plan', - modelId: 'kimi-for-coding', - instructions: 'system', - messages: [{ role: 'user', content: 'hello' }], - tools: [], - providerOptions, - requestPayload: { - prompt: [{ role: 'user', content: 'hello' }], - maxOutputTokens, - providerOptions, - }, - }); - - const anthropic = capture( - { anthropic: { thinking: { type: 'enabled', budgetTokens: 1_024 } } }, - 31_744, - ); - const openai = capture({ maka: { kimiReasoningField: 'reasoning_content' } }, 32_768); - - assert.equal( - anthropic.requestPayloadWithoutProviderOptionsHash, - openai.requestPayloadWithoutProviderOptionsHash, + test('bounds ordered segments without dropping their count or bytes', () => { + const prompt = Array.from({ length: 1_000 }, (_, index) => ({ + role: 'user', + content: `message-${index}`, + })); + const material = prepareRequestObservation({ prompt }); + const expectedBytes = prompt.reduce( + (total, message) => + total + prepareRequestObservation({ prompt: [message] }).observation.segments[0]!.bytes, + 0, ); - assert.notEqual(anthropic.requestHash, openai.requestHash); - }); - - test('excludes provider metadata nested in prompt messages and parts', () => { - const capture = (prompt: unknown[], tools: unknown[] = []) => - requestShape.capturePreparedProviderRequest({ - providerId: 'provider', - modelId: 'model', - messages: prompt, - tools, - requestPayload: { prompt, tools }, - }); - const sharedPrompt = [ - { - role: 'assistant', - content: [{ type: 'reasoning', text: 'analysis' }], - }, - ]; - const anthropicPrompt = [ - { - role: 'assistant', - providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, - content: [ - { - type: 'reasoning', - text: 'analysis', - providerOptions: { anthropic: { signature: 'signed-reasoning' } }, - }, - ], - }, - ]; + assert.ok(material.observation.segments.length <= 256); assert.equal( - capture(anthropicPrompt).requestPayloadWithoutProviderOptionsHash, - capture(sharedPrompt).requestPayloadWithoutProviderOptionsHash, + material.observation.segments.reduce((total, segment) => total + segment.bytes, 0), + expectedBytes, ); - - const sharedToolPrompt = [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-1', - toolName: 'Inspect', - output: { type: 'content', value: [{ type: 'text', text: 'done' }] }, - }, - ], - }, - ]; - const providerToolPrompt = [ - { - ...sharedToolPrompt[0], - providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, - content: [ - { - ...sharedToolPrompt[0]!.content[0], - providerOptions: { anthropic: { toolUseId: 'provider-call-1' } }, - output: { - type: 'content', - providerOptions: { anthropic: { resultId: 'provider-result-1' } }, - value: [ - { - type: 'text', - text: 'done', - providerOptions: { anthropic: { blockId: 'provider-block-1' } }, - }, - ], - }, - }, - ], - }, - ]; - const sharedTools = [{ type: 'function', name: 'Inspect', inputSchema: { type: 'object' } }]; - const providerTools = [ - { - ...sharedTools[0], - providerOptions: { anthropic: { deferLoading: true } }, - }, - ]; - assert.equal( - capture(providerToolPrompt, providerTools).requestPayloadWithoutProviderOptionsHash, - capture(sharedToolPrompt, sharedTools).requestPayloadWithoutProviderOptionsHash, + material.observation.segments.reduce( + (total, segment) => total + (segment.representedSegments ?? 1), + 0, + ), + prompt.length, ); + assert.equal(material.observation.segments.at(-1)?.comparison, 'opaque'); }); - test('normalizes provider-local tool and approval bookkeeping', () => { - const hash = (prompt: unknown[]) => - requestShape.capturePreparedProviderRequest({ - providerId: 'provider', - modelId: 'model', - messages: prompt, - tools: [], - requestPayload: { prompt, tools: [] }, - }).requestPayloadWithoutProviderOptionsHash; - const prompt = (suffix: string, approved: boolean) => [ - { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: `call-${suffix}`, - toolName: 'Inspect', - input: { path: 'README.md' }, - providerExecuted: suffix === 'anthropic', - }, - { - type: 'tool-approval-request', - approvalId: `approval-${suffix}`, - toolCallId: `call-${suffix}`, - isAutomatic: suffix === 'anthropic', - signature: `signature-${suffix}`, - }, - ], - }, - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: `call-${suffix}`, - toolName: 'Inspect', - output: { type: 'text', value: 'done' }, - }, - { - type: 'tool-approval-response', - approvalId: `approval-${suffix}`, - approved, - providerExecuted: suffix === 'anthropic', - }, - ], - }, - ]; - - assert.equal(hash(prompt('anthropic', true)), hash(prompt('openai', true))); - assert.notEqual(hash(prompt('anthropic', true)), hash(prompt('openai', false))); - }); - - test('preserves same-named fields inside user data and tool schemas', () => { - const hash = (prompt: unknown[], tools: unknown[] = []) => - requestShape.capturePreparedProviderRequest({ - providerId: 'provider', - modelId: 'model', - messages: prompt, - tools, - requestPayload: { prompt, tools }, - }).requestPayloadWithoutProviderOptionsHash; - const toolCall = (value: string) => [ - { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'call-1', - toolName: 'Inspect', - input: { providerOptions: value }, - }, - ], - }, - ]; - const toolResult = (value: string) => [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-1', - toolName: 'Inspect', - output: { type: 'json', value: { providerOptions: value } }, - }, - ], - }, - ]; - const tool = (description: string) => [ - { - type: 'function', - name: 'Inspect', - inputSchema: { - type: 'object', - properties: { providerOptions: { type: 'string', description } }, - }, - }, - ]; - - assert.notEqual(hash(toolCall('alpha')), hash(toolCall('bravo'))); - assert.notEqual(hash(toolResult('alpha')), hash(toolResult('bravo'))); - assert.notEqual(hash([], tool('alpha')), hash([], tool('bravo'))); - }); - - test('finds the first changed cacheable segment by exact content hash', () => { - const capture = requestShape.capturePreparedProviderRequest; - const findFirstChanged = Reflect.get(requestShape, 'findFirstChangedCacheableSegment') as - | (( - current: ReturnType, - prior: ReturnType, - ) => { kind: string; index: number; role?: string } | undefined) - | undefined; - assert.equal(typeof findFirstChanged, 'function'); - - const prior = capture({ - providerId: 'openai', - modelId: 'gpt-test', - instructions: 'system', - messages: [{ role: 'user', content: 'alpha' }], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions: { openai: { reasoningEffort: 'low' } }, - }); - const changedMessage = capture({ - providerId: 'openai', - modelId: 'gpt-test', - instructions: 'system', - messages: [{ role: 'user', content: 'bravo' }], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions: { openai: { reasoningEffort: 'low' } }, - }); - assert.deepEqual(findFirstChanged!(changedMessage, prior), { - kind: 'message', - index: 0, - role: 'user', - }); - - const onlyOptionsChanged = capture({ - providerId: 'openai', - modelId: 'gpt-test', - instructions: 'system', - messages: [{ role: 'user', content: 'alpha' }], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions: { openai: { reasoningEffort: 'high' } }, + test('records semantic segments in provider-prefix order and labels only tools', () => { + const material = prepareRequestObservation({ + prompt: [ + { role: 'system', content: 'system' }, + { role: 'user', content: 'hello' }, + ], + tools: [{ name: 'Bash', inputSchema: { type: 'object' } }, { inputSchema: {} }], + providerOptions: { anthropic: { thinking: { type: 'enabled' } } }, }); - assert.equal(findFirstChanged!(onlyOptionsChanged, prior), undefined); - const appendedMessage = capture({ - providerId: 'openai', - modelId: 'gpt-test', - instructions: 'system', - messages: [ - { role: 'user', content: 'alpha' }, - { role: 'assistant', content: 'done' }, + assert.deepEqual( + material.observation.segments.map(({ kind, index, cacheable, role, label }) => ({ + kind, + index, + cacheable, + ...(role ? { role } : {}), + ...(label ? { label } : {}), + })), + [ + { kind: 'tool_schema', index: 0, cacheable: true, label: 'Bash' }, + { kind: 'tool_schema', index: 1, cacheable: true }, + { kind: 'system_prompt', index: 0, cacheable: true }, + { kind: 'message', index: 0, cacheable: true, role: 'user' }, + { kind: 'provider_options', index: 0, cacheable: false }, ], - tools: [{ name: 'Read', inputSchema: { type: 'object' } }], - providerOptions: { openai: { reasoningEffort: 'low' } }, - }); - assert.deepEqual(findFirstChanged!(appendedMessage, prior), { - kind: 'message', - index: 1, - role: 'assistant', - }); + ); }); }); diff --git a/packages/runtime/src/__tests__/semantic-prefix-continuity.test.ts b/packages/runtime/src/__tests__/semantic-prefix-continuity.test.ts new file mode 100644 index 0000000000..b077efa1c2 --- /dev/null +++ b/packages/runtime/src/__tests__/semantic-prefix-continuity.test.ts @@ -0,0 +1,494 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { + ModelCallAttempt, + PreparedRequestObservation, + PreparedRequestObservationSegment, +} from '@maka/core/model-call-attempt'; +import { + deriveAttemptSemanticPrefixContinuity, + deriveSemanticPrefixContinuity, + resolveRequestPrefixPredecessor, +} from '../semantic-prefix-continuity.js'; + +test('reports preserved when the next request only appends model-facing segments', () => { + const previous = observation([ + segment('system_prompt', 0, 'system'), + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + const current = observation([...previous.segments, segment('message', 2, 'user-2', 'user')]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current, + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'preserved', + previousSegmentCount: 3, + preservedSegmentCount: 3, + }, + ); +}); + +test('reports the first model-facing segment whose prior content changed', () => { + const previous = observation([ + segment('system_prompt', 0, 'system'), + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + const current = observation([ + segment('system_prompt', 0, 'system'), + segment('message', 0, 'changed-user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current, + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'diverged', + previousSegmentCount: 3, + preservedSegmentCount: 1, + firstDivergentSegment: { kind: 'message', index: 0, role: 'user' }, + }, + ); +}); + +test('reports the deleted prior segment as the first divergence', () => { + const previous = observation([ + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([segment('message', 0, 'user-1', 'user')]), + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'diverged', + previousSegmentCount: 2, + preservedSegmentCount: 1, + firstDivergentSegment: { kind: 'message', index: 1, role: 'assistant' }, + }, + ); +}); + +test('reports an inserted segment at the insertion boundary', () => { + const previous = observation([ + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([ + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'inserted', 'user'), + segment('message', 2, 'assistant-1', 'assistant'), + ]), + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'diverged', + previousSegmentCount: 2, + preservedSegmentCount: 1, + firstDivergentSegment: { kind: 'message', index: 1, role: 'user' }, + }, + ); +}); + +test('reports the first reordered segment as the divergence', () => { + const previous = observation([ + segment('message', 0, 'user-1', 'user'), + segment('message', 1, 'assistant-1', 'assistant'), + ]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([ + segment('message', 0, 'assistant-1', 'assistant'), + segment('message', 1, 'user-1', 'user'), + ]), + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'diverged', + previousSegmentCount: 2, + preservedSegmentCount: 0, + firstDivergentSegment: { kind: 'message', index: 0, role: 'assistant' }, + }, + ); +}); + +test('reports unknown instead of preserved across an opaque segment', () => { + const previous = observation([ + segment('system_prompt', 0, 'system'), + opaqueSegment('message', 0, 'redacted-summary', 4, 'assistant'), + ]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: previous, + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'unknown', + previousSegmentCount: 5, + preservedSegmentCount: 1, + }, + ); +}); + +test('reports a known segment-structure change before opaque content', () => { + const previous = observation([opaqueSegment('message', 0, 'redacted-before', 1, 'assistant')]); + const current = observation([opaqueSegment('system_prompt', 0, 'redacted-after', 1)]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current, + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'diverged', + previousSegmentCount: 1, + preservedSegmentCount: 0, + firstDivergentSegment: { kind: 'system_prompt', index: 0 }, + }, + ); +}); + +test('does not let an appended opaque tail hide a fully preserved prefix', () => { + const previous = observation([segment('message', 0, 'user-1', 'user')]); + + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([ + ...previous.segments, + opaqueSegment('message', 1, 'bounded-tail', 3, 'assistant'), + ]), + predecessor: { kind: 'observation', observation: previous }, + }), + { + status: 'preserved', + previousSegmentCount: 1, + preservedSegmentCount: 1, + }, + ); +}); + +test('reports no predecessor for the first request in a causal lane', () => { + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([segment('message', 0, 'user-1', 'user')]), + predecessor: { kind: 'none' }, + }), + { + status: 'no_predecessor', + previousSegmentCount: 0, + preservedSegmentCount: 0, + }, + ); +}); + +test('reports unavailable when a dispatched predecessor has no observation', () => { + assert.deepEqual( + deriveSemanticPrefixContinuity({ + current: observation([segment('message', 0, 'user-2', 'user')]), + predecessor: { kind: 'unavailable' }, + }), + { + status: 'unavailable', + previousSegmentCount: 0, + preservedSegmentCount: 0, + }, + ); +}); + +test('does not compare equal content across a different provider domain', () => { + const request = observation([segment('message', 0, 'user-1', 'user')]); + + assert.deepEqual( + deriveAttemptSemanticPrefixContinuity( + modelAttempt({ attemptId: 'current', requestObservation: request }), + { + kind: 'attempt', + attempt: modelAttempt({ + attemptId: 'previous', + providerId: 'openai', + requestObservation: request, + }), + }, + ), + { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }, + ); +}); + +test('does not compare equal content across different effective request domains', () => { + const request = observation([segment('message', 0, 'user-1', 'user')]); + + assert.deepEqual( + deriveAttemptSemanticPrefixContinuity( + modelAttempt({ + attemptId: 'current', + requestPrefixDomain: digest('connection-account-b'), + requestObservation: request, + }), + { + kind: 'attempt', + attempt: modelAttempt({ + attemptId: 'previous', + requestPrefixDomain: digest('connection-account-a'), + requestObservation: request, + }), + }, + ), + { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }, + ); +}); + +test('does not compare a current domain-aware request to a historical unqualified attempt', () => { + const request = observation([segment('message', 0, 'user-1', 'user')]); + + assert.deepEqual( + deriveAttemptSemanticPrefixContinuity( + modelAttempt({ + attemptId: 'current', + requestPrefixDomain: digest('connection-account-a'), + requestObservation: request, + }), + { + kind: 'attempt', + attempt: modelAttempt({ + attemptId: 'previous', + requestObservation: request, + }), + }, + ), + { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }, + ); +}); + +test('selects the predecessor from the durable previous-root-turn authority', async () => { + const previous = modelAttempt({ + attemptId: 'previous', + runId: 'run-1', + turnId: 'turn-1', + requestObservation: observation([segment('message', 0, 'user-1', 'user')]), + }); + const current = modelAttempt({ + attemptId: 'current', + runId: 'run-2', + turnId: 'turn-2', + requestObservation: observation([segment('message', 0, 'user-1', 'user')]), + }); + + const resolved = await resolveRequestPrefixPredecessor({ + current, + lineage: {}, + store: { + readRootTurnAdmission: async (_sessionId, turnId) => + turnId === 'turn-2' ? { runId: 'run-2', previousRootTurnId: 'turn-1' } : undefined, + listSessionRuns: async () => [{ runId: 'run-1', turnId: 'turn-1' }], + readEvents: async () => [attemptEvent(previous)], + }, + }); + + assert.equal(resolved.kind, 'attempt'); + if (resolved.kind === 'attempt') assert.equal(resolved.attempt.attemptId, 'previous'); +}); + +test('selects the unique durable continuation tip of the previous root turn', async () => { + const source = modelAttempt({ + attemptId: 'source-attempt', + runId: 'source-run', + turnId: 'turn-1', + }); + const continuation = modelAttempt({ + attemptId: 'continuation-attempt', + runId: 'continuation-run', + turnId: 'turn-1', + }); + const current = modelAttempt({ + attemptId: 'current', + runId: 'current-run', + turnId: 'turn-2', + }); + + const resolved = await resolveRequestPrefixPredecessor({ + current, + lineage: {}, + store: { + readRootTurnAdmission: async () => ({ + runId: 'current-run', + previousRootTurnId: 'turn-1', + }), + listSessionRuns: async () => [ + { runId: 'source-run', turnId: 'turn-1' }, + { + runId: 'continuation-run', + turnId: 'turn-1', + parentRunId: 'source-run', + continuationSource: { kind: 'safe_boundary' }, + }, + ], + readEvents: async (_sessionId, runId) => + runId === 'source-run' ? [attemptEvent(source)] : [attemptEvent(continuation)], + }, + }); + + assert.equal(resolved.kind, 'attempt'); + if (resolved.kind === 'attempt') { + assert.equal(resolved.attempt.attemptId, 'continuation-attempt'); + } +}); + +test('a retry selects only the previous physical attempt of its logical call', async () => { + const previous = modelAttempt({ attemptId: 'attempt-0', attempt: 0 }); + const unrelated = modelAttempt({ + logicalCallId: 'other-call', + attemptId: 'other-attempt', + attempt: 0, + }); + const current = modelAttempt({ attemptId: 'attempt-1', attempt: 1 }); + + const resolved = await resolveRequestPrefixPredecessor({ + current, + lineage: {}, + store: { + listSessionRuns: async () => [], + readEvents: async () => [attemptEvent(unrelated), attemptEvent(previous)], + }, + }); + + assert.equal(resolved.kind, 'attempt'); + if (resolved.kind === 'attempt') assert.equal(resolved.attempt.attemptId, 'attempt-0'); +}); + +test('an overlapping turn never skips its unavailable durable predecessor', async () => { + const older = modelAttempt({ attemptId: 'older', runId: 'run-1', turnId: 'turn-1' }); + const current = modelAttempt({ attemptId: 'current', runId: 'run-3', turnId: 'turn-3' }); + + const resolved = await resolveRequestPrefixPredecessor({ + current, + lineage: {}, + store: { + readRootTurnAdmission: async () => ({ + runId: 'run-3', + previousRootTurnId: 'turn-2', + }), + listSessionRuns: async () => [ + { runId: 'run-1', turnId: 'turn-1' }, + { runId: 'run-2', turnId: 'turn-2' }, + ], + readEvents: async (_sessionId, runId) => (runId === 'run-1' ? [attemptEvent(older)] : []), + }, + }); + + assert.deepEqual(resolved, { kind: 'unavailable' }); +}); + +function observation(segments: PreparedRequestObservationSegment[]): PreparedRequestObservation { + return { + schemaVersion: 1, + digest: digest(`request:${segments.map((item) => item.digest).join(',')}`), + bytes: segments.reduce((total, item) => total + item.bytes, 0), + segments, + }; +} + +function segment( + kind: PreparedRequestObservationSegment['kind'], + index: number, + identity: string, + role?: string, +): PreparedRequestObservationSegment { + return { + kind, + index, + cacheable: kind !== 'provider_options', + comparison: 'exact', + digest: digest(identity), + bytes: identity.length, + ...(role ? { role } : {}), + }; +} + +function opaqueSegment( + kind: PreparedRequestObservationSegment['kind'], + index: number, + identity: string, + representedSegments: number, + role?: string, +): PreparedRequestObservationSegment { + return { + ...segment(kind, index, identity, role), + comparison: 'opaque', + representedSegments, + }; +} + +function digest(identity: string): `sha256:${string}` { + return `sha256:${Buffer.from(identity).toString('hex').padEnd(64, '0').slice(0, 64)}`; +} + +function modelAttempt(overrides: Partial = {}): ModelCallAttempt { + return { + schemaVersion: 1, + logicalCallId: 'call-1', + attemptId: 'attempt-1', + traceId: 'trace-1', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + step: 0, + attempt: 0, + callKind: 'main', + connectionSlug: 'primary', + providerId: 'anthropic', + modelId: 'claude', + requestPrefixDomain: digest('default-request-domain'), + startedAt: 1, + completedAt: 2, + latencyMs: 1, + status: 'completed', + usageBasis: 'missing', + costBasis: 'unpriced', + ...overrides, + }; +} + +function attemptEvent(attempt: ModelCallAttempt) { + return { + type: 'model_call_attempt_recorded', + id: attempt.attemptId, + sessionId: attempt.sessionId, + runId: attempt.runId, + turnId: attempt.turnId, + ts: attempt.completedAt, + data: { ...attempt }, + }; +} diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 880a44922e..6098d98d89 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -10936,132 +10936,6 @@ describe('SessionManager permission mode updates', () => { expect(JSON.stringify(events).includes('sk-live-secret-token-value')).toBe(false); }); - test('required capture and later attempt still write after an attempt append fails', async () => { - const store = new MemorySessionStore(); - const attemptFailureRecorded = makeGate(); - const captureOutcomes: string[] = []; - let failAttemptOnce = true; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type === 'provider_request_attempt_recorded' && failAttemptOnce) { - failAttemptOnce = false; - throw new Error('diagnostic attempt append failed'); - } - if (event.type === 'trace_write_failed') attemptFailureRecorded.release(); - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new ProviderCaptureAfterAttemptFailureBackend( - ctx, - attemptFailureRecorded.promise, - captureOutcomes, - ), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(12_762), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - - expect(captureOutcomes).toEqual(['fulfilled']); - const [run] = await runStore.listSessionRuns(session.id); - const events = await runStore.readEvents(session.id, run!.runId); - expect(events.some((event) => event.type === 'provider_request_captured')).toBe(true); - expect(events.some((event) => event.id === 'attempt-2')).toBe(true); - }); - - test('carries a provider attempt failure latch into the terminal run header', async () => { - const store = new MemorySessionStore(); - let failAttemptAppend = true; - let failFailureLatch = true; - let failFailureSentinel = true; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunUpdate: async (_sessionId, _runId, patch) => { - if (patch.traceWriteError && failFailureLatch) { - failFailureLatch = false; - throw new Error('trace failure latch update failed'); - } - }, - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type === 'provider_request_attempt_recorded' && failAttemptAppend) { - failAttemptAppend = false; - throw new Error('provider attempt append failed'); - } - if (event.type === 'trace_write_failed' && failFailureSentinel) { - failFailureSentinel = false; - throw new Error('trace failure sentinel append failed'); - } - }, - }); - const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new ProviderRequestTraceBackend(ctx)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(12_763), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - - const [run] = await runStore.listSessionRuns(session.id); - expect(run?.status).toBe('completed'); - expect(run?.traceWriteError).toMatch( - /append provider request attempt: provider attempt append failed/, - ); - const events = await runStore.readEvents(session.id, run!.runId); - expect(events.some((event) => event.type === 'trace_write_failed')).toBe(false); - }); - - test('finalizes the run when a required provider capture append fails', async () => { - const store = new MemorySessionStore(); - let providerDispatches = 0; - let failCaptureOnce = true; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type === 'provider_request_captured' && failCaptureOnce) { - failCaptureOnce = false; - throw new Error('required capture append failed'); - } - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new ProviderCaptureGateBackend(ctx, () => (providerDispatches += 1)), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(12_764), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })).catch( - () => {}, - ); - - expect(providerDispatches).toBe(0); - const [run] = await runStore.listSessionRuns(session.id); - expect(run?.status).toBe('failed'); - expect(run?.completedAt).toBeDefined(); - }); - test('history compact cleanup includes continuation events without including child agent events', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -13430,192 +13304,6 @@ class TraceBackend implements AgentBackend { async dispose(): Promise {} } -class ProviderRequestTraceBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor(private readonly ctx: BackendFactoryContext) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - await this.ctx.recordProviderRequestCapture?.({ - schemaVersion: 2, - traceId: 'provider-trace-1', - captureId: 'capture-1', - turnId: input.turnId, - step: 0, - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:request', - requestPayloadWithoutProviderOptionsHash: 'sha256:shared-request', - requestBytes: 100, - segments: [], - artifactId: 'artifact-capture', - }); - await this.ctx.recordProviderRequestAttempt?.({ - traceId: 'provider-trace-1', - attemptId: 'attempt-1', - turnId: input.turnId, - step: 0, - attempt: 1, - captureId: 'capture-1', - captureArtifactId: 'artifact-capture', - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:request', - requestBytes: 100, - segments: Array.from({ length: 75 }, (_, index) => ({ - kind: 'message' as const, - index, - cacheable: true, - hash: `sha256:${index}`, - bytes: 1, - })), - startedAt: 1, - completedAt: 2, - status: 'completed', - finishReason: 'stop', - latencyMs: 1, - }); - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 3, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - -class ProviderCaptureGateBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly dispatch: () => void, - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - await this.ctx.recordProviderRequestCapture?.({ - schemaVersion: 2, - traceId: 'provider-trace-gated', - captureId: 'capture-gated', - turnId: input.turnId, - step: 0, - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:gated', - requestPayloadWithoutProviderOptionsHash: 'sha256:shared-gated', - requestBytes: 100, - segments: [], - artifactId: 'artifact-gated', - }); - this.dispatch(); - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 3, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - -class ProviderCaptureAfterAttemptFailureBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly attemptFailureRecorded: Promise, - private readonly captureOutcomes: string[], - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - await this.ctx.recordProviderRequestAttempt?.({ - traceId: 'provider-trace-1', - attemptId: 'attempt-1', - turnId: input.turnId, - step: 0, - attempt: 1, - captureId: 'capture-1', - captureArtifactId: 'artifact-capture-1', - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:request-1', - requestBytes: 100, - segments: [], - startedAt: 1, - completedAt: 2, - status: 'completed', - latencyMs: 1, - }); - await this.attemptFailureRecorded; - try { - await this.ctx.recordProviderRequestCapture?.({ - schemaVersion: 2, - traceId: 'provider-trace-1', - captureId: 'capture-2', - turnId: input.turnId, - step: 1, - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:request-2', - requestPayloadWithoutProviderOptionsHash: 'sha256:shared-request-2', - requestBytes: 120, - segments: [], - artifactId: 'artifact-capture-2', - }); - this.captureOutcomes.push('fulfilled'); - } catch { - this.captureOutcomes.push('rejected'); - } - await this.ctx.recordProviderRequestAttempt?.({ - traceId: 'provider-trace-1', - attemptId: 'attempt-2', - turnId: input.turnId, - step: 1, - attempt: 1, - captureId: 'capture-2', - captureArtifactId: 'artifact-capture-2', - providerId: 'fake', - modelId: 'fake-model', - requestHash: 'sha256:request-2', - requestBytes: 120, - segments: [], - startedAt: 2, - completedAt: 3, - status: 'completed', - latencyMs: 1, - }); - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 3, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - class HistoryCompactCheckpointBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index ba68ad62c5..b8c51edb86 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -85,12 +85,14 @@ import { type RuntimeContinuationStartAdmissionProof, } from './runtime-continuation-admission.js'; import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; -import type { - ProviderRequestAttemptRecord, - ProviderRequestCaptureLedgerRecord, -} from './provider-request-telemetry.js'; import { materializeRuntimeEventTranscriptProjection } from './runtime-ledger-repair.js'; import { cloneAndFreezeRuntimeSnapshot } from './runtime-snapshot.js'; +import { + deriveAttemptSemanticPrefixContinuity, + resolveRequestPrefixPredecessor, + type AttemptSemanticPrefixPredecessor, +} from './semantic-prefix-continuity.js'; +import { withLatestContextRequestPrefix } from './latest-context-snapshot.js'; export interface AgentRunActiveSession { sessionId: string; @@ -441,46 +443,6 @@ export class AgentRun { }); } - recordProviderRequestCapture(capture: ProviderRequestCaptureLedgerRecord): Promise { - if (!this.input.runStore) return Promise.reject(new Error('AgentRun store is not configured')); - return this.enqueueRequiredRunStoreWrite('append provider request capture', async () => { - const { - schemaVersion, - serializedRequest: _serializedRequest, - ...data - } = capture as ProviderRequestCaptureLedgerRecord & { serializedRequest?: string }; - await this.input.runStore?.appendEvent( - this.sessionId, - this.runId, - { - type: 'provider_request_captured', - id: capture.captureId, - runId: this.runId, - sessionId: this.sessionId, - turnId: capture.turnId, - ts: this.input.now(), - data: { schemaVersion, ...data }, - }, - { durable: true }, - ); - }); - } - - recordProviderRequestAttempt(attempt: ProviderRequestAttemptRecord): void { - if (!this.input.runStore) return; - this.enqueueBestEffortProviderAttempt('append provider request attempt', async () => { - await this.input.runStore?.appendEvent(this.sessionId, this.runId, { - type: 'provider_request_attempt_recorded', - id: attempt.attemptId, - runId: this.runId, - sessionId: this.sessionId, - turnId: attempt.turnId, - ts: attempt.completedAt, - data: { ...attempt }, - }); - }); - } - /** * Canonical accounting record for one physical provider request (#1679). * @@ -499,22 +461,54 @@ export class AgentRun { const { attempt, latestContext } = commit; if (!this.input.runStore) return Promise.resolve(); return this.enqueueRequiredRunStoreWrite('append model call attempt', async () => { - await this.input.runStore?.appendEvent( + const runStore = this.input.runStore; + if (!runStore) return; + let recordedAttempt = attempt; + let recordedLatestContext = latestContext; + if (attempt.callKind === 'main') { + let predecessor: AttemptSemanticPrefixPredecessor; + try { + predecessor = await resolveRequestPrefixPredecessor({ + current: attempt, + lineage: this.lineage, + store: runStore, + }); + } catch { + predecessor = { kind: 'unavailable' }; + } + recordedAttempt = { + ...attempt, + requestPrefixPredecessor: + predecessor.kind === 'attempt' + ? { kind: 'attempt', attemptId: predecessor.attempt.attemptId } + : { kind: predecessor.kind }, + }; + if (recordedLatestContext) { + recordedLatestContext = withLatestContextRequestPrefix( + recordedLatestContext, + deriveAttemptSemanticPrefixContinuity(recordedAttempt, predecessor), + ); + } + } + await runStore.appendEvent( this.sessionId, this.runId, { type: MODEL_CALL_ATTEMPT_EVENT_TYPE, - id: attempt.attemptId, + id: recordedAttempt.attemptId, runId: this.runId, sessionId: this.sessionId, - turnId: attempt.turnId, - ts: attempt.completedAt, - data: { ...attempt }, + turnId: recordedAttempt.turnId, + ts: recordedAttempt.completedAt, + data: { ...recordedAttempt }, }, // 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 } : {}) }, + { + durable: true, + ...(recordedLatestContext ? { latestContext: recordedLatestContext } : {}), + }, ); }); } @@ -1577,19 +1571,6 @@ export class AgentRun { return next; } - /** - * Each physical provider request gets its own best-effort diagnostic row. - * One failed attempt append must not suppress later attempts or poison the - * general AgentRun store latch; a required capture independently gates every - * provider dispatch. - */ - private enqueueBestEffortProviderAttempt(label: string, operation: () => Promise): void { - const next = this.traceQueue - .then(operation, operation) - .catch((error) => this.enqueueTraceWriteFailure(error, label)); - this.traceQueue = next.catch(() => {}); - } - /** * Serialize a required Run-store write without consulting the best-effort * latch. A successful required write proves the store is available again; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 2364129d91..a48ad4edc6 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -106,7 +106,7 @@ import type { PricingConfig, ToolInvocationRecord, } from '@maka/core/usage-stats/types'; -import type { ContextBudgetDiagnostic, PromptSegmentEstimate } from '@maka/core/usage-stats/types'; +import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; import type { JSONValue, ModelFinishReason, @@ -217,17 +217,12 @@ import { type RuntimeEventModelReplayPlan, type RuntimeEventReplayFallbackGate, } from './model-history.js'; -import { - computeRequestShapeDiagnostic, - toolSchemaCharsForDiagnostics, - type RequestShapeDiagnostic, -} from './request-shape.js'; +import { toolSchemaCharsForDiagnostics } from './request-shape.js'; import type { ModelCallAttempt, ModelCallKind } from '@maka/core/model-call-attempt'; import { ProviderRequestTracker, type ModelCallAccountingInput, - type ProviderRequestAttemptRecord, - type ProviderRequestCaptureRecord, + type PreparedRequestArtifactInput, type ProviderRequestUsage, type ResolvedModelCallCost, } from './provider-request-telemetry.js'; @@ -256,7 +251,6 @@ import { import { applyRuntimeEventContextBudget, buildContextBudgetDiagnosticShell, - buildPromptSegmentEstimates, estimateRuntimeEventsTokens, mergeContextBudgetDiagnostic, mergeContextBudgetDiagnosticPatches, @@ -757,25 +751,18 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { readChildAgentOutput?: ToolRuntimeInput['readChildAgentOutput']; /** Optional diagnostic trace hook for explaining a runtime turn without changing renderer events. */ recordRunTrace?: RunTraceRecorder; - /** - * Durable prepared-request capture boundary. When configured, rejection - * prevents the corresponding provider request from being dispatched. - */ - recordProviderRequestCapture?: ( - capture: ProviderRequestCaptureRecord, + /** Optional private artifact sink for the secret-free prepared request. */ + persistPreparedRequestArtifact?: ( + input: PreparedRequestArtifactInput, ) => Promise<{ artifactId: string }>; - /** Best-effort durable row for one physical provider request attempt. */ - recordProviderRequestAttempt?: (attempt: ProviderRequestAttemptRecord) => void | Promise; - /** - * Canonical metering sink. Separate from `recordProviderRequestAttempt`, which - * stays a diagnostic trace: this one carries the accounting record. - */ /** * 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; + /** Dispatch-owned, secret-free qualifier for main request-prefix comparison. */ + requestPrefixDomain?: ModelCallAttempt['requestPrefixDomain']; /** * Pre-dispatch accounting gate, paired with `recordModelCallAttempt` and read * only when it is present. Throws when the canonical record could not be @@ -1058,12 +1045,6 @@ export class AiSdkBackend implements AgentBackend { * read back as absent. */ private readonly activeTurns = new Set(); - /** - * Request-shape baseline for change attribution. Session-scoped on purpose: - * it compares each provider request against whatever this backend sent last, - * across turns. - */ - private priorRequestShape: RequestShapeDiagnostic | undefined; private readonly compaction: AiSdkCompaction; /** Session-scoped running total, deliberately accumulated across turns. */ private cumulativeUsageCheckpoint: NormalizedAiSdkUsage | undefined; @@ -1076,8 +1057,8 @@ export class AiSdkBackend implements AgentBackend { this.maxSteps = input.maxSteps; this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry; // One resolved options value for every reader: the main call, the - // auxiliary memory-extraction call, and the request-shape diagnostics all - // describe the same request, so they must not disagree on what was sent. + // auxiliary memory-extraction call, and the provider request all use the + // same options value, so they cannot disagree on what was sent. this.resolvedProviderOptions = input.providerOptions ?? buildProviderOptions(input.connection, input.modelId, input.header.thinkingLevel); @@ -1360,7 +1341,7 @@ export class AiSdkBackend implements AgentBackend { // -------------------------------------------------------------------------- async compactHistory(input: BackendCompactHistoryInput): Promise { - return this.compaction.compactHistory(input, this.priorRequestShape?.requestShapeHash); + return this.compaction.compactHistory(input); } // -------------------------------------------------------------------------- @@ -1556,8 +1537,7 @@ export class AiSdkBackend implements AgentBackend { let streamStatus: LlmCallRecord['status'] = 'success'; let streamErrorClass: string | undefined; let runtimeSteps = 0; - let requestShapeForTelemetry: RequestShapeDiagnostic | undefined; - let promptSegmentsForTelemetry: PromptSegmentEstimate[] = []; + let toolAvailabilityForTelemetry: ReturnType = undefined; let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined; let contextCompactedNoteWritten = false; let contextCompactionFailedOpenNoteWritten = false; @@ -1913,78 +1893,19 @@ export class AiSdkBackend implements AgentBackend { ? currentTurnMessages : [...priorReplay.messages, ...currentTurnMessages]; }; - // Diagnostics describe the provider-visible (active) tool subset. A group - // loaded *this* turn expands that subset on later provider requests, - // so the durable cost record is refined against the final active set once - // the stream is consumed (see below). Both computations classify against - // the same pre-turn baseline. The availability runtime builds the tool - // diagnostic from the same per-step active set + schema-char measurement. + // Tool Availability describes the provider-visible (active) subset. A + // group loaded this turn expands that subset on later requests, so the + // terminal trace is refined against the final active set below. contextBudgetForTelemetry = priorReplay.contextBudget; - const priorShapeBaseline = this.priorRequestShape; - const computeTurnDiagnostics = (active: readonly string[]) => { + const computeToolAvailability = (active: readonly string[]) => { const toolSchemaChars = toolSchemaCharsForDiagnostics(providerTools, active); - const toolAvailabilityDiagnostic = plan.diagnostics(active, toolSchemaChars); - return { - promptSegments: buildPromptSegmentEstimates({ - systemPrompt, - toolSchemaChars, - toolCount: active.length, - priorMessages: priorReplay.messages, - priorRuntimeEventCount: priorReplay.runtimeEventCount, - currentUserContent: input.continuation - ? '' - : formatTextWithInlineRefs(input.text, { - ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), - ...(input.quotes !== undefined ? { quotes: input.quotes } : {}), - }), - }), - requestShape: computeRequestShapeDiagnostic( - { - connection: this.input.connection, - modelId: this.input.modelId, - systemPrompt, - providerOptions: this.resolvedProviderOptions, - providerTools, - activeTools: active, - priorMessages: priorReplay.messages, - ...(toolAvailabilityDiagnostic !== undefined - ? { toolAvailability: toolAvailabilityDiagnostic } - : {}), - }, - priorShapeBaseline, - ), - }; - }; - // Publish a diagnostics snapshot to every telemetry sink at once so the - // cost record and prefix baseline describe the same active tool set. A - // same-turn deferred load re-publishes the final snapshot below. - let turnDiagnostics = computeTurnDiagnostics(activeTools); - const publishTurnDiagnostics = (diag: typeof turnDiagnostics): void => { - turnDiagnostics = diag; - promptSegmentsForTelemetry = diag.promptSegments; - requestShapeForTelemetry = diag.requestShape; - this.priorRequestShape = diag.requestShape; + return plan.diagnostics(active, toolSchemaChars); }; - // Step-0 (turn-start) view: literally what the first request carries, so - // the stream-start trace reports it as the prefix actually sent. - publishTurnDiagnostics(turnDiagnostics); + toolAvailabilityForTelemetry = computeToolAvailability(activeTools); trace.modelStreamStarted(activeTools, { - systemPromptHash: turnDiagnostics.requestShape.componentHashes.systemPromptHash, - prefixHash: turnDiagnostics.requestShape.prefixHash, - prefixChangeReason: turnDiagnostics.requestShape.prefixChangeReason, - requestShapeHash: turnDiagnostics.requestShape.requestShapeHash, - requestShapeChangeReason: turnDiagnostics.requestShape.requestShapeChangeReason, - ...(turnDiagnostics.requestShape.toolSchemaChangeReason !== undefined - ? { - toolSchemaChangeReason: turnDiagnostics.requestShape.toolSchemaChangeReason, - } - : {}), - ...(turnDiagnostics.requestShape.toolAvailability !== undefined - ? { - toolAvailability: turnDiagnostics.requestShape.toolAvailability, - } + ...(toolAvailabilityForTelemetry !== undefined + ? { toolAvailability: toolAvailabilityForTelemetry } : {}), - promptSegments: turnDiagnostics.promptSegments, ...(priorReplay.contextBudget ? { contextBudget: priorReplay.contextBudget } : {}), }); @@ -2757,15 +2678,15 @@ export class AiSdkBackend implements AgentBackend { break agentLoop; } - // Refine the durable cost record + prefix baseline against the final - // active set. Deferred loading may add tools, while boundary convergence - // may remove them; comparing membership avoids missing a same-size swap. + // Refine Tool Availability against the final active set. Deferred + // loading may add tools, while boundary convergence may remove them; + // comparing membership avoids missing a same-size swap. const finalActiveTools = currentRepairToolNames(); if ( finalActiveTools.length !== activeTools.length || finalActiveTools.some((name, index) => name !== activeTools[index]) ) { - publishTurnDiagnostics(computeTurnDiagnostics(finalActiveTools)); + toolAvailabilityForTelemetry = computeToolAvailability(finalActiveTools); } // Final usage event. Each adapter result covers one provider request. @@ -2777,7 +2698,6 @@ export class AiSdkBackend implements AgentBackend { const attemptTotalUsage = providerOutcome.usage; tokenUsage = sawUnusableStepUsage ? undefined : (completedStepUsage ?? attemptTotalUsage); if (tokenUsage) { - const systemPromptHash = turnDiagnostics.requestShape.componentHashes.systemPromptHash; tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage); const contextBudgetForUsage = contextBudgetWithRequestProjectionDiagnostics( contextBudgetForTelemetry, @@ -2820,12 +2740,6 @@ export class AiSdkBackend implements AgentBackend { ? { cacheCreation: tokenUsage.cacheWriteInputTokens } : {}), ...(tokenUsageCostUsd !== undefined ? { costUsd: tokenUsageCostUsd } : {}), - systemPromptHash, - prefixHash: turnDiagnostics.requestShape.prefixHash, - prefixChangeReason: turnDiagnostics.requestShape.prefixChangeReason, - requestShapeHash: turnDiagnostics.requestShape.requestShapeHash, - requestShapeChangeReason: turnDiagnostics.requestShape.requestShapeChangeReason, - promptSegments: turnDiagnostics.promptSegments, ...(contextBudgetForUsage ? { contextBudget: contextBudgetForUsage } : {}), ...(contextRemainingForUsage !== undefined ? { contextRemaining: contextRemainingForUsage } @@ -3005,27 +2919,8 @@ export class AiSdkBackend implements AgentBackend { ...(contextBudgetForTelemetry !== undefined ? { contextBudget: contextBudgetForTelemetry } : {}), - ...(promptSegmentsForTelemetry.length > 0 - ? { promptSegments: promptSegmentsForTelemetry } - : {}), - ...(requestShapeForTelemetry !== undefined - ? { - systemPromptHash: requestShapeForTelemetry.componentHashes.systemPromptHash, - prefixHash: requestShapeForTelemetry.prefixHash, - prefixChangeReason: requestShapeForTelemetry.prefixChangeReason, - requestShapeHash: requestShapeForTelemetry.requestShapeHash, - requestShapeChangeReason: requestShapeForTelemetry.requestShapeChangeReason, - ...(requestShapeForTelemetry.toolSchemaChangeReason !== undefined - ? { - toolSchemaChangeReason: requestShapeForTelemetry.toolSchemaChangeReason, - } - : {}), - ...(requestShapeForTelemetry.toolAvailability !== undefined - ? { - toolAvailability: requestShapeForTelemetry.toolAvailability, - } - : {}), - } + ...(toolAvailabilityForTelemetry !== undefined + ? { toolAvailability: toolAvailabilityForTelemetry } : {}), }); queue.close(); @@ -3292,14 +3187,14 @@ export class AiSdkBackend implements AgentBackend { /** * One tracker for one physical provider call kind (#1679). * - * Auxiliary calls get the same capture, attempt, and accounting plumbing the + * Auxiliary calls get the same observation, attempt, and accounting plumbing the * main send uses, built here because the sinks and the current run live on * this backend. Callers receive a ready tracker rather than the ingredients: * a half-wired tracker is what produces records nothing can attribute. * - * Absent only when there is nothing to feed: no capture sink *and* no - * canonical sink. Metering deliberately does not depend on capture — capture - * is a diagnostic, and a deployment that turns it off must still be billed. + * Absent only when there is nothing to feed: no artifact sink, canonical + * sink, or dispatch gate. Metering deliberately does not depend on artifact + * persistence: the observation is created in memory for every tracked call. */ private createProviderRequestTracker(input: { turnId: string; @@ -3313,7 +3208,7 @@ export class AiSdkBackend implements AgentBackend { */ runId: string | undefined; }): ProviderRequestTracker | undefined { - const persistCapture = this.input.recordProviderRequestCapture; + const persistArtifact = this.input.persistPreparedRequestArtifact; const accounting = this.modelCallAccounting(input.callKind, { modelId: input.modelId, ...(input.runId ? { runId: input.runId } : {}), @@ -3330,15 +3225,14 @@ export class AiSdkBackend implements AgentBackend { runId, }) : undefined; - if (!persistCapture && !accounting && !beforeDispatch) return undefined; + if (!persistArtifact && !accounting && !beforeDispatch) return undefined; return new ProviderRequestTracker({ traceId: this.newId(), turnId: input.turnId, contextWindow: resolveSelectedModelContextWindow(this.input.connection, input.modelId), now: this.now, newId: this.newId, - ...(persistCapture ? { persistCapture } : {}), - recordAttempt: this.input.recordProviderRequestAttempt ?? (() => {}), + ...(persistArtifact ? { persistArtifact } : {}), ...(beforeDispatch ? { beforeDispatch } : {}), ...(accounting ? { accounting } : {}), }); @@ -3373,6 +3267,9 @@ export class AiSdkBackend implements AgentBackend { connectionSlug: this.input.connection.slug, providerId: this.input.connection.providerType, callKind, + ...(callKind === 'main' && this.input.requestPrefixDomain !== undefined + ? { requestPrefixDomain: this.input.requestPrefixDomain } + : {}), ...(identity?.historyCompactRoute ? { historyCompactRoute: identity.historyCompactRoute } : {}), @@ -3487,7 +3384,6 @@ export class AiSdkBackend implements AgentBackend { runId: scope.runId, runtimeContext: priorRuntimeContext, }, - this.priorRequestShape?.requestShapeHash, automaticMemorySource ? { runId: automaticMemorySource.runId, diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index f3a6cbdcdf..a969f03bc4 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -45,7 +45,6 @@ export interface HistoryCompactSummaryInput { maxEstimatedTokens: number; charsPerToken: number; }; - requestShapeHashBefore?: string; abortSignal?: AbortSignal; /** * Physical-call tracking for this summarization, built by the backend (#1679). diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 3fa12b2ea0..cba17140a0 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -22,10 +22,10 @@ * from AiSdkBackend (issue #1084, runtime/compaction lane, slice 2). * * Owns the compaction planning and persistence paths that AiSdkBackend's - * Runtime request projection drives. Behavior-neutral collaborator: methods move - * verbatim, turn-scoped state (abortSignal, requestShapeHashBefore) is passed - * per call, and replay/telemetry capabilities that stay on AiSdkBackend are - * injected as host callbacks. + * Runtime request projection drives. Behavior-neutral collaborator: methods + * move verbatim, turn-scoped state (such as abortSignal) is passed per call, + * and replay/telemetry capabilities that stay on AiSdkBackend are injected as + * host callbacks. */ import type { RuntimeEvent } from '@maka/core/runtime-event'; @@ -225,7 +225,6 @@ export class AiSdkCompaction { public async compactHistory( input: Omit & { runId: string | undefined }, - requestShapeHashBefore?: string, automaticMemoryBoundary?: HistoryCompactMemoryExtractionBoundary, ): Promise { const historyCompactAbortController = new AbortController(); @@ -335,7 +334,6 @@ export class AiSdkCompaction { maxEstimatedTokens: policy.maxHistoryEstimatedTokens ?? estimatedTokensBefore, charsPerToken, }, - ...(requestShapeHashBefore ? { requestShapeHashBefore } : {}), abortSignal: historyCompactAbortController.signal, ...(tracker ? { providerRequestTracker: tracker } : {}), }), @@ -451,9 +449,9 @@ export class AiSdkCompaction { historyCompactRoute: this.input.historyCompactRoute, contextBudget: this.input.contextBudget, inputBudget: input.inputBudget, - requestShapeHashBefore: input.requestShapeHashBefore, - previousCheckpointId: input.previousCheckpoint?.checkpointId, + previousCheckpoint: input.previousCheckpoint, foldedRuntimeEvents: input.source.foldedRuntimeEvents, + newlyFoldedRuntimeEvents: input.newlyFoldedRuntimeEvents, }), ); const priorFailure = this.malformedSummaryFailures.get(fingerprint); diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index a38ad111c0..20a5ae3062 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -68,12 +68,10 @@ import { type HistoryCompactionCheckpointReplayFit, } from './history-compaction.js'; -import type { ModelMessage } from './model-protocol.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { CompactionDecisionDiagnostic, ContextBudgetDiagnostic, - PromptSegmentEstimate, } from '@maka/core/usage-stats/types'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; @@ -113,16 +111,6 @@ export interface BudgetedRuntimeContext { historyCompactCheckpoint?: HistoryCompactCheckpoint; } -export interface PromptSegmentInput { - systemPrompt?: string; - toolSchemaChars: number; - toolCount: number; - priorMessages: readonly ModelMessage[]; - priorRuntimeEventCount?: number; - currentUserContent: string; - charsPerToken?: number; -} - export function applyRuntimeEventContextBudget( events: readonly RuntimeEvent[], policy: ContextBudgetPolicy | undefined, @@ -191,67 +179,6 @@ export function applyRuntimeEventContextBudget( }; } -export function buildPromptSegmentEstimates(input: PromptSegmentInput): PromptSegmentEstimate[] { - const charsPerToken = input.charsPerToken ?? 4; - return [ - segment('system_prompt', input.systemPrompt?.length ?? 0, charsPerToken), - { - ...segment('tool_schema', input.toolSchemaChars, charsPerToken), - toolCount: input.toolCount, - }, - { - ...segment('prior_history', estimateModelMessagesChars(input.priorMessages), charsPerToken), - messageCount: input.priorMessages.length, - ...(input.priorRuntimeEventCount !== undefined - ? { eventCount: input.priorRuntimeEventCount } - : {}), - }, - segment('current_user', input.currentUserContent.length, charsPerToken), - ]; -} - -export function estimateModelMessagesChars(messages: readonly ModelMessage[]): number { - return messages.reduce((total, message) => total + estimateModelMessageChars(message), 0); -} - -function estimateModelMessageChars(message: ModelMessage): number { - const raw = message as unknown as { content?: unknown }; - return estimateContentChars(raw.content); -} - -function estimateContentChars(content: unknown): number { - if (typeof content === 'string') return content.length; - if (Array.isArray(content)) { - return content.reduce((total, part) => total + estimatePartChars(part), 0); - } - return stableJsonLength(content); -} - -function estimatePartChars(part: unknown): number { - if (!part || typeof part !== 'object') return stableJsonLength(part); - const value = part as Record; - let total = 0; - for (const key of ['text', 'toolName', 'toolCallId'] as const) { - if (typeof value[key] === 'string') total += value[key].length; - } - for (const key of ['input', 'output'] as const) { - if (value[key] !== undefined) total += stableJsonLength(value[key]); - } - return total; -} - -function segment( - kind: PromptSegmentEstimate['kind'], - chars: number, - charsPerToken: number, -): PromptSegmentEstimate { - return { - kind, - chars, - estimatedTokens: estimateTokens(chars, charsPerToken), - }; -} - // ============================================================================ // Replay ordering + context-budget diagnostic merge helpers. // Relocated from ai-sdk-backend.ts: these are pure functions over diff --git a/packages/runtime/src/context-diagnostics.ts b/packages/runtime/src/context-diagnostics.ts index a8d7cfaf0c..bf918da038 100644 --- a/packages/runtime/src/context-diagnostics.ts +++ b/packages/runtime/src/context-diagnostics.ts @@ -25,6 +25,7 @@ import { } from '@maka/core/agent-run'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; import { + foldPromptComposition, PROVIDER_REQUEST_ATTEMPT_EVENT_TYPE, readPromptCompositionEvent, } from './prompt-composition.js'; @@ -38,6 +39,11 @@ import { validateHistoryCompactCheckpointShape, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; +import { + deriveAttemptSemanticPrefixContinuity, + type AttemptSemanticPrefixPredecessor, + type SemanticPrefixContinuity, +} from './semantic-prefix-continuity.js'; export type ContextDiagnosticsUnavailableReason = 'no_completed_request' | 'trace_unavailable'; @@ -87,18 +93,18 @@ export type ContextDiagnostics = inputTokens?: number; contextWindow?: number; /** - * What the latest request was made of, or absent when the durable - * metering record has no capture to match. + * What the latest request was made of, or absent when its canonical + * attempt has no prepared-request observation. * - * 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). + * Absence is a state a reader must be able to see. Reporting the + * composition of an *older* request under a current heading would be the + * quiet lie this separation exists to prevent, so readers never join an + * independent capture stream (#2323). */ composition?: ContextDiagnosticsComposition; compaction?: ContextDiagnosticsCompaction; + /** Semantic request-prefix continuity, separate from provider cache usage. */ + requestPrefix: SemanticPrefixContinuity; }; export interface ContextDiagnosticsComposition { @@ -122,17 +128,15 @@ type ContextRunStore = Pick< * 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 + * the folded composition of its own observation, 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. + * That sealing is the whole design. The request facts live on one canonical + * attempt; the compaction boundary remains recovery-owned. Reading "the newest + * of each kind" and joining independent histories would recreate the retired + * second authority and let the snapshot's parts drift apart. * * Warm reads are O(1) — one projection row. The ledger scan below is the cold * path, for a session written before this record existed. @@ -142,6 +146,7 @@ export async function readLatestContextDiagnostics( sessionId: string, ): Promise { try { + let replaceProjectionId: string | undefined; if (runStore.readEventProjection) { // Three states, three answers. `undefined` is an uninitialized // projection — nothing has been decided about this session, so the @@ -157,8 +162,9 @@ export async function readLatestContextDiagnostics( if (projected === null) return { status: 'unavailable', reason: 'no_completed_request' }; const snapshot = readLatestContextSnapshot(projected ?? undefined); if (snapshot) return availableFrom(snapshot); + if (projected) replaceProjectionId = projected.id; } - return await rebuildContextFromLedger(runStore, sessionId); + return await rebuildContextFromLedger(runStore, sessionId, replaceProjectionId); } catch { return { status: 'unavailable', reason: 'trace_unavailable' }; } @@ -167,8 +173,9 @@ export async function readLatestContextDiagnostics( /** * 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 + * A ledger written before sealed snapshots existed can still be reconstructed + * from its canonical attempts (or, for legacy sessions only, provider events), + * 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. @@ -176,6 +183,7 @@ export async function readLatestContextDiagnostics( async function rebuildContextFromLedger( runStore: ContextRunStore, sessionId: string, + replaceProjectionId?: string, ): Promise { const runs = (await runStore.listSessionRuns(sessionId)).filter(isSessionInlineRun); let anchor: MeteringAnchor | undefined; @@ -191,20 +199,20 @@ async function rebuildContextFromLedger( // 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 canonicalAttempts = 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); + const attempt = canonicalAttempt(event); + if (attempt) canonicalAttempts.set(attempt.attemptId, attempt); + const candidate = attempt ? meteringAnchor(attempt) : undefined; 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; @@ -222,12 +230,13 @@ async function rebuildContextFromLedger( // authority for data written since canonical metering shipped. const resolved = anchor ?? (sawCanonicalRecord ? undefined : legacy); if (!resolved) { - await repairLatestContext(runStore, sessionId, null); + await repairLatestContext(runStore, sessionId, null, replaceProjectionId); 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 requestPrefix = resolved.attempt + ? rebuiltRequestPrefix(resolved.attempt, canonicalAttempts) + : unavailableRequestPrefix(); const snapshot: LatestContextSnapshot = { schemaVersion: LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION, attemptId: resolved.attemptId, @@ -239,13 +248,14 @@ async function rebuildContextFromLedger( ? { cacheReadInputTokens: resolved.cacheReadInputTokens } : {}), ...(resolved.contextWindow !== undefined ? { contextWindow: resolved.contextWindow } : {}), - ...(read?.attemptId === resolved.attemptId ? { composition: read.composition } : {}), + ...(resolved.composition ? { composition: resolved.composition } : {}), ...(boundary ? { compaction: contextDiagnosticsCompactionOf(boundary.checkpoint) } : {}), + requestPrefix, }; // 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); + await repairLatestContext(runStore, sessionId, snapshot, replaceProjectionId); return availableFrom(snapshot); } @@ -260,6 +270,7 @@ async function repairLatestContext( runStore: ContextRunStore, sessionId: string, snapshot: LatestContextSnapshot | null, + replaceProjectionId?: string, ): Promise { const repair = runStore.repairEventProjection; if (!repair) return; @@ -279,6 +290,7 @@ async function repairLatestContext( data: snapshot as unknown as Record, } as AgentRunEvent) : null, + replaceProjectionId ? { replaceEventId: replaceProjectionId } : undefined, ) .catch(() => {}); } @@ -301,6 +313,7 @@ function legacyProviderAnchor(event: AgentRunEvent): MeteringAnchor | undefined ) { return undefined; } + const composition = readPromptCompositionEvent(event)?.composition; return { attemptId, providerId, @@ -309,6 +322,7 @@ function legacyProviderAnchor(event: AgentRunEvent): MeteringAnchor | undefined completedAt, ...(typeof data.inputTokens === 'number' ? { inputTokens: data.inputTokens } : {}), ...(typeof data.contextWindow === 'number' ? { contextWindow: data.contextWindow } : {}), + ...(composition ? { composition } : {}), }; } @@ -327,6 +341,7 @@ function availableFrom(snapshot: LatestContextSnapshot): ContextDiagnostics { ...(snapshot.contextWindow !== undefined ? { contextWindow: snapshot.contextWindow } : {}), ...(snapshot.composition ? { composition: snapshot.composition } : {}), ...(snapshot.compaction ? { compaction: snapshot.compaction } : {}), + requestPrefix: snapshot.requestPrefix ?? unavailableRequestPrefix(), }; } @@ -342,6 +357,8 @@ interface MeteringAnchor { inputTokens?: number; cacheReadInputTokens?: number; contextWindow?: number; + composition?: ContextDiagnosticsComposition; + attempt?: ModelCallAttempt; } interface CheckpointCandidate { @@ -351,14 +368,19 @@ interface CheckpointCandidate { } /** Only a completed MAIN call describes the conversation's own context. */ -function meteringAnchor(event: AgentRunEvent): MeteringAnchor | undefined { - let attempt: ModelCallAttempt; +function canonicalAttempt(event: AgentRunEvent): ModelCallAttempt | undefined { try { - attempt = decodeModelCallAttempt(event.data); + return decodeModelCallAttempt(event.data); } catch { return undefined; } +} + +function meteringAnchor(attempt: ModelCallAttempt): MeteringAnchor | undefined { if (attempt.callKind !== 'main' || attempt.status !== 'completed') return undefined; + const composition = attempt.requestObservation + ? foldPromptComposition(attempt.requestObservation.segments) + : undefined; return { attemptId: attempt.attemptId, providerId: attempt.providerId, @@ -370,9 +392,31 @@ function meteringAnchor(event: AgentRunEvent): MeteringAnchor | undefined { ? { cacheReadInputTokens: attempt.cacheReadInputTokens } : {}), ...(attempt.contextWindow !== undefined ? { contextWindow: attempt.contextWindow } : {}), + ...(composition ? { composition } : {}), + attempt, }; } +function rebuiltRequestPrefix( + attempt: ModelCallAttempt, + attempts: ReadonlyMap, +): SemanticPrefixContinuity { + const link = attempt.requestPrefixPredecessor; + let predecessor: AttemptSemanticPrefixPredecessor; + if (!link || link.kind === 'unavailable') predecessor = { kind: 'unavailable' }; + else if (link.kind === 'none') predecessor = { kind: 'none' }; + else { + const resolved = attempts.get(link.attemptId); + predecessor = resolved ? { kind: 'attempt', attempt: resolved } : { kind: 'unavailable' }; + } + return deriveAttemptSemanticPrefixContinuity(attempt, predecessor); +} + +/** Historical projections cannot prove continuity merely by lacking the field. */ +function unavailableRequestPrefix(): SemanticPrefixContinuity { + return { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }; +} + function latestCheckpointBefore( candidates: readonly CheckpointCandidate[], anchor: MeteringAnchor, diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 85867ee812..94c2720452 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -26,6 +26,7 @@ import type { import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { StorageRef, ToolResultContent } from '@maka/core/events'; +import type { MessageContent } from '@maka/core/events'; import { parseAttachmentResourceRef } from '@maka/core/attachments'; import { markPersisted } from '@maka/core/persisted-value'; import type { StoredMessage } from '@maka/core/session'; @@ -231,6 +232,24 @@ export function rewriteConversationCopyMessage( return message; } +/** Rewrites Session-owned attachment references inside durable root input. */ +export function rewriteConversationCopyMessageContent( + content: MessageContent, + references: ConversationCopyArtifactReferenceMap, +): MessageContent { + return { + ...content, + ...(content.attachments + ? { + attachments: content.attachments.map((attachment) => ({ + ...attachment, + ref: rewriteStorageRef(attachment.ref, references), + })), + } + : {}), + }; +} + function rewriteAttachmentResourceRefs( text: string, artifactIds: ReadonlyMap, @@ -679,6 +698,7 @@ function cloneAgentRunEvent( event, { sessionId: ids.sessionId, runId: ids.runId, attemptId: ids.eventId }, references, + operationalEventIds, providerTraceIds, logicalCallIds, ); @@ -788,8 +808,16 @@ function rewriteProviderRequestAttempt( ...data, traceId: requiredMappedId(providerTraceIds, data.traceId, 'provider trace'), attemptId: eventId, - captureId: requiredMappedId(operationalEventIds, data.captureId, 'provider request capture'), - captureArtifactId: rewriteOwnedArtifactId(data.captureArtifactId, references), + ...(data.captureId !== undefined && data.captureArtifactId !== undefined + ? { + captureId: requiredMappedId( + operationalEventIds, + data.captureId, + 'provider request capture', + ), + captureArtifactId: rewriteOwnedArtifactId(data.captureArtifactId, references), + } + : {}), }; } @@ -801,6 +829,7 @@ function rewriteModelCallAttempt( readonly attemptId: string; }, references: ConversationCopyReferenceMap, + operationalEventIds: ReadonlyMap, providerTraceIds: ReadonlyMap, logicalCallIds: ReadonlyMap, ): Record { @@ -830,6 +859,18 @@ function rewriteModelCallAttempt( attemptId: ids.attemptId, logicalCallId: requiredMappedId(logicalCallIds, attempt.logicalCallId, 'logical model call'), traceId: requiredMappedId(providerTraceIds, attempt.traceId, 'provider trace'), + ...(attempt.requestPrefixPredecessor?.kind === 'attempt' + ? { + requestPrefixPredecessor: { + kind: 'attempt', + attemptId: requiredMappedId( + operationalEventIds, + attempt.requestPrefixPredecessor.attemptId, + 'request-prefix predecessor', + ), + }, + } + : {}), ...(attempt.captureArtifactId !== undefined ? { captureArtifactId: rewriteOwnedArtifactId(attempt.captureArtifactId, references) } : {}), @@ -861,16 +902,17 @@ function providerRequestCapture(event: AgentRunEvent): Record & function providerRequestAttempt(event: AgentRunEvent): Record & { readonly traceId: string; readonly attemptId: string; - readonly captureId: string; - readonly captureArtifactId: string; + readonly captureId?: string; + readonly captureArtifactId?: string; } { const data = event.data; + const hasCaptureId = typeof data?.captureId === 'string'; + const hasArtifactId = typeof data?.captureArtifactId === 'string'; if ( !data || data.attemptId !== event.id || typeof data.traceId !== 'string' || - typeof data.captureId !== 'string' || - typeof data.captureArtifactId !== 'string' + hasCaptureId !== hasArtifactId ) { throw new Error(`Cannot copy invalid provider request attempt ${event.id}`); } @@ -878,8 +920,9 @@ function providerRequestAttempt(event: AgentRunEvent): Record & ...data, traceId: data.traceId, attemptId: data.attemptId, - captureId: data.captureId, - captureArtifactId: data.captureArtifactId, + ...(hasCaptureId && hasArtifactId + ? { captureId: data.captureId as string, captureArtifactId: data.captureArtifactId as string } + : {}), }; } diff --git a/packages/runtime/src/latest-context-snapshot.ts b/packages/runtime/src/latest-context-snapshot.ts index 4e070bbb37..ade45510b0 100644 --- a/packages/runtime/src/latest-context-snapshot.ts +++ b/packages/runtime/src/latest-context-snapshot.ts @@ -29,17 +29,15 @@ import type { ContextDiagnosticsComposition, } from './context-diagnostics.js'; import { foldPromptComposition, type SizedRequestSegment } from './prompt-composition.js'; +import type { SemanticPrefixContinuity } from './semantic-prefix-continuity.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. + * The reason this is a record rather than a read-time join: the canonical + * attempt owns the request facts, while the compaction boundary has its own + * recovery lifecycle. Reading independent "latest" records would produce a + * snapshot whose parts describe different moments. * * 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 @@ -48,7 +46,7 @@ import { foldPromptComposition, type SizedRequestSegment } from './prompt-compos * request, never authorises a write, so the last good answer stands. */ -export const LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION = 1 as const; +export const LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION = 2 as const; export interface LatestContextSnapshot { schemaVersion: typeof LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION; @@ -63,13 +61,15 @@ export interface LatestContextSnapshot { /** 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. + * What the prompt was made of. Absent when THIS canonical attempt carries no + * prepared-request observation — 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; + /** Runtime-owned verdict; consumers must not recompute it from observations. */ + requestPrefix?: SemanticPrefixContinuity; } /** @@ -106,6 +106,16 @@ export function latestContextProjectionInput( }; } +export function withLatestContextRequestPrefix( + input: LatestContextProjectionInput, + requestPrefix: SemanticPrefixContinuity, +): LatestContextProjectionInput { + return { + ...input, + snapshot: { ...input.snapshot, requestPrefix }, + }; +} + /** The metered facts a snapshot freezes, as the canonical attempt carries them. */ export interface LatestContextFacts { attemptId: string; @@ -120,10 +130,10 @@ export interface LatestContextFacts { /** * 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. + * Only the current observation-backed schema is trusted. Older projections + * were derived from the retired capture-event path; newer projections may + * change semantics this build cannot safely infer. Either case falls back to the + * canonical ledger and repairs the derived row. */ export function readLatestContextSnapshot( event: Pick | undefined, @@ -133,6 +143,7 @@ export function readLatestContextSnapshot( if (!data || typeof data !== 'object') return undefined; const record = data as Record; if ( + record.schemaVersion !== LATEST_CONTEXT_SNAPSHOT_SCHEMA_VERSION || typeof record.attemptId !== 'string' || record.attemptId.length === 0 || typeof record.providerId !== 'string' || diff --git a/packages/runtime/src/prompt-composition.ts b/packages/runtime/src/prompt-composition.ts index 6a2f98cb39..1355a365fd 100644 --- a/packages/runtime/src/prompt-composition.ts +++ b/packages/runtime/src/prompt-composition.ts @@ -21,34 +21,32 @@ import type { ContextDiagnosticsComposition, ContextDiagnosticsSegment, } from './context-diagnostics.js'; -import type { PreparedRequestSegmentKind } from './request-shape.js'; +import type { PreparedRequestObservationSegmentKind } from '@maka/core/model-call-attempt'; /** * 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. + * A current observation segment satisfies this structurally, while the legacy + * event decoder does not have to invent fields the fold never reads. */ export interface SizedRequestSegment { - kind: PreparedRequestSegmentKind; + kind: PreparedRequestObservationSegmentKind; bytes: number; + representedSegments?: number; label?: string; } /** - * Folds one request's captured segments into "what was this prompt made of" + * Folds one request's observed 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). + * views of one number: composition is measured in **bytes of the observed + * semantic segments** 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 @@ -61,15 +59,23 @@ export function foldPromptComposition( ): ContextDiagnosticsComposition | undefined { if (segments.length === 0) return undefined; - const byKind = new Map(); + const byKind = new Map(); const byTool = new Map(); let unlabelledToolBytes = 0; + let boundedToolCount = 0; + let boundedToolBytes = 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); + if (segment.label !== undefined) { + byTool.set(segment.label, (byTool.get(segment.label) ?? 0) + segment.bytes); + } else if ((segment.representedSegments ?? 1) > 1) { + boundedToolCount += segment.representedSegments ?? 1; + boundedToolBytes += segment.bytes; + } else { + unlabelledToolBytes += segment.bytes; + } } // A zero-byte kind is dropped rather than shown as `≈0`, the same way @@ -93,32 +99,30 @@ export function foldPromptComposition( // 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); + const remainingToolCount = remainder.length + boundedToolCount; + const remainingToolBytes = + remainder.reduce((carry, tool) => carry + tool.bytes, 0) + boundedToolBytes; return { segments: folded, ...(tools.length > 0 ? { tools } : {}), - ...(remainder.length > 0 - ? { remainingTools: { count: remainder.length, bytes: remainingToolBytes } } + ...(remainingToolCount > 0 + ? { remainingTools: { count: remainingToolCount, bytes: remainingToolBytes } } : {}), ...(unlabelledToolBytes > 0 ? { unlabelledToolBytes } : {}), }; } -/** - * The diagnostic append that carries the segments, alongside the durable - * metering record on the same run stream. - */ +/** Historical provider-attempt event retained only for pre-canonical ledgers. */ 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. + * Returns undefined for every event that is not a decodable historical + * provider attempt. Current writers put the observation on the canonical + * ModelCallAttempt instead. Absence is the honest outcome: an unreadable legacy + * record is a composition the reader does not have, not a prompt made of nothing. */ export function readPromptCompositionEvent(event: { readonly type: string; @@ -147,12 +151,21 @@ export function readPromptCompositionEvent(event: { 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 (!KIND_ORDER.includes(kind as PreparedRequestObservationSegmentKind)) return undefined; if (!isNonNegativeInteger(value.bytes)) return undefined; + if ( + value.representedSegments !== undefined && + (!isNonNegativeInteger(value.representedSegments) || value.representedSegments === 0) + ) { + return undefined; + } if (value.label !== undefined && typeof value.label !== 'string') return undefined; return { - kind: kind as PreparedRequestSegmentKind, + kind: kind as PreparedRequestObservationSegmentKind, bytes: value.bytes, + ...(typeof value.representedSegments === 'number' + ? { representedSegments: value.representedSegments } + : {}), ...(typeof value.label === 'string' ? { label: value.label } : {}), }; } @@ -174,7 +187,7 @@ function isNonNegativeInteger(value: unknown): value is number { */ const MAX_TOOL_ROWS = 64; -const KIND_ORDER: readonly PreparedRequestSegmentKind[] = [ +const KIND_ORDER: readonly PreparedRequestObservationSegmentKind[] = [ 'system_prompt', 'tool_schema', 'message', @@ -186,9 +199,10 @@ const KIND_ORDER: readonly PreparedRequestSegmentKind[] = [ * 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', -}; +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 73d0c99160..beb1d2d196 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -23,13 +23,10 @@ import { type ModelCallAttempt, type ModelCallKind, type ModelCallUsageBasis, + type PreparedRequestObservation, } from '@maka/core/model-call-attempt'; import type { PricingConfig } from '@maka/core/usage-stats/types'; -import { - capturePreparedProviderRequest, - type PreparedProviderRequestCapture, - type PreparedRequestSegment, -} from './request-shape.js'; +import { prepareRequestObservation, type PreparedRequestMaterial } from './request-shape.js'; import { rawFinishReasonString } from './model-protocol.js'; import { providerFailureDiagnostic, @@ -68,7 +65,7 @@ export interface ProviderRequestUsageLike { export type ProviderRequestAttemptStatus = 'completed' | 'failed' | 'interrupted' | 'aborted'; -export interface ProviderRequestCaptureRecord extends PreparedProviderRequestCapture { +export interface PreparedRequestArtifactInput extends PreparedRequestMaterial { traceId: string; captureId: string; turnId: string; @@ -77,37 +74,20 @@ export interface ProviderRequestCaptureRecord extends PreparedProviderRequestCap modelId: string; } -export interface ProviderRequestCaptureRef { - captureId: string; +export interface PreparedRequestArtifactRef { artifactId: string; } -export type ProviderRequestCaptureLedgerRecord = Omit< - ProviderRequestCaptureRecord, - 'serializedRequest' -> & { - artifactId: string; -}; - -export interface ProviderRequestAttemptRecord extends ProviderRequestUsage { +interface SettledProviderAttempt extends ProviderRequestUsage { traceId: string; attemptId: string; turnId: string; step: number; attempt: number; - /** - * Present only when a capture sink is wired. The request shape below is - * computed locally and always present; these two are the join keys to the - * persisted artifact, so they are absent when there is nothing to join to. - */ - captureId?: string; captureArtifactId?: string; providerId: string; modelId: string; contextWindow?: number; - requestHash: string; - requestBytes: number; - segments: PreparedRequestSegment[]; startedAt: number; completedAt: number; status: ProviderRequestAttemptStatus; @@ -135,14 +115,10 @@ export interface ProviderRequestTrackerInput { now: () => number; newId: () => string; /** - * Request-body capture sink. Optional because capture is a diagnostic, and - * metering must not depend on one: a deployment with capture switched off - * still settles canonical records, it just has no artifact to join them to. + * Optional private artifact sink. Failure leaves the canonical observation + * intact and the attempt explicitly has no artifact join. */ - persistCapture?: ( - capture: ProviderRequestCaptureRecord, - ) => Promise>; - recordAttempt: (attempt: ProviderRequestAttemptRecord) => void | Promise; + persistArtifact?: (input: PreparedRequestArtifactInput) => Promise; /** * Durable run metadata that must exist before any physical provider call. * Kept outside accounting because a dispatch gate is an execution contract, @@ -177,6 +153,8 @@ export interface ModelCallAccountingInput { */ providerId?: string; callKind: ModelCallKind; + /** Host-qualified effective provider/cache domain for main-lane comparisons. */ + requestPrefixDomain?: ModelCallAttempt['requestPrefixDomain']; historyCompactRoute?: ModelCallAttempt['historyCompactRoute']; /** * Commits the attempt, and with it the derived latest-context row when this @@ -195,13 +173,6 @@ export interface ModelCallAccountingInput { assertReady?: () => void; } -export interface ProviderRequestCaptureRecorderInput { - persistArtifact: ( - capture: ProviderRequestCaptureRecord, - ) => Promise>; - recordLedger: (capture: ProviderRequestCaptureLedgerRecord) => Promise; -} - export interface TrackProviderStreamInput { providerId: string; modelId: string; @@ -302,19 +273,6 @@ export function withProviderStreamTracking(input: { }); } -export function createProviderRequestCaptureRecorder( - input: ProviderRequestCaptureRecorderInput, -): ( - capture: ProviderRequestCaptureRecord, -) => Promise> { - return async (capture) => { - const artifact = await input.persistArtifact(capture); - const { serializedRequest: _serializedRequest, ...metadata } = capture; - await input.recordLedger({ ...metadata, artifactId: artifact.artifactId }); - return artifact; - }; -} - export interface ProviderStreamResult { stream: ReadableStream; request?: unknown; @@ -330,9 +288,9 @@ export interface ProviderGenerateResult { } interface StoredCapture { - capture: ProviderRequestCaptureRecord; - /** Absent when no capture sink is wired: there is no artifact to point at. */ - ref?: ProviderRequestCaptureRef; + material: PreparedRequestMaterial; + /** Absent when artifact persistence is unavailable or failed. */ + artifactId?: string; } const CANONICAL_USAGE_FIELDS = [ @@ -372,7 +330,7 @@ function modelCallUsageFields( export class ProviderRequestTracker { private step = 0; private readonly attemptsByStep = new Map(); - private readonly captures = new Map>(); + private readonly captures = new Map(); /** * One logical call per step. Retries of the same step are further attempts of * that call, not new calls, so they share this id. @@ -395,7 +353,7 @@ export class ProviderRequestTracker { throwIfAbortedBeforeDispatch(input.abortSignal); this.input.accounting?.assertReady?.(); const step = this.step; - const capture = await this.capture(step, input); + const capture = this.capture(step, input); throwIfAbortedBeforeDispatch(input.abortSignal); let sawOutput = false; const attempt = this.beginAttempt(step, capture, input); @@ -464,7 +422,7 @@ export class ProviderRequestTracker { throwIfAbortedBeforeDispatch(input.abortSignal); this.input.accounting?.assertReady?.(); const step = this.step; - const capture = await this.capture(step, input); + const capture = this.capture(step, input); throwIfAbortedBeforeDispatch(input.abortSignal); const attempt = this.beginAttempt(step, capture, input); try { @@ -531,24 +489,16 @@ export class ProviderRequestTracker { const contextWindow = positiveInteger(this.input.contextWindow); const failure = finish?.error !== undefined ? providerFailureDiagnostic(finish.error) : undefined; - const record: ProviderRequestAttemptRecord = { + const record: SettledProviderAttempt = { traceId: this.input.traceId, attemptId, turnId: this.input.turnId, step, attempt, - ...(capture.ref - ? { - captureId: capture.ref.captureId, - captureArtifactId: capture.ref.artifactId, - } - : {}), + ...(capture.artifactId ? { captureArtifactId: capture.artifactId } : {}), providerId: input.providerId, modelId: input.modelId, ...(contextWindow !== undefined ? { contextWindow } : {}), - requestHash: capture.capture.requestHash, - requestBytes: capture.capture.requestBytes, - segments: capture.capture.segments, startedAt, completedAt, status, @@ -559,15 +509,11 @@ export class ProviderRequestTracker { ...(usage ?? {}), }; accountingSettlement = accountingSettlement.then(async () => { - try { - await this.input.recordAttempt(record); - } catch { - // Attempt telemetry is diagnostic. The provider outcome remains authoritative. - } await this.emitModelCallAttempt(record, { logicalCallId, usage, contextWindow, + requestObservation: capture.material.observation, // 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. @@ -606,11 +552,12 @@ export class ProviderRequestTracker { * reported, not raised. */ private async emitModelCallAttempt( - record: ProviderRequestAttemptRecord, + record: SettledProviderAttempt, context: { logicalCallId: string; usage: ProviderRequestUsage | undefined; contextWindow: number | undefined; + requestObservation: PreparedRequestObservation; historyCompactBoundary: ContextDiagnosticsCompaction | undefined; historyCompactRoute: HistoryCompactRoute | undefined; }, @@ -646,10 +593,14 @@ export class ProviderRequestTracker { : {}), providerId: accounting.providerId ?? record.providerId, modelId: record.modelId, + ...(accounting.requestPrefixDomain !== undefined + ? { requestPrefixDomain: accounting.requestPrefixDomain } + : {}), ...(context.contextWindow !== undefined ? { contextWindow: context.contextWindow } : {}), ...(record.captureArtifactId !== undefined ? { captureArtifactId: record.captureArtifactId } : {}), + requestObservation: context.requestObservation, startedAt: record.startedAt, completedAt: record.completedAt, latencyMs: record.latencyMs, @@ -678,7 +629,11 @@ export class ProviderRequestTracker { // 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) + ? latestContextProjectionInput( + attempt, + attempt.requestObservation?.segments, + context.historyCompactBoundary, + ) : undefined; try { @@ -689,41 +644,43 @@ export class ProviderRequestTracker { } } - private async capture( + private capture( step: number, input: TrackProviderStreamInput | TrackProviderGenerateInput, - ): Promise { - const prepared = preparedCapture(input.providerId, input.modelId, input.params); - const key = `${step}:${prepared.requestHash}`; + ): StoredCapture { + const material = preparedRequestMaterial(input.params); + const key = `${step}:${input.providerId}:${input.modelId}:${material.observation.digest}`; const existing = this.captures.get(key); - if (existing) return await existing; - - const persistCapture = this.input.persistCapture; - const pending = (async (): Promise => { - const captureId = this.input.newId(); - const capture: ProviderRequestCaptureRecord = { - ...prepared, + if (existing) return existing; + + const persistArtifact = this.input.persistArtifact; + const capture: StoredCapture = { material }; + this.captures.set(key, capture); + if (persistArtifact) { + const artifactInput: PreparedRequestArtifactInput = { + ...material, traceId: this.input.traceId, - captureId, + captureId: this.input.newId(), turnId: this.input.turnId, step, providerId: input.providerId, modelId: input.modelId, }; - // The request shape on `capture` is computed here and needs no sink. Only - // the artifact join keys depend on one, so without it the attempt still - // carries hash, bytes, and segments — it just points at nothing. - if (!persistCapture) return { capture }; - const persisted = await persistCapture(capture); - return { capture, ref: { captureId, artifactId: persisted.artifactId } }; - })(); - this.captures.set(key, pending); - try { - return await pending; - } catch (error) { - this.captures.delete(key); - throw error; + // Persist the private body in parallel. Dispatch and canonical accounting + // are both allowed to finish without it; the bounded observation already + // lives on the canonical attempt. If persistence wins the race, the + // attempt also carries the optional artifact join. + try { + void persistArtifact(artifactInput) + .then((ref) => { + capture.artifactId = ref.artifactId; + }) + .catch(() => undefined); + } catch { + // A synchronous adapter failure is the same optional-artifact miss. + } } + return capture; } } @@ -733,31 +690,9 @@ function throwIfAbortedBeforeDispatch(signal: AbortSignal | undefined): void { } } -function preparedCapture( - providerId: string, - modelId: string, - params: Record, -): PreparedProviderRequestCapture { +function preparedRequestMaterial(params: Record): PreparedRequestMaterial { const safeParams = secretFreeParams(params); - const prompt = Array.isArray(safeParams.prompt) ? safeParams.prompt : []; - const instructions: unknown[] = []; - const messages: unknown[] = []; - for (const item of prompt) { - const record = asRecord(item); - if (record?.role === 'system') instructions.push(record.content); - else messages.push(item); - } - const tools = Array.isArray(safeParams.tools) ? safeParams.tools : []; - const providerOptions = asRecord(safeParams.providerOptions); - return capturePreparedProviderRequest({ - providerId, - modelId, - instructions, - messages, - tools, - ...(providerOptions ? { providerOptions } : {}), - requestPayload: safeParams, - }); + return prepareRequestObservation(safeParams); } function secretFreeParams(params: Record): Record { diff --git a/packages/runtime/src/request-shape.ts b/packages/runtime/src/request-shape.ts index fb76ee45e5..341ba344e4 100644 --- a/packages/runtime/src/request-shape.ts +++ b/packages/runtime/src/request-shape.ts @@ -19,13 +19,14 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; -import type { - PrefixChangeReason, - ToolSchemaChangeReason, - ToolAvailabilityDiagnostic, -} from '@maka/core/usage-stats/types'; -import type { ModelMessage } from './model-protocol.js'; +import { + PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS, + PREPARED_REQUEST_OBSERVATION_SCHEMA_VERSION, + PREPARED_REQUEST_OBSERVATION_TEXT_MAX_LENGTH, + type PreparedRequestObservation, + type PreparedRequestObservationSegment, + type PreparedRequestObservationSegmentKind, +} from '@maka/core/model-call-attempt'; import { toJSONSchema } from 'zod'; import type { MakaTool } from './tool-runtime.js'; @@ -35,87 +36,13 @@ export interface CanonicalToolSet { activeTools: string[]; } -export interface RequestShapeInput { - connection: RuntimeExecutionConnection; - modelId: string; - systemPrompt?: string; - providerOptions?: Record; - providerTools: readonly MakaTool[]; - activeTools: readonly string[]; - priorMessages: readonly ModelMessage[]; - toolAvailability?: ToolAvailabilityDiagnostic; -} - -export interface RequestShapeComponents { - modelProviderHash: string; - systemPromptHash: string; - providerOptionsHash: string; - toolSchemaHash: string; - historyProjectionHash: string; -} - -export type DurablePrefixComponents = Omit; - -export interface RequestShapeDiagnostic { - /** Durable provider prefix shape, excluding prior-history projection. */ - prefixHash: string; - prefixChangeReason: PrefixChangeReason; - /** Full request shape, including prior-history projection. */ - requestShapeHash: string; - requestShapeChangeReason: PrefixChangeReason; - componentHashes: RequestShapeComponents; - toolSchemaChangeReason?: ToolSchemaChangeReason; - toolAvailability?: ToolAvailabilityDiagnostic; -} - -export type PreparedRequestSegmentKind = - | 'tool_schema' - | 'system_prompt' - | 'message' - | 'provider_options'; - -export interface PreparedRequestSegment { - kind: PreparedRequestSegmentKind; - index: number; - cacheable: boolean; - 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 { - providerId: string; - modelId: string; - instructions?: unknown; - messages: readonly unknown[]; - tools?: readonly unknown[]; - providerOptions?: Record; - /** Exact secret-free model-call parameters captured at the provider seam. */ - requestPayload?: unknown; -} - -export interface PreparedProviderRequestCapture { - schemaVersion: 2; - requestHash: string; - /** Hash of protocol-independent model-call semantics for cross-protocol comparison. */ - requestPayloadWithoutProviderOptionsHash: string; - requestBytes: number; +export interface PreparedRequestMaterial { + /** Full secret-free representation for the private request artifact. */ serializedRequest: string; - segments: PreparedRequestSegment[]; + /** Bounded public observation derived from that same representation. */ + observation: PreparedRequestObservation; } -export type PreparedRequestSegmentRef = Pick; - /** * Split the registry into the full dispatch set (`providerTools`) and the * model-visible subset (`activeTools`). @@ -148,53 +75,6 @@ export function canonicalizeToolSet( }; } -export function computeRequestShapeDiagnostic( - input: RequestShapeInput, - prior: RequestShapeDiagnostic | undefined, -): RequestShapeDiagnostic { - const componentHashes: RequestShapeComponents = { - modelProviderHash: stableHash({ - providerId: input.connection.providerType, - connectionSlug: input.connection.slug, - modelId: input.modelId, - }), - systemPromptHash: stableHash(input.systemPrompt ?? ''), - providerOptionsHash: stableHash(input.providerOptions ?? {}), - toolSchemaHash: stableHash({ - activeTools: [...input.activeTools], - // Only the provider-visible (active) subset crosses the wire, so the - // schema hash must reflect that subset — otherwise an inactive deferred - // tool's schema change would falsely fire `tool_schema_changed`, and a - // load would not be distinguishable from churn. - providerTools: providerVisibleTools(input.providerTools, input.activeTools).map( - toolShapeForDiagnostics, - ), - }), - historyProjectionHash: stableHash(input.priorMessages.map(messageShapeForHash)), - }; - const durablePrefixComponents = durableComponents(componentHashes); - const prefixHash = stableHash(durablePrefixComponents); - const requestShapeHash = stableHash(componentHashes); - const toolSchemaChangeReason = classifyToolSchemaChange( - componentHashes, - prior?.componentHashes, - input.toolAvailability, - prior?.toolAvailability, - ); - return { - prefixHash, - prefixChangeReason: classifyDurablePrefixChange( - durablePrefixComponents, - prior ? durableComponents(prior.componentHashes) : undefined, - ), - requestShapeHash, - requestShapeChangeReason: classifyRequestShapeChange(componentHashes, prior?.componentHashes), - componentHashes, - ...(toolSchemaChangeReason !== undefined ? { toolSchemaChangeReason } : {}), - ...(input.toolAvailability !== undefined ? { toolAvailability: input.toolAvailability } : {}), - }; -} - export function toolSchemaCharsForDiagnostics( providerTools: readonly MakaTool[], activeTools: readonly string[], @@ -206,254 +86,121 @@ export function toolSchemaCharsForDiagnostics( } /** - * Capture the standardized request immediately before the provider call. + * Observe the standardized request at the AI SDK model-call seam. * - * Segment order follows the stable Maka request-prefix model used for cache - * diagnostics: tools, system instructions, then conversation messages. - * Provider options are retained for exact replay evidence, but are not claimed - * to be a provider-cacheable prefix segment. + * Segment order follows Maka's semantic request-prefix model: tools, system + * instructions, then conversation messages. Provider options are retained for + * exact request evidence, but are not claimed to be a provider-cacheable prefix + * segment. None of this is presented as the provider's final wire body. */ -export function capturePreparedProviderRequest( - input: PreparedProviderRequestInput, -): PreparedProviderRequestCapture { - const payload = input.requestPayload ?? { - instructions: input.instructions, - messages: input.messages, - tools: input.tools ?? [], - providerOptions: input.providerOptions ?? {}, - }; - // This is the evidence body, not the hash canonicalizer: preserve the exact - // JSON ordering and values presented at the model-call seam. - const serializedRequest = JSON.stringify(payload); - const segments: PreparedRequestSegment[] = []; +export function prepareRequestObservation(payload: unknown): PreparedRequestMaterial { + const normalizedPayload = normalizePreparedValue(payload); + const serializedRequest = JSON.stringify(normalizedPayload.value); + const segments: PreparedRequestObservationSegment[] = []; + const parts = semanticRequestParts(payload); - for (const [index, tool] of (input.tools ?? []).entries()) { + for (const [index, tool] of parts.tools.entries()) { segments.push(preparedSegment('tool_schema', index, tool, true, undefined, toolLabel(tool))); } - if (input.instructions !== undefined) { - const instructions = Array.isArray(input.instructions) - ? input.instructions - : [input.instructions]; + if (parts.instructions !== undefined) { + const instructions = Array.isArray(parts.instructions) + ? parts.instructions + : [parts.instructions]; for (const [index, instruction] of instructions.entries()) { segments.push(preparedSegment('system_prompt', index, instruction, true)); } } - for (const [index, message] of input.messages.entries()) { + for (const [index, message] of parts.messages.entries()) { const role = isObjectLike(message) && typeof message.role === 'string' ? message.role : undefined; segments.push(preparedSegment('message', index, message, true, role)); } - if (input.providerOptions !== undefined) { - segments.push(preparedSegment('provider_options', 0, input.providerOptions, false)); + if (parts.providerOptions !== undefined) { + segments.push(preparedSegment('provider_options', 0, parts.providerOptions, false)); } return { - schemaVersion: 2, - requestHash: stableHash({ - providerId: input.providerId, - modelId: input.modelId, - payload, - }), - requestPayloadWithoutProviderOptionsHash: stableHash( - protocolIndependentRequestPayload(payload), - ), - requestBytes: Buffer.byteLength(serializedRequest, 'utf8'), serializedRequest, - segments, + observation: { + schemaVersion: PREPARED_REQUEST_OBSERVATION_SCHEMA_VERSION, + digest: hashSerialized(serializedRequest), + bytes: Buffer.byteLength(serializedRequest, 'utf8'), + segments: boundPreparedRequestSegments(segments), + }, }; } -function protocolIndependentRequestPayload(payload: unknown): unknown { - if (!isObjectLike(payload)) return payload; - const { providerOptions, ...shared } = payload; - const identities: ProtocolIndependentRequestIdentities = { - approvalIds: new Map(), - toolCallIds: new Map(), - }; - const reasoningEffort = protocolIndependentReasoningEffort(providerOptions); - const protocolIndependent: Record = { - ...shared, - ...(Array.isArray(shared.prompt) - ? { - prompt: shared.prompt.map((message) => withoutPromptProviderOptions(message, identities)), - } - : {}), - ...(Array.isArray(shared.messages) - ? { - messages: shared.messages.map((message) => - withoutPromptProviderOptions(message, identities), - ), - } - : {}), - ...(Array.isArray(shared.tools) - ? { tools: shared.tools.map(withoutObjectProviderOptions) } - : {}), - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - }; - const thinkingBudget = anthropicThinkingBudget(providerOptions); - if ( - thinkingBudget === undefined || - !isNonNegativeSafeInteger(protocolIndependent.maxOutputTokens) - ) { - return protocolIndependent; - } - const wireOutputLimit = protocolIndependent.maxOutputTokens + thinkingBudget; - return Number.isSafeInteger(wireOutputLimit) - ? { ...protocolIndependent, maxOutputTokens: wireOutputLimit } - : protocolIndependent; -} +const MAX_PREPARED_REQUEST_REMAINDERS = 4; -function protocolIndependentReasoningEffort( - providerOptions: unknown, -): string | string[] | undefined { - if (!isObjectLike(providerOptions)) return undefined; - const efforts = new Set(); - const anthropic = providerOptions.anthropic; - if (isObjectLike(anthropic) && typeof anthropic.effort === 'string') { - efforts.add(anthropic.effort); - } - for (const namespace of Object.values(providerOptions)) { - if (!isObjectLike(namespace)) continue; - if (typeof namespace.reasoningEffort === 'string') efforts.add(namespace.reasoningEffort); - if (isObjectLike(namespace.thinking) && namespace.thinking.type === 'disabled') { - efforts.add('none'); - } - const thinkingConfig = namespace.thinkingConfig; - if (isObjectLike(thinkingConfig) && typeof thinkingConfig.thinkingLevel === 'string') { - efforts.add(thinkingConfig.thinkingLevel); - } - if (isObjectLike(thinkingConfig) && thinkingConfig.thinkingBudget === 0) { - efforts.add('none'); - } - const chatTemplateKwargs = namespace.chat_template_kwargs; - if (isObjectLike(chatTemplateKwargs) && chatTemplateKwargs.thinking === false) { - efforts.add('none'); +function boundPreparedRequestSegments( + segments: readonly PreparedRequestObservationSegment[], +): PreparedRequestObservationSegment[] { + if (segments.length <= PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS) return [...segments]; + const kept = segments.slice( + 0, + PREPARED_REQUEST_OBSERVATION_MAX_SEGMENTS - MAX_PREPARED_REQUEST_REMAINDERS, + ); + const remainders: PreparedRequestObservationSegment[] = []; + for (const segment of segments.slice(kept.length)) { + const previous = remainders.at(-1); + if (previous?.kind === segment.kind) { + previous.bytes += segment.bytes; + previous.representedSegments = (previous.representedSegments ?? 1) + 1; + previous.digest = hashSerialized( + JSON.stringify(['prepared-segment-remainder', previous.digest, segment.digest]), + ); + continue; } + remainders.push({ + kind: segment.kind, + index: segment.index, + cacheable: segment.cacheable, + comparison: 'opaque', + digest: hashSerialized(JSON.stringify(['prepared-segment-remainder', segment.digest])), + bytes: segment.bytes, + representedSegments: 1, + }); } - const normalized = [...efforts].sort(); - return normalized.length > 1 ? normalized : normalized[0]; -} - -interface ProtocolIndependentRequestIdentities { - approvalIds: Map; - toolCallIds: Map; -} - -function withoutPromptProviderOptions( - value: unknown, - identities: ProtocolIndependentRequestIdentities, -): unknown { - const message = withoutObjectProviderOptions(value); - if (!isObjectLike(message) || !Array.isArray(message.content)) return message; - return { - ...message, - content: message.content.map((part) => withoutPromptPartProviderOptions(part, identities)), - }; + return [...kept, ...remainders]; } -function withoutPromptPartProviderOptions( - value: unknown, - identities: ProtocolIndependentRequestIdentities, -): unknown { - const part = withoutObjectProviderOptions(value); - if (!isObjectLike(part)) return part; - if (part.type === 'tool-call') { - const { providerExecuted: _providerExecuted, ...shared } = part; - return { - ...shared, - toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'), - }; - } - if (part.type === 'tool-approval-request') { - const { isAutomatic: _isAutomatic, signature: _signature, ...shared } = part; - return { - ...shared, - approvalId: protocolIndependentId(part.approvalId, identities.approvalIds, 'approval'), - toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'), - }; - } - if (part.type === 'tool-approval-response') { - const { providerExecuted: _providerExecuted, ...shared } = part; - return { - ...shared, - approvalId: protocolIndependentId(part.approvalId, identities.approvalIds, 'approval'), - }; +function semanticRequestParts(payload: unknown): { + instructions?: unknown; + messages: readonly unknown[]; + tools: readonly unknown[]; + providerOptions?: Record; +} { + if (!isObjectLike(payload)) { + return { messages: [], tools: [] }; } - if (part.type !== 'tool-result') return part; - const output = withoutObjectProviderOptions(part.output); - const normalizedPart = { - ...part, - toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'), - }; - if (!isObjectLike(output) || output.type !== 'content' || !Array.isArray(output.value)) { - return { ...normalizedPart, output }; + const prompt = Array.isArray(payload.prompt) ? payload.prompt : undefined; + const instructions: unknown[] = []; + const messages: unknown[] = []; + if (prompt) { + for (const item of prompt) { + const record = isObjectLike(item) ? item : undefined; + if (record?.role === 'system') instructions.push(record.content); + else messages.push(item); + } } + const payloadMessages = Array.isArray(payload.messages) ? payload.messages : undefined; + const providerOptions = isPlainObject(payload.providerOptions) + ? payload.providerOptions + : undefined; return { - ...normalizedPart, - output: { ...output, value: output.value.map(withoutObjectProviderOptions) }, + ...(prompt + ? instructions.length > 0 + ? { instructions } + : {} + : payload.instructions !== undefined + ? { instructions: payload.instructions } + : {}), + messages: prompt ? messages : (payloadMessages ?? []), + tools: Array.isArray(payload.tools) ? payload.tools : [], + ...(providerOptions !== undefined ? { providerOptions } : {}), }; } -function protocolIndependentId( - value: unknown, - identities: Map, - prefix: string, -): unknown { - if (typeof value !== 'string') return value; - const existing = identities.get(value); - if (existing) return existing; - const normalized = `${prefix}-${identities.size + 1}`; - identities.set(value, normalized); - return normalized; -} - -function withoutObjectProviderOptions(value: unknown): unknown { - if (!isObjectLike(value)) return value; - const { providerOptions: _providerOptions, ...shared } = value; - return shared; -} - -function anthropicThinkingBudget(providerOptions: unknown): number | undefined { - if (!isObjectLike(providerOptions)) return undefined; - const anthropic = providerOptions.anthropic; - if (!isObjectLike(anthropic)) return undefined; - const thinking = anthropic.thinking; - if (!isObjectLike(thinking) || thinking.type !== 'enabled') return undefined; - return isNonNegativeSafeInteger(thinking.budgetTokens) ? thinking.budgetTokens : undefined; -} - -function isNonNegativeSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} - -export function findFirstChangedCacheableSegment( - current: Pick, - prior: Pick, -): PreparedRequestSegmentRef | undefined { - const currentSegments = current.segments.filter((segment) => segment.cacheable); - const priorSegments = prior.segments.filter((segment) => segment.cacheable); - const segmentCount = Math.max(currentSegments.length, priorSegments.length); - for (let position = 0; position < segmentCount; position += 1) { - const currentSegment = currentSegments[position]; - const priorSegment = priorSegments[position]; - if ( - currentSegment?.kind === priorSegment?.kind && - currentSegment?.index === priorSegment?.index && - currentSegment?.hash === priorSegment?.hash - ) { - continue; - } - const changed = currentSegment ?? priorSegment; - if (!changed) return undefined; - return { - kind: changed.kind, - index: changed.index, - ...(changed.role !== undefined ? { role: changed.role } : {}), - }; - } - return undefined; -} - /** The provider-visible tools — the active subset actually serialized on the wire. */ function providerVisibleTools( providerTools: readonly MakaTool[], @@ -464,31 +211,37 @@ function providerVisibleTools( } function preparedSegment( - kind: PreparedRequestSegmentKind, + kind: PreparedRequestObservationSegmentKind, index: number, value: unknown, cacheable: boolean, role?: string, label?: string, -): PreparedRequestSegment { - const serialized = stableStringify(value); +): PreparedRequestObservationSegment { + const normalized = normalizePreparedValue(value); + const serialized = JSON.stringify(normalized.value); return { kind, index, cacheable, - hash: stableHash(value), + comparison: normalized.opaque || containsComparisonOpaqueRedaction(value) ? 'opaque' : 'exact', + digest: hashSerialized(serialized), bytes: Buffer.byteLength(serialized, 'utf8'), - ...(role !== undefined ? { role } : {}), - ...(label !== undefined ? { label } : {}), + ...(role !== undefined + ? { role: role.slice(0, PREPARED_REQUEST_OBSERVATION_TEXT_MAX_LENGTH) } + : {}), + ...(label !== undefined + ? { label: label.slice(0, PREPARED_REQUEST_OBSERVATION_TEXT_MAX_LENGTH) } + : {}), }; } /** * 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. + * Read off the prepared payload rather than the registry: this observation + * describes what Maka handed to the model-call seam, so a name absent there is + * not a name this segment can claim. */ function toolLabel(tool: unknown): string | undefined { if (!isObjectLike(tool)) return undefined; @@ -499,6 +252,10 @@ export function stableHash(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(stableStringify(value)).digest('hex')}`; } +function hashSerialized(serialized: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(serialized).digest('hex')}`; +} + export function toolCatalogHash(tools: readonly MakaTool[]): `sha256:${string}` { return stableHash( [...tools] @@ -511,103 +268,180 @@ export function stableStringify(value: unknown): string { return JSON.stringify(canonicalize(value)); } -function classifyDurablePrefixChange( - current: DurablePrefixComponents, - prior: DurablePrefixComponents | undefined, -): PrefixChangeReason { - if (!prior) return 'first_turn'; - if (current.modelProviderHash !== prior.modelProviderHash) return 'model_or_provider_changed'; - if (current.systemPromptHash !== prior.systemPromptHash) return 'system_prompt_changed'; - if (current.toolSchemaHash !== prior.toolSchemaHash) return 'tool_schema_changed'; - if (current.providerOptionsHash !== prior.providerOptionsHash) return 'provider_options_changed'; - return 'stable'; +interface NormalizedPreparedValue { + value: unknown; + opaque: boolean; } -function classifyRequestShapeChange( - current: RequestShapeComponents, - prior: RequestShapeComponents | undefined, -): PrefixChangeReason { - if (!prior) return 'first_turn'; - if (current.modelProviderHash !== prior.modelProviderHash) return 'model_or_provider_changed'; - if (current.systemPromptHash !== prior.systemPromptHash) return 'system_prompt_changed'; - if (current.toolSchemaHash !== prior.toolSchemaHash) return 'tool_schema_changed'; - if (current.providerOptionsHash !== prior.providerOptionsHash) return 'provider_options_changed'; - if (current.historyProjectionHash !== prior.historyProjectionHash) - return 'history_projection_changed'; - return 'stable'; +/** + * Lossless JSON representation for the semantic values accepted by the model + * seam. Every value is tagged, so a bigint cannot collide with a user string + * and an undefined property cannot disappear. Values that cannot be described + * exactly are retained as explicit opaque markers instead of pretending they + * were equal to another request. + */ +function normalizePreparedValue(value: unknown): NormalizedPreparedValue { + const tag = '__makaPreparedValue'; + const ancestors = new Set(); + const visit = (current: unknown, depth: number): NormalizedPreparedValue => { + if (current === null || typeof current === 'string' || typeof current === 'boolean') { + return { value: current, opaque: false }; + } + if (typeof current === 'number') { + if (Number.isFinite(current) && !Object.is(current, -0)) { + return { value: current, opaque: false }; + } + const encoded = Number.isNaN(current) + ? 'NaN' + : current === Infinity + ? 'Infinity' + : current === -Infinity + ? '-Infinity' + : '-0'; + return { value: { [tag]: 'number', value: encoded }, opaque: false }; + } + if (typeof current === 'bigint') { + return { value: { [tag]: 'bigint', value: current.toString() }, opaque: false }; + } + if (typeof current === 'undefined') { + return { value: { [tag]: 'undefined' }, opaque: false }; + } + if (typeof current === 'function' || typeof current === 'symbol') { + return { value: { [tag]: 'opaque', kind: typeof current }, opaque: true }; + } + if (typeof current !== 'object') { + return { value: { [tag]: 'opaque', kind: typeof current }, opaque: true }; + } + if (depth >= 64) { + return { value: { [tag]: 'opaque', kind: 'max-depth' }, opaque: true }; + } + if (ancestors.has(current)) { + return { value: { [tag]: 'opaque', kind: 'cycle' }, opaque: true }; + } + ancestors.add(current); + try { + if (current instanceof ArrayBuffer) { + return { + value: { + [tag]: 'binary', + kind: 'ArrayBuffer', + encoding: 'base64', + value: Buffer.from(current).toString('base64'), + }, + opaque: false, + }; + } + if (ArrayBuffer.isView(current)) { + return { + value: { + [tag]: 'binary', + kind: current.constructor?.name ?? 'ArrayBufferView', + encoding: 'base64', + value: Buffer.from(current.buffer, current.byteOffset, current.byteLength).toString( + 'base64', + ), + }, + opaque: false, + }; + } + if (current instanceof Date) { + const timestamp = current.getTime(); + return { + value: { + [tag]: 'date', + value: Number.isNaN(timestamp) ? 'invalid' : current.toISOString(), + }, + opaque: false, + }; + } + if (current instanceof Map) { + let opaque = false; + const entries = [...current.entries()].map(([key, entry]) => { + const normalizedKey = visit(key, depth + 1); + const normalizedEntry = visit(entry, depth + 1); + opaque ||= normalizedKey.opaque || normalizedEntry.opaque; + return [normalizedKey.value, normalizedEntry.value]; + }); + return { value: { [tag]: 'map', entries }, opaque }; + } + if (current instanceof Set) { + let opaque = false; + const entries = [...current].map((entry) => { + const normalized = visit(entry, depth + 1); + opaque ||= normalized.opaque; + return normalized.value; + }); + return { value: { [tag]: 'set', entries }, opaque }; + } + if (Array.isArray(current)) { + let opaque = false; + const entries = Array.from({ length: current.length }, (_, index) => { + if (!(index in current)) return { [tag]: 'array-hole' }; + const normalized = visit(current[index], depth + 1); + opaque ||= normalized.opaque; + return normalized.value; + }); + return { value: entries, opaque }; + } + if (isPlainObject(current)) { + let opaque = false; + const entries = Object.keys(current).map((key) => { + let normalized: NormalizedPreparedValue; + try { + normalized = visit(current[key], depth + 1); + } catch { + normalized = { + value: { [tag]: 'opaque', kind: 'unreadable-property' }, + opaque: true, + }; + } + opaque ||= normalized.opaque; + return [key, normalized.value]; + }); + if (Object.hasOwn(current, tag)) { + return { value: { [tag]: 'object', entries }, opaque }; + } + return { value: Object.fromEntries(entries), opaque }; + } + const toJSON = (current as { toJSON?: unknown }).toJSON; + if (typeof toJSON === 'function') { + try { + return visit(toJSON.call(current), depth + 1); + } catch { + return { value: { [tag]: 'opaque', kind: 'toJSON-failed' }, opaque: true }; + } + } + return { + value: { + [tag]: 'opaque', + kind: current.constructor?.name ?? 'non-plain-object', + }, + opaque: true, + }; + } finally { + ancestors.delete(current); + } + }; + return visit(value, 0); } -function classifyToolSchemaChange( - current: RequestShapeComponents, - prior: RequestShapeComponents | undefined, - currentAvail: ToolAvailabilityDiagnostic | undefined, - priorAvail: ToolAvailabilityDiagnostic | undefined, -): ToolSchemaChangeReason | undefined { - if (!prior || current.toolSchemaHash === prior.toolSchemaHash) return undefined; - if ( - isEnabledSourceStrictSuperset(currentAvail, priorAvail) && - sourceCatalogStable(currentAvail, priorAvail) - ) { - return 'tool_source_enabled'; - } - if (sourceStateChanged(currentAvail, priorAvail)) { - return 'tool_source_state_changed'; +function containsComparisonOpaqueRedaction(value: unknown, seen = new Set()): boolean { + if (!isObjectLike(value)) return false; + if (seen.has(value)) return false; + seen.add(value); + if (Array.isArray(value)) { + return value.some((entry) => containsComparisonOpaqueRedaction(entry, seen)); } - return 'tool_schema_changed'; -} - -function isEnabledSourceStrictSuperset( - current: ToolAvailabilityDiagnostic | undefined, - prior: ToolAvailabilityDiagnostic | undefined, -): boolean { if ( - !current || - !prior || - current.mode !== prior.mode || - (current.mode !== 'economy' && current.mode !== 'search') - ) - return false; - const currentIds = new Set(current.enabledSourceIds); - const priorIds = new Set(prior.enabledSourceIds); - if (currentIds.size <= priorIds.size) return false; - for (const sourceId of priorIds) { - if (!currentIds.has(sourceId)) return false; + value.type === 'custom' && + value.kind === 'openai.compaction' && + isPlainObject(value.providerOptions) && + isPlainObject(value.providerOptions.openai) && + value.providerOptions.openai.redacted === true + ) { + return true; } - return true; -} - -function sourceCatalogStable( - current: ToolAvailabilityDiagnostic | undefined, - prior: ToolAvailabilityDiagnostic | undefined, -): boolean { - if (!current || !prior) return false; - return ( - stableStringify(sourceCatalogShape(current)) === stableStringify(sourceCatalogShape(prior)) - ); -} - -function sourceStateChanged( - current: ToolAvailabilityDiagnostic | undefined, - prior: ToolAvailabilityDiagnostic | undefined, -): boolean { - return stableStringify(current ?? null) !== stableStringify(prior ?? null); -} - -function sourceCatalogShape(diagnostic: ToolAvailabilityDiagnostic): unknown { - return { - mode: diagnostic.mode, - connectorToolName: diagnostic.connectorToolName, - visibleToolNamesBySource: diagnostic.visibleToolNamesBySource ?? {}, - }; -} - -function durableComponents(components: RequestShapeComponents): DurablePrefixComponents { - return { - modelProviderHash: components.modelProviderHash, - systemPromptHash: components.systemPromptHash, - providerOptionsHash: components.providerOptionsHash, - toolSchemaHash: components.toolSchemaHash, - }; + return Object.values(value).some((entry) => containsComparisonOpaqueRedaction(entry, seen)); } function toolShapeForDiagnostics(tool: MakaTool): unknown { @@ -649,44 +483,6 @@ function stripJsonSchemaRuntimeFields(value: unknown): unknown { return out; } -function messageShapeForHash(message: ModelMessage): unknown { - const raw = message as unknown as { role?: unknown; content?: unknown }; - return { - role: typeof raw.role === 'string' ? raw.role : 'unknown', - content: contentShapeForHash(raw.content), - }; -} - -function contentShapeForHash(content: unknown): unknown { - if (typeof content === 'string') { - return { type: 'text', chars: content.length }; - } - if (Array.isArray(content)) { - return content.map((part) => { - if (!isObjectLike(part)) return { type: typeof part }; - const type = typeof part.type === 'string' ? part.type : 'unknown'; - return { - type, - ...(typeof part.toolName === 'string' ? { toolName: part.toolName } : {}), - ...(typeof part.toolCallId === 'string' ? { toolCallId: part.toolCallId } : {}), - ...(typeof part.text === 'string' ? { chars: part.text.length } : {}), - ...('output' in part ? { output: payloadShapeForHash(part.output) } : {}), - }; - }); - } - return { type: typeof content }; -} - -function payloadShapeForHash(value: unknown): unknown { - const serialized = stableStringify(value); - return { - type: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value, - chars: serialized.length, - bytes: Buffer.byteLength(serialized, 'utf8'), - hash: stableHash(value), - }; -} - function canonicalize(value: unknown, parentKey?: string): unknown { if (value === null) return null; if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') diff --git a/packages/runtime/src/run-trace.ts b/packages/runtime/src/run-trace.ts index 7eeac0eb1d..2d69a68ebd 100644 --- a/packages/runtime/src/run-trace.ts +++ b/packages/runtime/src/run-trace.ts @@ -22,9 +22,6 @@ import { generalizedErrorMessage, redactSecrets } from '@maka/core/redaction'; import type { CacheMissInputSource, ContextBudgetDiagnostic, - PrefixChangeReason, - PromptSegmentEstimate, - ToolSchemaChangeReason, ToolAvailabilityDiagnostic, } from '@maka/core/usage-stats/types'; @@ -159,21 +156,14 @@ export class RunTrace { modelStreamStarted( activeTools: readonly string[], - prefix?: { - systemPromptHash?: string; - prefixHash: string; - prefixChangeReason: PrefixChangeReason; - requestShapeHash?: string; - requestShapeChangeReason?: PrefixChangeReason; - toolSchemaChangeReason?: ToolSchemaChangeReason; + diagnostics?: { toolAvailability?: ToolAvailabilityDiagnostic; - promptSegments?: PromptSegmentEstimate[]; contextBudget?: ContextBudgetDiagnostic; }, ): void { this.emit('model', 'model_stream_started', 'Model stream started', { activeTools: [...activeTools], - ...(prefix !== undefined ? prefix : {}), + ...(diagnostics !== undefined ? diagnostics : {}), }); } @@ -218,13 +208,6 @@ export class RunTrace { outputTokens?: number; totalTokens?: number; contextBudget?: unknown; - promptSegments?: readonly unknown[]; - systemPromptHash?: string; - prefixHash?: string; - prefixChangeReason?: PrefixChangeReason; - requestShapeHash?: string; - requestShapeChangeReason?: PrefixChangeReason; - toolSchemaChangeReason?: ToolSchemaChangeReason; toolAvailability?: ToolAvailabilityDiagnostic; }): void { this.emit('model', 'send_diagnostics_recorded', 'Send diagnostics recorded', { @@ -236,28 +219,6 @@ export class RunTrace { ...(diagnostics.contextBudget !== undefined ? { contextBudget: diagnostics.contextBudget } : {}), - ...(diagnostics.promptSegments !== undefined && diagnostics.promptSegments.length > 0 - ? { promptSegments: diagnostics.promptSegments } - : {}), - // The FINAL request shape, not step 0's: a same-turn tool load changes it - // mid-send, and `model_stream_started` reports only what the first - // request carried. - ...(diagnostics.systemPromptHash !== undefined - ? { systemPromptHash: diagnostics.systemPromptHash } - : {}), - ...(diagnostics.prefixHash !== undefined ? { prefixHash: diagnostics.prefixHash } : {}), - ...(diagnostics.prefixChangeReason !== undefined - ? { prefixChangeReason: diagnostics.prefixChangeReason } - : {}), - ...(diagnostics.requestShapeHash !== undefined - ? { requestShapeHash: diagnostics.requestShapeHash } - : {}), - ...(diagnostics.requestShapeChangeReason !== undefined - ? { requestShapeChangeReason: diagnostics.requestShapeChangeReason } - : {}), - ...(diagnostics.toolSchemaChangeReason !== undefined - ? { toolSchemaChangeReason: diagnostics.toolSchemaChangeReason } - : {}), ...(diagnostics.toolAvailability !== undefined ? { toolAvailability: diagnostics.toolAvailability } : {}), diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ccfe7f86c2..f2f048ecc8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2223,8 +2223,6 @@ export class RuntimeKernel implements RuntimeKernelLike { }): Pick< BackendFactoryContext, | 'recordRunTrace' - | 'recordProviderRequestCapture' - | 'recordProviderRequestAttempt' | 'recordModelCallAttempt' | 'recordRunComposition' | 'loadHistoryCompactCheckpoint' @@ -2243,15 +2241,6 @@ export class RuntimeKernel implements RuntimeKernelLike { }, ...(this.deps.runStore ? { - recordProviderRequestCapture: (capture) => { - const run = runFor(capture.turnId); - if (!run) - return Promise.reject(new Error('No active AgentRun for provider request capture')); - return run.recordProviderRequestCapture(capture); - }, - recordProviderRequestAttempt: (attempt) => { - runFor(attempt.turnId)?.recordProviderRequestAttempt(attempt); - }, // Resolved by runId rather than turnId: the canonical record names // the run it belongs to, so it needs no turn-to-run indirection. recordModelCallAttempt: (commit) => { diff --git a/packages/runtime/src/semantic-prefix-continuity.ts b/packages/runtime/src/semantic-prefix-continuity.ts new file mode 100644 index 0000000000..b586ac44b2 --- /dev/null +++ b/packages/runtime/src/semantic-prefix-continuity.ts @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ModelCallAttempt, + PreparedRequestObservation, + PreparedRequestObservationSegment, +} from '@maka/core/model-call-attempt'; +import { decodeModelCallAttempt } from '@maka/core/model-call-attempt'; +import { isSessionInlineRun, type AgentRunEvent } from '@maka/core/agent-run'; + +export type SemanticPrefixContinuity = + | { + status: 'no_predecessor' | 'unavailable'; + previousSegmentCount: number; + preservedSegmentCount: number; + } + | { + status: 'preserved'; + previousSegmentCount: number; + preservedSegmentCount: number; + } + | { + status: 'unknown'; + previousSegmentCount: number; + preservedSegmentCount: number; + } + | { + status: 'diverged'; + previousSegmentCount: number; + preservedSegmentCount: number; + firstDivergentSegment: SemanticPrefixSegmentRef; + }; + +export type SemanticPrefixSegmentRef = Pick< + PreparedRequestObservationSegment, + 'kind' | 'index' | 'role' | 'label' +>; + +export type SemanticPrefixPredecessor = + | { kind: 'none' } + | { kind: 'unavailable' } + | { + kind: 'observation'; + observation: PreparedRequestObservation; + }; + +export type AttemptSemanticPrefixPredecessor = + | { kind: 'none' } + | { kind: 'unavailable' } + | { kind: 'attempt'; attempt: ModelCallAttempt }; + +export interface RequestPrefixLineageStore { + listSessionRuns(sessionId: string): Promise< + readonly { + runId: string; + turnId: string; + parentRunId?: string; + continuationSource?: unknown; + agentId?: string; + }[] + >; + readEvents(sessionId: string, runId: string): Promise; + readRootTurnAdmission?( + sessionId: string, + turnId: string, + ): Promise<{ runId: string; previousRootTurnId: string | null } | undefined>; +} + +export interface RequestPrefixRunLineage { + parentRunId?: string; + parentTurnId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; +} + +/** Selects one causal predecessor without consulting append order or wall time. */ +export async function resolveRequestPrefixPredecessor(input: { + current: ModelCallAttempt; + lineage: RequestPrefixRunLineage; + store: RequestPrefixLineageStore; +}): Promise { + const { current, lineage, store } = input; + if (current.callKind !== 'main') return { kind: 'unavailable' }; + + const sameRunAttempts = await readCanonicalMainAttempts(store, current.sessionId, current.runId); + if (current.attempt > 0) { + return uniqueAttempt( + sameRunAttempts.filter( + (candidate) => + candidate.turnId === current.turnId && + candidate.logicalCallId === current.logicalCallId && + candidate.attempt === current.attempt - 1, + ), + ); + } + if (current.step > 0) { + return latestPhysicalAttempt( + sameRunAttempts.filter( + (candidate) => candidate.turnId === current.turnId && candidate.step === current.step - 1, + ), + ); + } + + if (lineage.parentRunId) { + return latestPhysicalAttempt( + await readCanonicalMainAttempts(store, current.sessionId, lineage.parentRunId), + ); + } + const explicitSourceTurnId = + lineage.retriedFromTurnId ?? + lineage.regeneratedFromTurnId ?? + lineage.branchOfTurnId ?? + lineage.parentTurnId; + let predecessorTurnId = explicitSourceTurnId; + if (!predecessorTurnId) { + const admission = await store.readRootTurnAdmission?.(current.sessionId, current.turnId); + if (!admission) return { kind: 'unavailable' }; + if (admission.previousRootTurnId === null) return { kind: 'none' }; + predecessorTurnId = admission.previousRootTurnId; + } + + const sourceRuns = (await store.listSessionRuns(current.sessionId)).filter( + (run) => run.turnId === predecessorTurnId && isSessionInlineRun(run), + ); + const tip = uniqueDurableRunTip(sourceRuns); + if (!tip) return { kind: 'unavailable' }; + return latestPhysicalAttempt( + await readCanonicalMainAttempts(store, current.sessionId, tip.runId), + ); +} + +/** + * Derives the projection from canonical attempts without crossing a request + * domain boundary. The predecessor has already been selected by durable + * lineage; this function deliberately has no fallback selection of its own. + */ +export function deriveAttemptSemanticPrefixContinuity( + current: ModelCallAttempt, + predecessor: AttemptSemanticPrefixPredecessor, +): SemanticPrefixContinuity { + if (predecessor.kind === 'none') { + return { status: 'no_predecessor', previousSegmentCount: 0, preservedSegmentCount: 0 }; + } + if ( + predecessor.kind === 'unavailable' || + current.callKind !== 'main' || + !current.requestObservation + ) { + return unavailableContinuity(); + } + const previous = predecessor.attempt; + if ( + previous.callKind !== 'main' || + !previous.requestObservation || + !sameRequestDomain(current, previous) + ) { + return unavailableContinuity(); + } + return deriveSemanticPrefixContinuity({ + current: current.requestObservation, + predecessor: { kind: 'observation', observation: previous.requestObservation }, + }); +} + +export function deriveSemanticPrefixContinuity(input: { + current: PreparedRequestObservation; + predecessor: SemanticPrefixPredecessor; +}): SemanticPrefixContinuity { + if (input.predecessor.kind === 'none') { + return { status: 'no_predecessor', previousSegmentCount: 0, preservedSegmentCount: 0 }; + } + if (input.predecessor.kind === 'unavailable') { + return { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }; + } + const previous = comparableSegments(input.predecessor.observation); + const current = comparableSegments(input.current); + const previousSegmentCount = countRepresentedSegments(previous); + let firstDivergence = -1; + let firstOpaque = -1; + for (let index = 0; index < previous.length; index += 1) { + const before = previous[index]; + const after = current[index]; + if (!after || !sameSegmentIdentity(before!, after)) { + firstDivergence = index; + break; + } + if (before?.comparison === 'opaque' || after?.comparison === 'opaque') { + firstOpaque = index; + break; + } + if (before && after?.digest !== before.digest) { + firstDivergence = index; + break; + } + } + if (firstDivergence !== -1) { + const segment = current[firstDivergence] ?? previous[firstDivergence]!; + return { + status: 'diverged', + previousSegmentCount, + preservedSegmentCount: countRepresentedSegments(previous.slice(0, firstDivergence)), + firstDivergentSegment: segmentRef(segment), + }; + } + if (firstOpaque !== -1) { + return { + status: 'unknown', + previousSegmentCount, + preservedSegmentCount: countRepresentedSegments(previous.slice(0, firstOpaque)), + }; + } + return { + status: 'preserved', + previousSegmentCount, + preservedSegmentCount: previousSegmentCount, + }; +} + +function sameSegmentIdentity( + left: PreparedRequestObservationSegment, + right: PreparedRequestObservationSegment, +): boolean { + return ( + left.kind === right.kind && + left.index === right.index && + left.role === right.role && + left.label === right.label + ); +} + +function comparableSegments(observation: PreparedRequestObservation) { + return observation.segments.filter((segment) => segment.cacheable); +} + +function countRepresentedSegments(segments: readonly PreparedRequestObservationSegment[]): number { + return segments.reduce((count, segment) => count + (segment.representedSegments ?? 1), 0); +} + +function segmentRef(segment: PreparedRequestObservationSegment): SemanticPrefixSegmentRef { + return { + kind: segment.kind, + index: segment.index, + ...(segment.role !== undefined ? { role: segment.role } : {}), + ...(segment.label !== undefined ? { label: segment.label } : {}), + }; +} + +function unavailableContinuity(): SemanticPrefixContinuity { + return { status: 'unavailable', previousSegmentCount: 0, preservedSegmentCount: 0 }; +} + +function sameRequestDomain(current: ModelCallAttempt, previous: ModelCallAttempt): boolean { + if ( + current.sessionId !== previous.sessionId || + current.connectionSlug !== previous.connectionSlug || + current.providerId !== previous.providerId || + current.modelId !== previous.modelId || + current.requestPrefixDomain === undefined || + previous.requestPrefixDomain === undefined || + current.requestPrefixDomain !== previous.requestPrefixDomain + ) { + return false; + } + const currentPartition = exactProviderPartition(current.requestObservation!); + const previousPartition = exactProviderPartition(previous.requestObservation!); + return ( + currentPartition !== undefined && + previousPartition !== undefined && + currentPartition === previousPartition + ); +} + +/** `undefined` means the transport/cache partition is opaque. */ +function exactProviderPartition(observation: PreparedRequestObservation): string | undefined { + const segments = observation.segments.filter((segment) => segment.kind === 'provider_options'); + if (segments.some((segment) => segment.comparison === 'opaque')) return undefined; + return segments.map((segment) => `${segment.index}:${segment.digest}`).join('|'); +} + +async function readCanonicalMainAttempts( + store: RequestPrefixLineageStore, + sessionId: string, + runId: string, +): Promise { + const attempts: ModelCallAttempt[] = []; + for (const event of await store.readEvents(sessionId, runId)) { + if (event.type !== 'model_call_attempt_recorded') continue; + try { + const attempt = decodeModelCallAttempt(event.data); + if ( + attempt.callKind === 'main' && + attempt.sessionId === sessionId && + attempt.runId === runId && + attempt.attemptId === event.id + ) { + attempts.push(attempt); + } + } catch { + // One unreadable record cannot become a guessed predecessor. + } + } + return attempts; +} + +function uniqueAttempt(candidates: readonly ModelCallAttempt[]): AttemptSemanticPrefixPredecessor { + if (candidates.length !== 1) return { kind: 'unavailable' }; + return { kind: 'attempt', attempt: candidates[0]! }; +} + +function latestPhysicalAttempt( + candidates: readonly ModelCallAttempt[], +): AttemptSemanticPrefixPredecessor { + if (candidates.length === 0) return { kind: 'unavailable' }; + const highestStep = Math.max(...candidates.map((candidate) => candidate.step)); + const onStep = candidates.filter((candidate) => candidate.step === highestStep); + const highestAttempt = Math.max(...onStep.map((candidate) => candidate.attempt)); + return uniqueAttempt(onStep.filter((candidate) => candidate.attempt === highestAttempt)); +} + +function uniqueDurableRunTip( + runs: readonly T[], +): T | undefined { + const byId = new Map(runs.map((run) => [run.runId, run])); + if (byId.size !== runs.length) return undefined; + + const childByParent = new Map(); + for (const run of runs) { + if (!run.parentRunId || !byId.has(run.parentRunId)) continue; + if (childByParent.has(run.parentRunId)) return undefined; + childByParent.set(run.parentRunId, run.runId); + } + const tips = runs.filter((run) => !childByParent.has(run.runId)); + if (tips.length !== 1) return undefined; + + const visited = new Set(); + let cursor: T | undefined = tips[0]; + while (cursor) { + if (visited.has(cursor.runId)) return undefined; + visited.add(cursor.runId); + cursor = cursor.parentRunId ? byId.get(cursor.parentRunId) : undefined; + } + return visited.size === runs.length ? tips[0] : undefined; +} diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 96591f9d73..64118c02e6 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -164,10 +164,6 @@ import type { MakaTool } from './tool-runtime.js'; import type { TurnShellPlan } from './shell-detect.js'; import type { RunTraceRecorder } from './run-trace.js'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; -import type { - ProviderRequestAttemptRecord, - 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'; @@ -678,14 +674,9 @@ export interface BackendFactoryContext { /** Turn-scoped shell plan captured with a bound child tool ceiling. */ turnShellPlan?: TurnShellPlan; recordRunTrace?: RunTraceRecorder; - /** Durable AgentRun metadata row written after the private capture artifact. */ - recordProviderRequestCapture?: (capture: ProviderRequestCaptureLedgerRecord) => Promise; - /** Best-effort AgentRun row for one physical provider call. */ - recordProviderRequestAttempt?: (attempt: ProviderRequestAttemptRecord) => void; /** - * Durable AgentRun row carrying the canonical accounting record for one - * physical provider call. Distinct from the diagnostic row above: this one is - * the metering source of truth (#1679). + * Durable AgentRun row carrying the canonical record for one physical + * provider call, including metering and its prepared-request observation. */ recordModelCallAttempt?: (commit: ModelCallCommit) => Promise; /** Immutable Run policy snapshot; provider dispatch waits for this durable commit. */ diff --git a/packages/runtime/src/system-prompt/main-session-prompt.ts b/packages/runtime/src/system-prompt/main-session-prompt.ts index 72467be668..5a46c19f5b 100644 --- a/packages/runtime/src/system-prompt/main-session-prompt.ts +++ b/packages/runtime/src/system-prompt/main-session-prompt.ts @@ -34,7 +34,7 @@ * fragment order. * * The identity and response-format guidance are pure static text: constant - * across turns, so they never churn the systemPromptHash / prefix-cache (see + * across turns, so they never churn the provider-stable request prefix (see * request-shape.ts). They are intentionally NOT injected into sub-agent * (childInstruction) paths, which already carry their own role identities. */ diff --git a/packages/storage/src/__tests__/regenerate-root-admission.test.ts b/packages/storage/src/__tests__/regenerate-root-admission.test.ts index f1c2d0c501..8863533a58 100644 --- a/packages/storage/src/__tests__/regenerate-root-admission.test.ts +++ b/packages/storage/src/__tests__/regenerate-root-admission.test.ts @@ -106,6 +106,31 @@ test('new root admissions reject removed Automation authority', async () => { } }); +test('conversation-copy import preserves a historical Automation admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-legacy-automation-copy-')); + try { + const store = createSqliteAgentRunStore(root); + const imported = await store.importConversationCopyRootTurn( + admissionInput({ + sessionId: 'copied-session', + execution: { + kind: 'legacy_automation', + automationId: 'automation-1', + } as RootExecutionDescriptor, + }), + ); + + assert.equal(imported.kind, 'admitted'); + assert.deepEqual(imported.admission.execution, { + kind: 'legacy_automation', + automationId: 'automation-1', + }); + store.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function admissionInput(overrides: Partial = {}): AdmitRootTurnInput { return { sessionId: 'root-session', diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 27481f3fec..2e05ebbb70 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -190,6 +190,8 @@ export type AdmitRootTurnResult = export interface RootTurnAdmissionStore { admitRootTurn(input: AdmitRootTurnInput): Promise; + /** Imports a copied durable admission, including frozen historical execution kinds. */ + importConversationCopyRootTurn(input: AdmitRootTurnInput): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; readRootTurnSourceMessageReceipt( sessionId: string, @@ -690,7 +692,18 @@ class SqliteAgentRunStore implements DurableAgentRunStore { } async admitRootTurn(input: AdmitRootTurnInput): Promise { - const admission = normalizeAdmitRootTurnInput(input); + return this.#storeRootTurnAdmission(input, false); + } + + async importConversationCopyRootTurn(input: AdmitRootTurnInput): Promise { + return this.#storeRootTurnAdmission(input, true); + } + + async #storeRootTurnAdmission( + input: AdmitRootTurnInput, + allowHistoricalExecution: boolean, + ): Promise { + const admission = normalizeAdmitRootTurnInput(input, allowHistoricalExecution); return this.#lease.transaction('write', () => { const existing = readSqliteRootTurnAdmission( this.#lease.database, @@ -1199,7 +1212,10 @@ function normalizeStoredRootTurnStartRejection( return Object.freeze(rejection); } -function normalizeAdmitRootTurnInput(input: AdmitRootTurnInput): RootTurnAdmission { +function normalizeAdmitRootTurnInput( + input: AdmitRootTurnInput, + allowHistoricalExecution = false, +): RootTurnAdmission { assertSafeId(input.sessionId, 'Invalid session id'); assertSafeId(input.turnId, 'Invalid turn id'); assertSafeId(input.proposedRunId, 'Invalid run id'); @@ -1226,7 +1242,7 @@ function normalizeAdmitRootTurnInput(input: AdmitRootTurnInput): RootTurnAdmissi : decodeSkillInvocationResult(input.skillInvocation); const authorization = normalizeRootTurnAdmissionAuthorization(input.authorization); const execution = normalizeRootExecutionDescriptor(input.execution); - if (execution.kind === 'legacy_automation') { + if (execution.kind === 'legacy_automation' && !allowHistoricalExecution) { throw new Error('New root admission cannot use removed Automation authority'); } const admission: RootTurnAdmission = { diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 33bdf087e7..4f67454597 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -502,6 +502,8 @@ async function createExecutionStoresForWrite agentRunStore.repairEventProjection(sessionId, type, event, options)), admitRootTurn: (input: AdmitRootTurnInput): Promise => run(() => agentRunStore.admitRootTurn(input)), + importConversationCopyRootTurn: (input: AdmitRootTurnInput): Promise => + run(() => agentRunStore.importConversationCopyRootTurn(input)), readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), readRootTurnStartRejection: (sessionId, turnId) =>