From 8780d6a0d8b757ca0fde0b0d6423a0141a651823 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 13 Aug 2026 11:30:48 +0800 Subject: [PATCH 01/16] refactor(agent-core-v2): carry the context fold cursor in state and converge fold/projection internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContextModel state is now { messages, fold }: the loop-event fold cursor (openStepUuid / pending / deferred) lives in the state instead of a module-level WeakMap keyed by array identity, so wholesale replacements (undo / clear / compaction / swarm exit) reset it structurally via EMPTY_FOLD instead of a manual resetFold at five call sites. - The display transcript and the wire model now share one generic fold kernel (FoldFrame / FoldEntryAdapter), eliminating the mirrored second implementation. Events tagged with a non-open step uuid are dropped and step.end settles only the step it names — defensive in abnormal streams, identical on well-formed ones (v1 replay unaffected). - IAgentContextProjectorService converges to project(messages, policy) with a ProjectionPolicy data object; llmRequester builds the policy from retry state instead of selecting among four methods. - Blob rehydrate now also covers messages still deferred in the fold cursor. - ContextState is deeply frozen at the op boundary to preserve the consumer immutability the wire's shallow freeze gave the bare array state. --- .../contextMemory/contextMemoryService.ts | 2 +- .../src/agent/contextMemory/contextOps.ts | 89 +++--- .../agent/contextMemory/contextTranscript.ts | 259 ++++++------------ .../src/agent/contextMemory/loopEventFold.ts | 219 +++++++++------ .../src/agent/contextMemory/types.ts | 46 ++++ .../contextProjector/contextProjector.ts | 22 +- .../contextProjectorService.ts | 50 ++-- .../agent/llmRequester/llmRequesterService.ts | 23 +- .../tokenCounting/tokenCountingService.ts | 2 +- .../contextMemory/contextTranscript.test.ts | 121 +++++++- .../agent/contextMemory/splice-replay.test.ts | 23 +- .../agent/contextMemory/undoPrecheck.test.ts | 21 +- .../projector-tool-exchanges.test.ts | 46 ++-- .../llmRequester/llmRequesterService.test.ts | 121 +++----- 14 files changed, 565 insertions(+), 479 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index fb5e00e7e4..4173f3cc33 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -75,7 +75,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte } get(): readonly ContextMessage[] { - return this.wire.getModel(ContextModel) as readonly ContextMessage[]; + return this.wire.getModel(ContextModel).messages as readonly ContextMessage[]; } append(...messages: readonly ContextMessage[]): void { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 7c9610cdb2..8a15d99076 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -5,10 +5,14 @@ * `context.undo` (`contextUndo`) / `context.append_loop_event` * (`contextAppendLoopEvent`) for the per-agent conversation history. * - * Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply` - * is a pure array transform that returns a NEW reference on change and the SAME + * Declares the history as `ContextState` — `{ messages, fold }`, initial + * `{ messages: [], fold: EMPTY_FOLD }`; every Op's `apply` + * is a pure transform that returns a NEW reference on change and the SAME * reference on a no-op (so the wire's reference-equality gate stays quiet), and - * carries no non-determinism. + * carries no non-determinism. The loop-event fold cursor (`state.fold`) is part + * of the state, so wholesale replacements (undo / clear / compaction / the + * `swarm_mode.exit` pop) reset it by returning `EMPTY_FOLD` — there is no + * out-of-band reset to forget. * * The live write path emits the v1 Ops: non-loop appends (user prompts, * injections, hook/task notices) go on the wire as `append_message` (persisted @@ -33,7 +37,8 @@ * passing each `ContentPart[]` through `transform` to offload oversized data * URIs. * - `rehydrate(state, transform)`: after replay, traverses the surviving final - * state and loads `blobref:` URLs back to inline data — skipping I/O for + * state (including still-deferred messages in the fold cursor) and loads + * `blobref:` URLs back to inline data — skipping I/O for * data that was compacted away during the session. */ @@ -54,13 +59,13 @@ import { isUndoAnchor, isValidUndoCount, } from './conversationTime'; +import { foldAppendMessage, foldLoopEvent, type LoopRecordedEvent } from './loopEventFold'; import { - foldAppendMessage, - foldLoopEvent, - resetFold, - type LoopRecordedEvent, -} from './loopEventFold'; -import type { ContextMessage } from './types'; + EMPTY_FOLD, + freezeContextState, + type ContextMessage, + type ContextState, +} from './types'; async function dehydrateMessages( messages: readonly ContextMessage[], @@ -111,25 +116,34 @@ async function dehydrateRecord( return record; } -export const ContextModel = defineModel('contextMemory', () => [], { - blobs: { - dehydrate: dehydrateRecord, - rehydrate: async (state, transform) => { - const { changed, result } = await dehydrateMessages(state, transform); - return changed ? result : state; +export const ContextModel = defineModel( + 'contextMemory', + () => freezeContextState({ messages: [], fold: EMPTY_FOLD }), + { + blobs: { + dehydrate: dehydrateRecord, + rehydrate: async (state, transform) => { + const messages = await dehydrateMessages(state.messages, transform); + const deferred = await dehydrateMessages(state.fold.deferred, transform); + if (!messages.changed && !deferred.changed) return state; + return freezeContextState({ + messages: messages.result, + fold: deferred.changed ? { ...state.fold, deferred: deferred.result } : state.fold, + }); + }, + }, + reducers: { + 'swarm_mode.exit': popSwarmModeReminder, }, }, - reducers: { - 'swarm_mode.exit': popSwarmModeReminder, - }, -}); +); -function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): ContextMessage[] { - const last = state[state.length - 1]; +function popSwarmModeReminder(state: ContextState, _payload: unknown): ContextState { + const last = state.messages.at(-1); if (last === undefined) return state; const origin = last.origin; if (origin?.kind !== 'injection' || origin.variant !== 'swarm_mode') return state; - return resetFold(state.slice(0, -1)) as ContextMessage[]; + return freezeContextState({ messages: state.messages.slice(0, -1), fold: EMPTY_FOLD }); } declare module '#/wire/types' { @@ -147,17 +161,25 @@ const loopRecordedEventSchema = z.custom(); export const contextAppendMessage = ContextModel.defineOp('context.append_message', { schema: z.object({ message: contextMessageSchema }), - apply: (state, p) => foldAppendMessage(state, p.message) as ContextMessage[], + apply: (state, p) => freezeContextState(foldAppendMessage(state, p.message)), }); export const contextAppendLoopEvent = ContextModel.defineOp('context.append_loop_event', { schema: z.object({ event: loopRecordedEventSchema }), - apply: (state, p) => foldLoopEvent(state, p.event) as ContextMessage[], + apply: (state, p) => freezeContextState(foldLoopEvent(state, p.event)), }); export const contextClear = ContextModel.defineOp('context.clear', { schema: z.object({}), - apply: (state) => (state.length === 0 ? state : (resetFold([]) as ContextMessage[])), + apply: (state) => { + const { fold } = state; + const pristine = + fold.openStepUuid === undefined && + fold.pending.length === 0 && + fold.deferred.length === 0; + if (state.messages.length === 0 && pristine) return state; + return freezeContextState({ messages: [], fold: EMPTY_FOLD }); + }, }); const contextCompactionBaseShape = { @@ -196,8 +218,8 @@ type ContextCompactionPayload = z.infer; export const contextApplyCompaction = ContextModel.defineOp('context.apply_compaction', { schema: contextApplyCompactionSchema, apply: (state, p) => { - const result = buildContextCompactionShape(state, readContextCompactionShapeInput(p)); - return resetFold([...result.messages]) as ContextMessage[]; + const result = buildContextCompactionShape(state.messages, readContextCompactionShapeInput(p)); + return freezeContextState({ messages: [...result.messages], fold: EMPTY_FOLD }); }, }); @@ -212,7 +234,7 @@ export function applyContextCompactionRecord( record: ContextCompactionRecord, ): ContextMessage[] { const result = buildContextCompactionShape(state, readContextCompactionShapeInput(record)); - return resetFold([...result.messages]) as ContextMessage[]; + return [...result.messages]; } export function readContextCompactionShapeInput( @@ -412,9 +434,12 @@ export const contextUndo = ContextModel.defineOp('context.undo', { count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), }), apply: (state, p) => { - if (!isValidUndoCount(p.count) || state.length === 0) return state; - const cut = computeUndoCut(state, p.count); + if (!isValidUndoCount(p.count) || state.messages.length === 0) return state; + const cut = computeUndoCut(state.messages, p.count); if (!isFullyUndoable(cut, p.count)) return state; - return resetFold(state.slice(0, cut.cutIndex)) as ContextMessage[]; + return freezeContextState({ + messages: state.messages.slice(0, cut.cutIndex), + fold: EMPTY_FOLD, + }); }, }); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 7733783170..2e441044d2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -3,9 +3,16 @@ * * Supplies transcript consumers with full pre-compaction history and folded * context length while preserving undo/clear semantics. Scope-agnostic. + * + * Loop events and plain appends are reduced by the shared fold kernel + * (`loopEventFold.ts`) over time-stamped entries, so the display view can + * never drift from the live/replay fold; this reducer only adds the display + * bookkeeping the kernel does not own — per-entry record times, `clearFloor`, + * and `foldedLength` — plus the transcript-specific meaning of undo (splice + * the tail, keep injections with their owner), clear (keep entries, move the + * floor), and compaction (append the summary marker, keep the folded prefix). */ -import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { WireRecord } from '#/wire/record'; import { @@ -14,12 +21,14 @@ import { selectRecentUserMessages, } from './compactionHandoff'; import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; -import type { LoopRecordedEvent } from './loopEventFold'; -import type { ContextMessage } from './types'; -import { isVacuousContentPart } from './vacuousContent'; - -const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = - 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; +import { + appendMessageTo, + applyLoopEventTo, + type FoldEntryAdapter, + type FoldFrame, + type LoopRecordedEvent, +} from './loopEventFold'; +import { EMPTY_FOLD, type ContextMessage } from './types'; export interface ContextTranscript { readonly entries: readonly ContextMessage[]; @@ -32,20 +41,15 @@ export interface ContextTranscriptReducer { result(): ContextTranscript; } -interface MutableMessage { - id?: string; - role: ContextMessage['role']; - content: ContentPart[]; - toolCalls: ToolCall[]; - toolCallId?: string; - isError?: boolean; - origin?: ContextMessage['origin']; +interface TranscriptEntry { + readonly message: ContextMessage; + readonly time?: number; } -interface MutableEntry { - message: MutableMessage; - time?: number; -} +const entryAdapter: FoldEntryAdapter = { + messageOf: (entry) => entry.message, + withMessage: (entry, message) => ({ ...entry, message }), +}; export function reduceContextTranscript(records: Iterable): ContextTranscript { const reducer = createContextTranscriptReducer(); @@ -54,209 +58,108 @@ export function reduceContextTranscript(records: Iterable): ContextT } export function createContextTranscriptReducer(): ContextTranscriptReducer { - const transcript: MutableEntry[] = []; + let frame: FoldFrame = { messages: [], fold: EMPTY_FOLD }; let foldedLength = 0; let clearFloor = 0; - const openSteps = new Map(); - const pendingToolResultIds = new Set(); - let deferred: MutableEntry[] = []; - let lastOpenStepUuid: string | undefined; - const push = (...entries: MutableEntry[]): void => { - transcript.push(...entries); - foldedLength += entries.length; - }; - const flushDeferredIfToolExchangeClosed = (): void => { - if (pendingToolResultIds.size > 0 || deferred.length === 0) return; - push(...deferred); - deferred = []; - }; - const closePendingToolResults = (time: number | undefined): void => { - if (pendingToolResultIds.size === 0) return; - const interruptedToolCallIds = [...pendingToolResultIds]; - for (const toolCallId of interruptedToolCallIds) { - push({ - message: { - role: 'tool', - content: [{ type: 'text', text: TOOL_INTERRUPTED_ON_RESUME_OUTPUT }], - toolCalls: [], - toolCallId, - isError: true, - }, - time, - }); - pendingToolResultIds.delete(toolCallId); - } - flushDeferredIfToolExchangeClosed(); - }; - const resetOpenState = (): void => { - openSteps.clear(); - pendingToolResultIds.clear(); - deferred = []; - lastOpenStepUuid = undefined; - }; - const settleStep = (uuid: string): void => { - const entry = openSteps.get(uuid); - if (entry === undefined) return; - openSteps.delete(uuid); - if (entry.message.toolCalls.length > 0) return; - if (!entry.message.content.every(isVacuousContentPart)) return; - const index = transcript.indexOf(entry); - if (index === -1) return; - transcript.splice(index, 1); - foldedLength = Math.max(0, foldedLength - 1); - }; - - const applyLoopEvent = (event: LoopRecordedEvent, time: number | undefined): void => { - switch (event.type) { - case 'step.begin': { - closePendingToolResults(time); - if (lastOpenStepUuid !== undefined) settleStep(lastOpenStepUuid); - const entry: MutableEntry = { - message: { role: 'assistant', content: [], toolCalls: [] }, - time, - }; - push(entry); - openSteps.set(event.uuid, entry); - lastOpenStepUuid = event.uuid; - return; - } - case 'step.end': { - settleStep(event.uuid); - if (lastOpenStepUuid === event.uuid) lastOpenStepUuid = undefined; - flushDeferredIfToolExchangeClosed(); - return; - } - case 'content.part': { - openSteps.get(event.stepUuid)?.message.content.push(event.part); - return; - } - case 'tool.call': { - const openStep = openSteps.get(event.stepUuid); - if (openStep === undefined) return; - const call: ToolCall = { - type: 'function', - id: event.toolCallId, - name: event.name, - arguments: event.args === undefined ? null : JSON.stringify(event.args), - ...(event.extras !== undefined ? { extras: event.extras } : {}), - }; - openStep.message.toolCalls.push(call); - pendingToolResultIds.add(event.toolCallId); - return; - } - case 'tool.result': { - if (!pendingToolResultIds.has(event.toolCallId)) return; - push({ - message: { - role: 'tool', - content: rawToolResultContent(event.result.output), - toolCalls: [], - toolCallId: event.toolCallId, - isError: event.result.isError, - }, - time, - }); - pendingToolResultIds.delete(event.toolCallId); - flushDeferredIfToolExchangeClosed(); - return; - } - } + const applyKernel = (next: FoldFrame): void => { + foldedLength += next.messages.length - frame.messages.length; + frame = next; }; const applyUndo = (count: number): void => { if (count <= 0) return; + const entries = frame.messages.slice(); let removedUserCount = 0; - for (let i = transcript.length - 1; i >= clearFloor; i--) { - const message = transcript[i]!.message; + let removed = 0; + for (let i = entries.length - 1; i >= clearFloor; i--) { + const message = entries[i]!.message; if (message.origin?.kind === 'injection') continue; if (message.origin?.kind === 'compaction_summary') break; - transcript.splice(i, 1); - foldedLength = Math.max(0, foldedLength - 1); + entries.splice(i, 1); + removed++; if (isUndoAnchor(message)) { removedUserCount++; if (removedUserCount >= count) { while ( i > clearFloor && - isPromptOwnedInjection(transcript[i - 1]!.message, message) + isPromptOwnedInjection(entries[i - 1]!.message, message) ) { - transcript.splice(i - 1, 1); + entries.splice(i - 1, 1); i--; - foldedLength = Math.max(0, foldedLength - 1); + removed++; } break; } } } - resetOpenState(); + foldedLength = Math.max(0, foldedLength - removed); + frame = { messages: entries, fold: EMPTY_FOLD }; }; const add = (record: WireRecord): void => { switch (record.type) { case 'context.append_message': { - const entry = toMutableEntry(record['message'] as ContextMessage, record.time); - if (pendingToolResultIds.size > 0) deferred.push(entry); - else push(entry); - break; + applyKernel( + appendMessageTo(frame, { + message: record['message'] as ContextMessage, + time: record.time, + }), + ); + return; + } + case 'context.append_loop_event': { + const time = record.time; + applyKernel( + applyLoopEventTo( + frame, + record['event'] as LoopRecordedEvent, + entryAdapter, + (message): TranscriptEntry => ({ message, time }), + ), + ); + return; } - case 'context.append_loop_event': - applyLoopEvent(record['event'] as LoopRecordedEvent, record.time); - break; case 'context.apply_compaction': { - transcript.push({ - message: { - role: 'user', - content: [{ type: 'text', text: readCompactionSummaryText(record) }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - }, - time: record.time, - }); - foldedLength = recoverFoldedLength(record, transcript, clearFloor, foldedLength); - resetOpenState(); - break; + const summary: ContextMessage = { + role: 'user', + content: [{ type: 'text', text: readCompactionSummaryText(record) }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; + frame = { + messages: [...frame.messages, { message: summary, time: record.time }], + fold: EMPTY_FOLD, + }; + foldedLength = recoverFoldedLength(record, frame.messages, clearFloor, foldedLength); + return; } case 'context.undo': applyUndo(record['count'] as number); - break; + return; case 'context.clear': - clearFloor = transcript.length; + clearFloor = frame.messages.length; foldedLength = 0; - resetOpenState(); - break; + frame = { messages: frame.messages, fold: EMPTY_FOLD }; + return; default: - break; + return; } }; return { add, result: () => ({ - entries: transcript.map((e) => e.message), - times: transcript.map((e) => e.time), + entries: frame.messages.map((entry) => entry.message), + times: frame.messages.map((entry) => entry.time), foldedLength, }), }; } -function toMutableEntry(message: ContextMessage, time: number | undefined): MutableEntry { - return { - message: { - ...(message.id !== undefined ? { id: message.id } : {}), - role: message.role, - content: [...message.content], - toolCalls: [...message.toolCalls], - ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), - ...(message.isError !== undefined ? { isError: message.isError } : {}), - ...(message.origin !== undefined ? { origin: message.origin } : {}), - }, - time, - }; -} - function recoverFoldedLength( record: WireRecord, - transcript: readonly MutableEntry[], + transcript: readonly TranscriptEntry[], clearFloor: number, foldedLength: number, ): number { @@ -270,7 +173,7 @@ function recoverFoldedLength( return 1 + (foldedLength - compactedCount); } const keptUserMessages = selectRecentUserMessages( - collectCompactableUserMessages(transcript.slice(clearFloor).map((e) => e.message)), + collectCompactableUserMessages(transcript.slice(clearFloor).map((entry) => entry.message)), COMPACT_USER_MESSAGE_MAX_TOKENS, ); return keptUserMessages.length + 1; @@ -291,7 +194,7 @@ function isContextMessageLike(value: unknown): value is ContextMessage { return typeof message.role === 'string' && Array.isArray(message.content); } -function textOfParts(content: readonly ContentPart[]): string { +function textOfParts(content: ContextMessage['content']): string { let text = ''; for (const part of content) { if (part.type === 'text') text += part.text; @@ -303,7 +206,3 @@ function readNumber(record: WireRecord, key: string): number | undefined { const value = record[key]; return typeof value === 'number' ? value : undefined; } - -function rawToolResultContent(output: string | readonly ContentPart[]): ContentPart[] { - return typeof output === 'string' ? [{ type: 'text', text: output }] : [...output]; -} diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 325b94ba90..f62e61997a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -1,6 +1,7 @@ /** - * `contextMemory` loop-event fold — reduction of `context.append_loop_event` - * records into folded `ContextMessage`s. + * `contextMemory` loop-event fold — the single kernel that reduces + * `context.append_loop_event` / `context.append_message` records into folded + * conversation entries. * * The agent loop streams a turn as `context.append_loop_event` records * (`step.begin` / `content.part` / `tool.call` / `tool.result` / `step.end`) @@ -31,18 +32,28 @@ * A `context.append_message` reduced while a tool exchange is still open is * deferred and flushed once the exchange closes, so strict-provider * assistant↔tool adjacency is preserved. + * Events tagged with a step uuid that is not the open one (a late event of an + * attempt whose `step.begin` was already settled) are dropped, and a + * `step.end` only settles the step it names. * - * The fold is stateful across records within one replay. State is carried in a - * `WeakMap` keyed by each evolving state array, so the public - * `wire.getModel(ContextModel)` view stays a plain `ContextMessage[]` and - * concurrent replays of different agent scopes never share fold state. + * The fold is stateful across records within one replay; the cursor + * (`openStepUuid` / `pending` / `deferred`) is part of the frame's `fold` + * field, so live dispatch and replay share one pure transition, and every + * wholesale state replacement (undo / clear / compaction) resets the cursor + * structurally by returning `EMPTY_FOLD`. + * + * The kernel is generic over the entry type (`FoldFrame` + + * `FoldEntryAdapter`): the wire model folds bare `ContextMessage`s + * (`foldAppendMessage` / `foldLoopEvent` specializations below), while the + * display transcript (`contextTranscript.ts`) folds time-stamped entries — + * one reduction semantics, two read models. */ import type { FinishReason } from '#/kosong/contract/provider'; import { createToolMessage, type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { TokenUsage } from '#/kosong/contract/usage'; -import type { ContextMessage } from './types'; +import type { ContextFoldState, ContextMessage, ContextState } from './types'; import { isVacuousContentPart } from './vacuousContent'; const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = @@ -102,63 +113,78 @@ export type LoopRecordedEvent = readonly parentUuid?: string; }; -interface FoldCtx { - openStepUuid: string | undefined; - pending: Set; - deferred: ContextMessage[]; +/** A fold position: the entries reduced so far plus the fold cursor. */ +export interface FoldFrame { + readonly messages: readonly E[]; + readonly fold: ContextFoldState; +} + +/** How the kernel reads and rewrites the message carried by an entry. */ +export interface FoldEntryAdapter { + readonly messageOf: (entry: E) => ContextMessage; + readonly withMessage: (entry: E, message: ContextMessage) => E; } -const foldCtxMap = new WeakMap(); +const messageAdapter: FoldEntryAdapter = { + messageOf: (entry) => entry, + withMessage: (_entry, message) => message, +}; -function ctxOf(state: readonly ContextMessage[]): FoldCtx { - let ctx = foldCtxMap.get(state); - if (ctx === undefined) { - ctx = { openStepUuid: undefined, pending: new Set(), deferred: [] }; - foldCtxMap.set(state, ctx); - } - return ctx; +export function foldAppendMessage(state: ContextState, message: ContextMessage): ContextState { + return appendMessageTo(state, message); } -function bind(state: readonly ContextMessage[], ctx: FoldCtx): readonly ContextMessage[] { - foldCtxMap.set(state, ctx); - return state; +export function foldLoopEvent(state: ContextState, event: LoopRecordedEvent): ContextState { + return applyLoopEventTo(state, event, messageAdapter, (message) => message); } -export function foldAppendMessage( - state: readonly ContextMessage[], - message: ContextMessage, -): readonly ContextMessage[] { - const ctx = ctxOf(state); - if (ctx.pending.size > 0) { - ctx.deferred.push(message); - return state; +export function appendMessageTo(frame: FoldFrame, entry: E): FoldFrame { + const { fold } = frame; + if (fold.pending.length > 0) { + return { ...frame, fold: { ...fold, deferred: [...fold.deferred, entry] } }; } - return bind([...state, message], ctx); + return { ...frame, messages: [...frame.messages, entry] }; } -export function foldLoopEvent( - state: readonly ContextMessage[], +export function applyLoopEventTo( + frame: FoldFrame, event: LoopRecordedEvent, -): readonly ContextMessage[] { - const ctx = ctxOf(state); + adapter: FoldEntryAdapter, + makeEntry: (message: ContextMessage) => E, +): FoldFrame { + const { fold } = frame; switch (event.type) { case 'step.begin': { - const settled = settleOpenStep(state, ctx); - const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true }; - ctx.openStepUuid = event.uuid; - return bind([...settled, assistant], ctx); + const settled = settleOpenStep(frame, adapter, makeEntry); + const assistant: ContextMessage = { + role: 'assistant', + content: [], + toolCalls: [], + partial: true, + }; + return { + messages: [...settled.messages, makeEntry(assistant)], + fold: { ...settled.fold, openStepUuid: event.uuid }, + }; } case 'step.end': { - ctx.openStepUuid = undefined; - const s = settleOpenStep(state, ctx); - return bind(flushDeferred(s, ctx), ctx); + if (fold.openStepUuid !== event.uuid) return flushDeferred(frame); + const settled = settleOpenStep( + { ...frame, fold: { ...fold, openStepUuid: undefined } }, + adapter, + makeEntry, + ); + return flushDeferred(settled); } - case 'content.part': - return bind(appendToOpenAssistant(state, (message) => ({ + case 'content.part': { + if (fold.openStepUuid !== event.stepUuid) return frame; + return updateOpenAssistant(frame, adapter, (message) => ({ ...message, content: [...message.content, event.part], - })), ctx); + })); + } case 'tool.call': { + if (fold.openStepUuid !== event.stepUuid) return frame; const call: ToolCall = { type: 'function', id: event.toolCallId, @@ -166,82 +192,93 @@ export function foldLoopEvent( arguments: event.args === undefined ? null : JSON.stringify(event.args), ...(event.extras !== undefined ? { extras: event.extras } : {}), }; - ctx.pending.add(event.toolCallId); - return bind(appendToOpenAssistant(state, (message) => ({ + const withPending: FoldFrame = { + ...frame, + fold: { ...fold, pending: [...fold.pending, event.toolCallId] }, + }; + return updateOpenAssistant(withPending, adapter, (message) => ({ ...message, toolCalls: [...message.toolCalls, call], - })), ctx); + })); } case 'tool.result': { - if (!ctx.pending.has(event.toolCallId)) return state; + if (!fold.pending.includes(event.toolCallId)) return frame; const output = event.result.output; const toolMessage: ContextMessage = { ...createToolMessage(event.toolCallId, typeof output === 'string' ? output : [...output]), isError: event.result.isError, note: event.result.note, }; - ctx.pending.delete(event.toolCallId); - return bind(flushDeferred([...state, toolMessage], ctx), ctx); + const next: FoldFrame = { + messages: [...frame.messages, makeEntry(toolMessage)], + fold: { ...fold, pending: fold.pending.filter((id) => id !== event.toolCallId) }, + }; + return flushDeferred(next); } default: - return state; + return frame; } } -export function resetFold(state: readonly ContextMessage[]): readonly ContextMessage[] { - foldCtxMap.set(state, { openStepUuid: undefined, pending: new Set(), deferred: [] }); - return state; -} - -function appendToOpenAssistant( - state: readonly ContextMessage[], +function updateOpenAssistant( + frame: FoldFrame, + adapter: FoldEntryAdapter, update: (message: ContextMessage) => ContextMessage, -): readonly ContextMessage[] { - const index = findOpenAssistantIndex(state); - if (index === -1) return state; - const next = state.slice(); - next[index] = update(next[index]!); - return next; +): FoldFrame { + const index = findOpenAssistantIndex(frame, adapter); + if (index === -1) return frame; + const messages = frame.messages.slice(); + messages[index] = adapter.withMessage(messages[index]!, update(adapter.messageOf(messages[index]!))); + return { ...frame, messages }; } -function settleOpenStep( - state: readonly ContextMessage[], - ctx: FoldCtx, -): readonly ContextMessage[] { - const closed = closePending(state, ctx); - const index = findOpenAssistantIndex(closed); +function settleOpenStep( + frame: FoldFrame, + adapter: FoldEntryAdapter, + makeEntry: (message: ContextMessage) => E, +): FoldFrame { + const closed = closePending(frame, makeEntry); + const index = findOpenAssistantIndex(closed, adapter); if (index === -1) return closed; - const open = closed[index]!; + const open = adapter.messageOf(closed.messages[index]!); if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) { - return [...closed.slice(0, index), ...closed.slice(index + 1)]; + return { + ...closed, + messages: [...closed.messages.slice(0, index), ...closed.messages.slice(index + 1)], + }; } - const next = closed.slice(); - next[index] = { ...open, partial: undefined }; - return next; + const messages = closed.messages.slice(); + messages[index] = adapter.withMessage(messages[index]!, { ...open, partial: undefined }); + return { ...closed, messages }; } -function findOpenAssistantIndex(state: readonly ContextMessage[]): number { - for (let i = state.length - 1; i >= 0; i--) { - if (state[i]!.partial === true) return i; +function findOpenAssistantIndex(frame: FoldFrame, adapter: FoldEntryAdapter): number { + for (let i = frame.messages.length - 1; i >= 0; i--) { + if (adapter.messageOf(frame.messages[i]!).partial === true) return i; } return -1; } -function closePending(state: readonly ContextMessage[], ctx: FoldCtx): readonly ContextMessage[] { - if (ctx.pending.size === 0) return state; - const next = state.slice(); - for (const toolCallId of ctx.pending) { - next.push(interruptedToolMessage(toolCallId)); +function closePending( + frame: FoldFrame, + makeEntry: (message: ContextMessage) => E, +): FoldFrame { + const { fold } = frame; + if (fold.pending.length === 0) return frame; + const messages = frame.messages.slice(); + for (const toolCallId of fold.pending) { + messages.push(makeEntry(interruptedToolMessage(toolCallId))); } - ctx.pending.clear(); - return flushDeferred(next, ctx); + return flushDeferred({ ...frame, messages, fold: { ...fold, pending: [] } }); } -function flushDeferred(state: readonly ContextMessage[], ctx: FoldCtx): readonly ContextMessage[] { - if (ctx.pending.size > 0 || ctx.deferred.length === 0) return state; - const next = [...state, ...ctx.deferred]; - ctx.deferred.length = 0; - return next; +function flushDeferred(frame: FoldFrame): FoldFrame { + const { fold } = frame; + if (fold.pending.length > 0 || fold.deferred.length === 0) return frame; + return { + messages: [...frame.messages, ...fold.deferred], + fold: { ...fold, deferred: [] }, + }; } function interruptedToolMessage(toolCallId: string): ContextMessage { diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 5b8c59cdb3..074f556cd7 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -127,3 +127,49 @@ export interface AgentContextData { history: readonly ContextMessage[]; tokenCount: number; } + +/** + * Fold cursor carried inside `ContextState` — the reduction position of the + * loop-event fold across records. `pending` holds toolCallIds with no result + * yet; `deferred` holds entries appended while a tool exchange is still open + * (flushed once it closes, preserving assistant↔tool adjacency). Plain data: + * arrays instead of Sets so the state stays freeze- and JSON-safe. + * + * Generic over the entry type: the wire model folds `ContextMessage`s, while + * the display transcript folds time-stamped entries through the same kernel. + */ +export interface ContextFoldState { + readonly openStepUuid?: string; + readonly pending: readonly string[]; + readonly deferred: readonly E[]; +} + +export const EMPTY_FOLD: ContextFoldState = Object.freeze({ + pending: Object.freeze([]), + deferred: Object.freeze([]), +}); + +/** + * `ContextModel` state: the folded messages plus the fold cursor. The cursor + * lives in the state (not beside it) so every wholesale replacement — undo, + * clear, compaction — resets it structurally, by returning `EMPTY_FOLD`. + */ +export interface ContextState { + readonly messages: readonly ContextMessage[]; + readonly fold: ContextFoldState; +} + +/** + * Deeply freezes a `ContextState` (the wire service only shallow-freezes the + * top-level object, which covered the consumer view back when the state WAS + * the messages array). `Object.freeze` returns the same reference, so the + * wire's reference-equality gate is unaffected. + */ +export function freezeContextState(state: ContextState): ContextState { + const { fold } = state; + Object.freeze(fold.pending); + Object.freeze(fold.deferred); + Object.freeze(fold); + Object.freeze(state.messages); + return Object.freeze(state); +} diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts index 48987f673f..f9591599a7 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -4,6 +4,14 @@ * Defines wire-safe history projections and an opaque snapshot of the media * identities that a provider rejected, allowing later steps to strip only * that content while preserving newly generated recovery media. + * + * Projection variability is expressed as data: a `ProjectionPolicy` — + * `wire: 'strict'` adds the structural repairs strict providers need + * (duplicate tool calls dropped, consecutive assistants merged, leading + * non-user messages dropped); `media` selects the provider-rejection + * fallback (`'degraded'` replaces all but the most recent media with text + * markers after an HTTP 413; `{ strip }` replaces exactly the snapshotted + * media identities after a rejected-format or still-too-large resend). */ import { createDecorator } from '#/_base/di/instantiation'; @@ -17,17 +25,19 @@ export interface MediaStripSnapshot { readonly [mediaStripSnapshotBrand]: undefined; } +export interface ProjectionPolicy { + readonly wire?: 'default' | 'strict'; + readonly media?: 'keep' | 'degraded' | { readonly strip: MediaStripSnapshot }; +} + export interface IAgentContextProjectorService { readonly _serviceBrand: undefined; - project(messages: readonly ContextMessage[]): readonly Message[]; - projectStrict(messages: readonly ContextMessage[]): readonly Message[]; - projectMediaDegraded(messages: readonly ContextMessage[]): readonly Message[]; - captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot; - projectMediaStripped( + project( messages: readonly ContextMessage[], - snapshot?: MediaStripSnapshot, + policy?: ProjectionPolicy, ): readonly Message[]; + captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot; } export const IAgentContextProjectorService = createDecorator( diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index ca92b6205c..ffedfa4f20 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -15,14 +15,13 @@ * repair-dedup signature (`lastRepairSignature`) is registered into * `agentState` (`IAgentStateService`) and read/written through it. * - * `projectMediaDegraded` / `projectMediaStripped` are the fallback - * projections for the two deterministic provider rejections: media-degraded - * (all but the most recent media replaced by text markers) resends after an - * HTTP 413 body-size rejection; media-stripped captures every media identity - * present when degraded media is still too large or an image format is - * rejected, then replaces only that snapshot on later steps so a newly - * generated recovery image remains visible. Both are read-side only — the - * history keeps its media. + * `policy.media` selects the fallback projections for the two deterministic + * provider rejections: `'degraded'` (all but the most recent media replaced + * by text markers) resends after an HTTP 413 body-size rejection; + * `{ strip }` replaces only the snapshotted media identities present when + * degraded media is still too large or an image format is rejected, so a + * newly generated recovery image remains visible on later steps. Both are + * read-side only — the history keeps its media. */ import { createHash } from 'node:crypto'; @@ -40,6 +39,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentContextProjectorService, type MediaStripSnapshot, + type ProjectionPolicy, } from './contextProjector'; export const contextProjectorLastRepairSignatureKey = defineState( @@ -66,36 +66,24 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi this.states.set(contextProjectorLastRepairSignatureKey, value); } - project(messages: readonly ContextMessage[]): readonly Message[] { - return this.projectWithTrace(messages, project); - } - - projectStrict(messages: readonly ContextMessage[]): readonly Message[] { - return this.projectWithTrace(messages, projectStrict); - } - - projectMediaDegraded(messages: readonly ContextMessage[]): readonly Message[] { - return degradeOlderMediaParts( - this.projectWithTrace(messages, project), - MEDIA_DEGRADE_KEEP_RECENT, + project( + messages: readonly ContextMessage[], + policy: ProjectionPolicy = {}, + ): readonly Message[] { + const projected = this.projectWithTrace( + messages, + policy.wire === 'strict' ? projectStrict : project, ); + const media = policy.media; + if (media === undefined || media === 'keep') return projected; + if (media === 'degraded') return degradeOlderMediaParts(projected, MEDIA_DEGRADE_KEEP_RECENT); + return stripMediaPartsBySnapshot(projected, media.strip); } captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot { return captureMediaStripSnapshot(this.projectWithTrace(messages, project)); } - projectMediaStripped( - messages: readonly ContextMessage[], - snapshot?: MediaStripSnapshot, - ): readonly Message[] { - const projected = this.projectWithTrace(messages, project); - return stripMediaPartsBySnapshot( - projected, - snapshot ?? captureMediaStripSnapshot(projected), - ); - } - private projectWithTrace( messages: readonly ContextMessage[], fn: (history: readonly ContextMessage[], onAnomaly?: (anomaly: ProjectionAnomaly) => void) => Message[], diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 92339a83df..26fd178a6f 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -37,6 +37,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentContextProjectorService, type MediaStripSnapshot, + type ProjectionPolicy, } from '#/agent/contextProjector/contextProjector'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; @@ -333,21 +334,19 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const shaped = this.toolSelect.shapeHistory(request.messages); let mediaStripSnapshot = this.mediaStripSnapshotForTurn(request.source); const requestInput = (projection: RequestProjection) => { + let policy: ProjectionPolicy | undefined; + if (projection === 'strict') { + policy = { wire: 'strict' }; + } else if (projection === 'media-degraded') { + policy = { media: 'degraded' }; + } else if (projection === 'media-stripped') { + mediaStripSnapshot ??= this.projector.captureMediaStripSnapshot(shaped); + policy = { media: { strip: mediaStripSnapshot } }; + } return { systemPrompt: request.systemPrompt, tools: request.tools, - messages: - projection === 'strict' - ? this.projector.projectStrict(shaped) - : projection === 'media-degraded' - ? this.projector.projectMediaDegraded(shaped) - : projection === 'media-stripped' - ? this.projector.projectMediaStripped( - shaped, - (mediaStripSnapshot ??= - this.projector.captureMediaStripSnapshot(shaped)), - ) - : this.projector.project(shaped), + messages: this.projector.project(shaped, policy), }; }; diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts index 4feb38200b..94930c171d 100644 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts @@ -134,7 +134,7 @@ export class AgentTokenCountingService extends Disposable implements IAgentToken } private context(): readonly ContextMessage[] { - return this.wire.getModel(ContextModel) as readonly ContextMessage[]; + return this.wire.getModel(ContextModel).messages as readonly ContextMessage[]; } /** Latest anchor still valid for the live context: anchors beyond it are diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index c96e90900b..eeaabc9414 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -9,11 +9,26 @@ import { describe, expect, it } from 'vitest'; import { + createContextTranscriptReducer, reduceContextTranscript, type ContextTranscript, } from '#/agent/contextMemory/contextTranscript'; -import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; -import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +import { + contextApplyCompaction, + contextClear, + contextUndo, +} from '#/agent/contextMemory/contextOps'; +import { + foldAppendMessage, + foldLoopEvent, + type LoopRecordedEvent, +} from '#/agent/contextMemory/loopEventFold'; +import { + EMPTY_FOLD, + type ContextMessage, + type ContextState, + type PromptOrigin, +} from '#/agent/contextMemory/types'; import type { WireRecord } from '#/wire/record'; function userMessage(text: string, origin?: PromptOrigin): ContextMessage { @@ -292,3 +307,105 @@ describe('reduceContextTranscript', () => { expect(result.foldedLength).toBe(4); }); }); + +describe('transcript/model fold parity', () => { + function applyRecordToModel(state: ContextState, record: WireRecord): ContextState { + switch (record.type) { + case 'context.append_message': + return foldAppendMessage(state, record['message'] as ContextMessage); + case 'context.append_loop_event': + return foldLoopEvent(state, record['event'] as LoopRecordedEvent); + case 'context.undo': + return contextUndo.apply(state, { count: record['count'] as number }); + case 'context.clear': + return contextClear.apply(state, {}); + case 'context.apply_compaction': + return contextApplyCompaction.apply( + state, + record as unknown as Parameters[1], + ); + default: + return state; + } + } + + function currentOf(result: ContextTranscript): readonly ContextMessage[] { + return result.entries.slice(result.entries.length - result.foldedLength); + } + + function comparable(messages: readonly ContextMessage[]): unknown { + return messages.map((m) => ({ + role: m.role, + // The model-facing compaction summary text (contextSummary) differs from + // the display-facing one (summary) by design — mask it. + content: m.origin?.kind === 'compaction_summary' ? '' : m.content, + toolCalls: m.toolCalls, + toolCallId: m.toolCallId, + isError: m.isError, + note: m.note, + origin: m.origin, + partial: m.partial, + })); + } + + // Asserts after EVERY prefix of the stream that the transcript's current + // conversation (the tail of `foldedLength` entries) matches the model fold. + // Compaction pauses the check until the next clear: the display's + // foldedLength is a UI collapse metric (kept users + summary marker, and + // legacy records put the summary first in the model but last in the + // display), so the two views diverge there BY DESIGN; clear realigns them. + function expectParity(records: WireRecord[]): void { + let state: ContextState = { messages: [], fold: EMPTY_FOLD }; + const reducer = createContextTranscriptReducer(); + let aligned = true; + for (const record of records) { + state = applyRecordToModel(state, record); + reducer.add(record); + if (record.type === 'context.apply_compaction') aligned = false; + if (record.type === 'context.clear') aligned = true; + if (!aligned) continue; + const result = reducer.result(); + expect(comparable(currentOf(result))).toEqual(comparable(state.messages)); + } + } + + it('matches across retries, mid-exchange deferral, undo, compaction, and clear', () => { + expectParity([ + appendMessage(userMessage('u1', { kind: 'user' })), + ...assistantStep('s1', 'a1'), + // s2: a failed attempt left open (content + unanswered tool call)… + loopEvent({ type: 'step.begin', uuid: 's2' }), + loopEvent({ type: 'content.part', stepUuid: 's2', part: { type: 'text', text: 'half' } }), + loopEvent({ type: 'tool.call', stepUuid: 's2', toolCallId: 'c1', name: 'Bash', args: {} }), + // …settled by the retry's step.begin, then s3 runs a full exchange… + loopEvent({ type: 'step.begin', uuid: 's3' }), + loopEvent({ type: 'content.part', stepUuid: 's3', part: { type: 'text', text: 'a3' } }), + loopEvent({ type: 'tool.call', stepUuid: 's3', toolCallId: 'c2', name: 'Read', args: {} }), + // …during which a plain append is deferred until the exchange closes. + appendMessage(userMessage('injected mid-exchange')), + loopEvent({ type: 'tool.result', toolCallId: 'c2', result: { output: 'ok' } }), + loopEvent({ type: 'step.end', uuid: 's3' }), + appendMessage(userMessage('u2', { kind: 'user' })), + undo(1), + // keptUserMessageCount = the 2 compactable user messages ('u1' and the + // origin-less 'injected mid-exchange') so the model keeps both. + compaction('SUM', 7, 2), + appendMessage(userMessage('u3', { kind: 'user' })), + { type: 'context.clear' }, + ...assistantStep('s4', 'a4'), + ]); + }); + + it('matches when a tool exchange is interrupted without a retry', () => { + expectParity([ + appendMessage(userMessage('q', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'tool.call', stepUuid: 's1', toolCallId: 'c1', name: 'Bash', args: {} }), + // resume without a result: the next user message closes the exchange… + appendMessage(userMessage('next', { kind: 'user' })), + ...assistantStep('s2', 'done'), + undo(1), + undo(1), + ]); + }); +}); diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index 22915c6d11..fa41f21068 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -23,7 +23,7 @@ import { contextClear, contextUndo, } from '#/agent/contextMemory/contextOps'; -import type { ContextMessage } from '#/agent/contextMemory/types'; +import { EMPTY_FOLD, type ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import type { ContentPart } from '#/kosong/contract/message'; @@ -186,7 +186,7 @@ afterEach(() => disposables.dispose()); describe('AgentContextMemoryService (wire-backed)', () => { it('splice/append/undo/apply_compaction/clear/append_loop_event each update getModel with a NEW reference and persist flat records', async () => { const host = buildHost(KEY); - const model = () => host.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = () => host.wire.getModel(ContextModel).messages as readonly ContextMessage[]; host.wire.dispatch( contextAppendMessage({ message: userMessage('a') }), @@ -203,6 +203,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { host.wire.dispatch(contextUndo({ count: 1 })); expect(model()).not.toBe(prev); expect(model()).toHaveLength(2); + expect(host.wire.getModel(ContextModel).fold).toEqual(EMPTY_FOLD); prev = model(); host.wire.dispatch( @@ -215,11 +216,13 @@ describe('AgentContextMemoryService (wire-backed)', () => { content: [{ type: 'text', text: 'sum' }], origin: { kind: 'compaction_summary' }, }); + expect(host.wire.getModel(ContextModel).fold).toEqual(EMPTY_FOLD); prev = model(); host.wire.dispatch(contextClear({})); expect(model()).not.toBe(prev); expect(model()).toHaveLength(0); + expect(host.wire.getModel(ContextModel).fold).toEqual(EMPTY_FOLD); await host.wire.flush(); const records = await readRecords(host.log); @@ -282,7 +285,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); - const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(model.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']); expect(model[1]!.content).toEqual([{ type: 'text', text: 'hello' }]); expect(model[1]!.partial).toBeUndefined(); @@ -315,7 +318,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); - const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(model.map(textOf)).toEqual(['model-facing summary', 'tail']); expect(model[0]).toMatchObject({ role: 'user', @@ -354,7 +357,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); - const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']); expect(model.map(textOf)).toEqual(['old user', 'recent user', 'model-facing summary']); expect(model[2]).toMatchObject({ @@ -384,7 +387,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); - const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']); expect(model[2]).toMatchObject({ role: 'user', @@ -417,7 +420,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); - const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const model = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(model).toHaveLength(2); expect(model[0]).toEqual(legacySummary); expect(textOf(model[1]!)).toBe('tail'); @@ -431,7 +434,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { host.wire.dispatch(contextAppendMessage({ message: imageMessage(big) })); await host.wire.flush(); - const live = host.wire.getModel(ContextModel) as readonly ContextMessage[]; + const live = host.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(live).toHaveLength(1); expect(mediaUrl(live[0]!)).toBe(dataUri); @@ -452,7 +455,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { ); expect(blob.loadCalls).toBeGreaterThanOrEqual(1); - const rebuilt = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; + const rebuilt = replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]; expect(rebuilt).toEqual(live); expect(mediaUrl(rebuilt[0]!)).toBe(dataUri); }); @@ -482,7 +485,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { records, ); expect(replayed).toHaveLength(0); - expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2); + expect(replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]).toHaveLength(2); }); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts index acbe72909f..1a760d657e 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -5,7 +5,11 @@ import { contextUndo, isFullyUndoable, } from '#/agent/contextMemory/contextOps'; -import type { ContextMessage } from '#/agent/contextMemory/types'; +import { + EMPTY_FOLD, + type ContextMessage, + type ContextState, +} from '#/agent/contextMemory/types'; function text(value: string): { type: 'text'; text: string } { return { type: 'text', text: value }; @@ -98,27 +102,32 @@ describe('computeUndoCut', () => { }); describe('contextUndo op', () => { + function stateOf(messages: readonly ContextMessage[]): ContextState { + return { messages, fold: EMPTY_FOLD }; + } + it('slices the history at the cut point, dropping post-cut injections too', () => { - const state = [ + const state = stateOf([ user(USER_ORIGIN), assistant(), user(USER_ORIGIN), injection(), assistant(), - ]; + ]); const next = contextUndo.apply(state, { count: 1 }); - expect(next).toEqual([user(USER_ORIGIN), assistant()]); + expect(next.messages).toEqual([user(USER_ORIGIN), assistant()]); + expect(next.fold).toBe(EMPTY_FOLD); }); it('returns the same reference when not fully undoable', () => { - const state = [user(USER_ORIGIN), compaction(), assistant()]; + const state = stateOf([user(USER_ORIGIN), compaction(), assistant()]); expect(contextUndo.apply(state, { count: 1 })).toBe(state); }); it.each([0, 0.5, Number.MAX_SAFE_INTEGER + 1])( 'returns the same reference for invalid count %s', (count) => { - const state = [user(USER_ORIGIN), assistant()]; + const state = stateOf([user(USER_ORIGIN), assistant()]); expect(contextUndo.apply(state, { count })).toBe(state); }, ); diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index ad501f1c08..8e3a20063e 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -137,7 +137,7 @@ describe('projector tool-exchange normalization', () => { } function projectStrict(history: readonly ContextMessage[]): readonly Message[] { - return projector.projectStrict(history); + return projector.project(history, { wire: 'strict' }); } it('leaves a fully resolved exchange untouched', () => { @@ -667,13 +667,16 @@ describe('projector tool-exchange normalization', () => { } it('keeps the two most recent media parts and replaces older ones with markers', () => { - const projected = projector.projectMediaDegraded([ - imageMessage('data:image/png;base64,OLD1'), - user('middle'), - imageMessage('data:image/png;base64,OLD2'), - imageMessage('data:image/png;base64,KEEP1'), - imageMessage('data:image/png;base64,KEEP2'), - ]); + const projected = projector.project( + [ + imageMessage('data:image/png;base64,OLD1'), + user('middle'), + imageMessage('data:image/png;base64,OLD2'), + imageMessage('data:image/png;base64,KEEP1'), + imageMessage('data:image/png;base64,KEEP2'), + ], + { media: 'degraded' }, + ); const urls = projected .flatMap((message) => message.content) @@ -690,10 +693,10 @@ describe('projector tool-exchange normalization', () => { }); it('returns the projected messages untouched when media fits within keep-recent', () => { - const projected = projector.projectMediaDegraded([ - user('text'), - imageMessage('data:image/png;base64,AAAA'), - ]); + const projected = projector.project( + [user('text'), imageMessage('data:image/png;base64,AAAA')], + { media: 'degraded' }, + ); const allParts = projected.flatMap((message) => message.content); expect(allParts.some((part) => part.type === 'image_url')).toBe(true); }); @@ -709,8 +712,15 @@ describe('projector tool-exchange normalization', () => { }; } + function projectStripped( + history: readonly ContextMessage[], + snapshot = projector.captureMediaStripSnapshot(history), + ): readonly Message[] { + return projector.project(history, { media: { strip: snapshot } }); + } + it('replaces every media part with a text marker, keeping the surrounding text', () => { - const projected = projector.projectMediaStripped([ + const projected = projectStripped([ user('look at these'), imageMessage('data:image/png;base64,AAAA'), { @@ -742,7 +752,7 @@ describe('projector tool-exchange normalization', () => { }); it('returns the projected messages untouched when there is no media', () => { - const projected = projector.projectMediaStripped([user('just text')]); + const projected = projectStripped([user('just text')]); expect(projected).toEqual(project([user('just text')])); }); @@ -750,7 +760,7 @@ describe('projector tool-exchange normalization', () => { const rejected = imageMessage('data:image/png;base64,OLD', 'old-id'); const snapshot = projector.captureMediaStripSnapshot([rejected]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [rejected, imageMessage('data:image/png;base64,NEW', 'new-id')], snapshot, ); @@ -776,7 +786,7 @@ describe('projector tool-exchange normalization', () => { orphan, ]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage(url, 'orphan-id')], snapshot, ); @@ -793,7 +803,7 @@ describe('projector tool-exchange normalization', () => { imageMessage('data:image/png;base64,SAME', 'same-id'), ]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage('data:image/png;base64,SAME', 'same-id')], snapshot, ); @@ -809,7 +819,7 @@ describe('projector tool-exchange normalization', () => { const url = 'https://example.test/media/image.png'; const snapshot = projector.captureMediaStripSnapshot([imageMessage(url, 'old-id')]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage(url, 'new-id')], snapshot, ); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index a58079cc87..0ecec3dbc5 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -129,15 +129,8 @@ afterEach(() => disposables.dispose()); function createService( requester: ModelRequester, projector: - | (Pick & - Partial< - Pick< - IAgentContextProjectorService, - | 'captureMediaStripSnapshot' - | 'projectMediaDegraded' - | 'projectMediaStripped' - > - >) + | (Pick & + Partial>) | undefined, options: { readonly thinkingLevel?: ThinkingEffort; @@ -205,8 +198,6 @@ function createService( } else { ix.stub(IAgentContextProjectorService, { captureMediaStripSnapshot: () => testSnapshot, - projectMediaDegraded: projector.project, - projectMediaStripped: projector.project, ...projector, }); } @@ -301,12 +292,9 @@ describe('AgentLLMRequesterService strict resend', () => { let projectCalls = 0; let strictCalls = 0; const { service } = createService(createRequester(calls), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.wire === 'strict') strictCalls += 1; + else projectCalls += 1; return messages; }, }); @@ -331,9 +319,8 @@ describe('AgentLLMRequesterService strict resend', () => { }); let strictCalls = 0; const { service } = createService(requester, { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.wire === 'strict') strictCalls += 1; return messages; }, }); @@ -357,16 +344,9 @@ describe('AgentLLMRequesterService media-stripped resend', () => { let strictCalls = 0; let strippedCalls = 0; const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (typeof policy?.media === 'object') strippedCalls += 1; + else projectCalls += 1; return messages; }, }); @@ -385,13 +365,9 @@ describe('AgentLLMRequesterService media-stripped resend', () => { let projectCalls = 0; let strippedCalls = 0; const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (typeof policy?.media === 'object') strippedCalls += 1; + else projectCalls += 1; return messages; }, }); @@ -413,10 +389,8 @@ describe('AgentLLMRequesterService media-stripped resend', () => { const { service } = createService( createRequester(calls, new APIStatusError(400, 'some other validation problem')), { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (typeof policy?.media === 'object') strippedCalls += 1; return messages; }, }, @@ -444,17 +418,10 @@ describe('AgentLLMRequesterService media-degraded resend', () => { }), ), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.media === 'degraded') degradedCalls += 1; + else if (typeof policy?.media === 'object') strippedCalls += 1; + else projectCalls += 1; return messages; }, }, @@ -477,17 +444,10 @@ describe('AgentLLMRequesterService media-degraded resend', () => { const { service } = createService( createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.media === 'degraded') degradedCalls += 1; + else if (typeof policy?.media === 'object') strippedCalls += 1; + else projectCalls += 1; return messages; }, }, @@ -508,9 +468,6 @@ describe('AgentLLMRequesterService media-degraded resend', () => { createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]), { project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => messages, }, ); @@ -573,17 +530,10 @@ describe('AgentLLMRequesterService media-degraded resend', () => { const { service } = createService( createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413, BODY_TOO_LARGE_413]), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.media === 'degraded') degradedCalls += 1; + else if (typeof policy?.media === 'object') strippedCalls += 1; + else projectCalls += 1; return messages; }, }, @@ -603,13 +553,9 @@ describe('AgentLLMRequesterService media-degraded resend', () => { let projectCalls = 0; let degradedCalls = 0; const { service } = createService(createRequester(calls, BODY_TOO_LARGE_413), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.media === 'degraded') degradedCalls += 1; + else projectCalls += 1; return messages; }, }); @@ -633,10 +579,8 @@ describe('AgentLLMRequesterService media-degraded resend', () => { const calls = { value: 0 }; let degradedCalls = 0; const { service } = createService(createRequester(calls, error), { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; + project: (messages: readonly ContextMessage[], policy) => { + if (policy?.media === 'degraded') degradedCalls += 1; return messages; }, }); @@ -651,7 +595,6 @@ describe('AgentLLMRequesterService media-degraded resend', () => { describe('AgentLLMRequesterService trace id', () => { const passthroughProjector = { project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, }; function createTracedRequester(traceId: string | null): ModelRequester { From 2d419454dfa9df4735668a4b8f46a2c9a3bfcf75 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 13 Aug 2026 14:01:36 +0800 Subject: [PATCH 02/16] test(agent-core-v2): move fold parity rationales into the test file header --- .../contextMemory/contextTranscript.test.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index eeaabc9414..f7d21c10a1 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -4,6 +4,18 @@ * compaction keeps the prefix and appends a summary marker; undo removes the * tail but stops at compaction summaries / clear floors; clear keeps the * transcript but resets the folded view. + * + * The `transcript/model fold parity` block replays one record stream through + * both the transcript reducer and the model fold (`foldAppendMessage` / + * `foldLoopEvent` / `contextOps`) and asserts after EVERY record prefix that + * the transcript's current conversation (the tail of `foldedLength` entries) + * matches the model's messages. Two divergences are by design: the + * model-facing compaction summary text (`contextSummary`) differs from the + * display-facing one (`summary`), so comparisons mask summary content; and + * compaction pauses the check until the next clear, because the display's + * `foldedLength` is a UI collapse metric (kept users + summary marker, and + * legacy records put the summary first in the model but last in the display) — + * clear realigns the two views. */ import { describe, expect, it } from 'vitest'; @@ -336,8 +348,6 @@ describe('transcript/model fold parity', () => { function comparable(messages: readonly ContextMessage[]): unknown { return messages.map((m) => ({ role: m.role, - // The model-facing compaction summary text (contextSummary) differs from - // the display-facing one (summary) by design — mask it. content: m.origin?.kind === 'compaction_summary' ? '' : m.content, toolCalls: m.toolCalls, toolCallId: m.toolCallId, @@ -348,12 +358,6 @@ describe('transcript/model fold parity', () => { })); } - // Asserts after EVERY prefix of the stream that the transcript's current - // conversation (the tail of `foldedLength` entries) matches the model fold. - // Compaction pauses the check until the next clear: the display's - // foldedLength is a UI collapse metric (kept users + summary marker, and - // legacy records put the summary first in the model but last in the - // display), so the two views diverge there BY DESIGN; clear realigns them. function expectParity(records: WireRecord[]): void { let state: ContextState = { messages: [], fold: EMPTY_FOLD }; const reducer = createContextTranscriptReducer(); @@ -373,22 +377,17 @@ describe('transcript/model fold parity', () => { expectParity([ appendMessage(userMessage('u1', { kind: 'user' })), ...assistantStep('s1', 'a1'), - // s2: a failed attempt left open (content + unanswered tool call)… loopEvent({ type: 'step.begin', uuid: 's2' }), loopEvent({ type: 'content.part', stepUuid: 's2', part: { type: 'text', text: 'half' } }), loopEvent({ type: 'tool.call', stepUuid: 's2', toolCallId: 'c1', name: 'Bash', args: {} }), - // …settled by the retry's step.begin, then s3 runs a full exchange… loopEvent({ type: 'step.begin', uuid: 's3' }), loopEvent({ type: 'content.part', stepUuid: 's3', part: { type: 'text', text: 'a3' } }), loopEvent({ type: 'tool.call', stepUuid: 's3', toolCallId: 'c2', name: 'Read', args: {} }), - // …during which a plain append is deferred until the exchange closes. appendMessage(userMessage('injected mid-exchange')), loopEvent({ type: 'tool.result', toolCallId: 'c2', result: { output: 'ok' } }), loopEvent({ type: 'step.end', uuid: 's3' }), appendMessage(userMessage('u2', { kind: 'user' })), undo(1), - // keptUserMessageCount = the 2 compactable user messages ('u1' and the - // origin-less 'injected mid-exchange') so the model keeps both. compaction('SUM', 7, 2), appendMessage(userMessage('u3', { kind: 'user' })), { type: 'context.clear' }, @@ -401,7 +400,6 @@ describe('transcript/model fold parity', () => { appendMessage(userMessage('q', { kind: 'user' })), loopEvent({ type: 'step.begin', uuid: 's1' }), loopEvent({ type: 'tool.call', stepUuid: 's1', toolCallId: 'c1', name: 'Bash', args: {} }), - // resume without a result: the next user message closes the exchange… appendMessage(userMessage('next', { kind: 'user' })), ...assistantStep('s2', 'done'), undo(1), From e83ad3f20d224ce368a6f09334ca1057cde3f869 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 13 Aug 2026 14:13:00 +0800 Subject: [PATCH 03/16] docs(agent-core-v2): move fold declaration comments into module headers --- .../src/agent/contextMemory/loopEventFold.ts | 14 +++---- .../src/agent/contextMemory/types.ts | 42 +++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index f62e61997a..20cd308015 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -42,11 +42,13 @@ * wholesale state replacement (undo / clear / compaction) resets the cursor * structurally by returning `EMPTY_FOLD`. * - * The kernel is generic over the entry type (`FoldFrame` + - * `FoldEntryAdapter`): the wire model folds bare `ContextMessage`s - * (`foldAppendMessage` / `foldLoopEvent` specializations below), while the - * display transcript (`contextTranscript.ts`) folds time-stamped entries — - * one reduction semantics, two read models. + * The kernel is generic over the entry type: a `FoldFrame` pairs the + * entries reduced so far with the fold cursor, and a `FoldEntryAdapter` + * is how the kernel reads and rewrites the message an entry carries. The + * wire model folds bare `ContextMessage`s (`foldAppendMessage` / + * `foldLoopEvent` specializations below), while the display transcript + * (`contextTranscript.ts`) folds time-stamped entries — one reduction + * semantics, two read models. */ import type { FinishReason } from '#/kosong/contract/provider'; @@ -113,13 +115,11 @@ export type LoopRecordedEvent = readonly parentUuid?: string; }; -/** A fold position: the entries reduced so far plus the fold cursor. */ export interface FoldFrame { readonly messages: readonly E[]; readonly fold: ContextFoldState; } -/** How the kernel reads and rewrites the message carried by an entry. */ export interface FoldEntryAdapter { readonly messageOf: (entry: E) => ContextMessage; readonly withMessage: (entry: E, message: ContextMessage) => E; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 074f556cd7..2263b52494 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -1,3 +1,24 @@ +/** + * `contextMemory` shared contract types — message origins, `AgentContextData`, + * and the `ContextModel` fold state. + * + * `ContextState` is the `ContextModel` state: the folded messages plus the + * fold cursor (`ContextFoldState` — the reduction position of the loop-event + * fold across records: `pending` holds toolCallIds with no result yet, + * `deferred` holds entries appended while a tool exchange is still open, + * flushed once it closes to preserve assistant↔tool adjacency). The cursor + * lives in the state (not beside it) so every wholesale replacement — undo, + * clear, compaction — resets it structurally by returning `EMPTY_FOLD`. + * Plain data: arrays instead of Sets keep the state freeze- and JSON-safe. + * Generic over the entry type: the wire model folds `ContextMessage`s, while + * the display transcript folds time-stamped entries through the same kernel. + * + * `freezeContextState` deeply freezes a `ContextState` (the wire service only + * shallow-freezes the top-level object, which covered the consumer view back + * when the state WAS the messages array). `Object.freeze` returns the same + * reference, so the wire's reference-equality gate is unaffected. + */ + import type { ContentPart, Message } from '#/kosong/contract/message'; import type { AgentTaskStatus } from '#/agent/task/task'; @@ -128,16 +149,6 @@ export interface AgentContextData { tokenCount: number; } -/** - * Fold cursor carried inside `ContextState` — the reduction position of the - * loop-event fold across records. `pending` holds toolCallIds with no result - * yet; `deferred` holds entries appended while a tool exchange is still open - * (flushed once it closes, preserving assistant↔tool adjacency). Plain data: - * arrays instead of Sets so the state stays freeze- and JSON-safe. - * - * Generic over the entry type: the wire model folds `ContextMessage`s, while - * the display transcript folds time-stamped entries through the same kernel. - */ export interface ContextFoldState { readonly openStepUuid?: string; readonly pending: readonly string[]; @@ -149,22 +160,11 @@ export const EMPTY_FOLD: ContextFoldState = Object.freeze({ deferred: Object.freeze([]), }); -/** - * `ContextModel` state: the folded messages plus the fold cursor. The cursor - * lives in the state (not beside it) so every wholesale replacement — undo, - * clear, compaction — resets it structurally, by returning `EMPTY_FOLD`. - */ export interface ContextState { readonly messages: readonly ContextMessage[]; readonly fold: ContextFoldState; } -/** - * Deeply freezes a `ContextState` (the wire service only shallow-freezes the - * top-level object, which covered the consumer view back when the state WAS - * the messages array). `Object.freeze` returns the same reference, so the - * wire's reference-equality gate is unaffected. - */ export function freezeContextState(state: ContextState): ContextState { const { fold } = state; Object.freeze(fold.pending); From 2b47fe707e9d42bc404e176194004336552276a7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 13 Aug 2026 16:21:08 +0800 Subject: [PATCH 04/16] refactor(agent-core-v2): merge FoldFrame into generic ContextState --- .../agent/contextMemory/contextTranscript.ts | 33 +++--- .../src/agent/contextMemory/loopEventFold.ts | 105 +++++++++--------- .../src/agent/contextMemory/types.ts | 6 +- 3 files changed, 69 insertions(+), 75 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 2e441044d2..7514719ef9 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -25,10 +25,9 @@ import { appendMessageTo, applyLoopEventTo, type FoldEntryAdapter, - type FoldFrame, type LoopRecordedEvent, } from './loopEventFold'; -import { EMPTY_FOLD, type ContextMessage } from './types'; +import { EMPTY_FOLD, type ContextMessage, type ContextState } from './types'; export interface ContextTranscript { readonly entries: readonly ContextMessage[]; @@ -58,18 +57,18 @@ export function reduceContextTranscript(records: Iterable): ContextT } export function createContextTranscriptReducer(): ContextTranscriptReducer { - let frame: FoldFrame = { messages: [], fold: EMPTY_FOLD }; + let state: ContextState = { messages: [], fold: EMPTY_FOLD }; let foldedLength = 0; let clearFloor = 0; - const applyKernel = (next: FoldFrame): void => { - foldedLength += next.messages.length - frame.messages.length; - frame = next; + const applyKernel = (next: ContextState): void => { + foldedLength += next.messages.length - state.messages.length; + state = next; }; const applyUndo = (count: number): void => { if (count <= 0) return; - const entries = frame.messages.slice(); + const entries = state.messages.slice(); let removedUserCount = 0; let removed = 0; for (let i = entries.length - 1; i >= clearFloor; i--) { @@ -94,14 +93,14 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { } } foldedLength = Math.max(0, foldedLength - removed); - frame = { messages: entries, fold: EMPTY_FOLD }; + state = { messages: entries, fold: EMPTY_FOLD }; }; const add = (record: WireRecord): void => { switch (record.type) { case 'context.append_message': { applyKernel( - appendMessageTo(frame, { + appendMessageTo(state, { message: record['message'] as ContextMessage, time: record.time, }), @@ -112,7 +111,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const time = record.time; applyKernel( applyLoopEventTo( - frame, + state, record['event'] as LoopRecordedEvent, entryAdapter, (message): TranscriptEntry => ({ message, time }), @@ -127,20 +126,20 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { toolCalls: [], origin: { kind: 'compaction_summary' }, }; - frame = { - messages: [...frame.messages, { message: summary, time: record.time }], + state = { + messages: [...state.messages, { message: summary, time: record.time }], fold: EMPTY_FOLD, }; - foldedLength = recoverFoldedLength(record, frame.messages, clearFloor, foldedLength); + foldedLength = recoverFoldedLength(record, state.messages, clearFloor, foldedLength); return; } case 'context.undo': applyUndo(record['count'] as number); return; case 'context.clear': - clearFloor = frame.messages.length; + clearFloor = state.messages.length; foldedLength = 0; - frame = { messages: frame.messages, fold: EMPTY_FOLD }; + state = { messages: state.messages, fold: EMPTY_FOLD }; return; default: return; @@ -150,8 +149,8 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { return { add, result: () => ({ - entries: frame.messages.map((entry) => entry.message), - times: frame.messages.map((entry) => entry.time), + entries: state.messages.map((entry) => entry.message), + times: state.messages.map((entry) => entry.time), foldedLength, }), }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 20cd308015..6f7066ce1d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -37,25 +37,25 @@ * `step.end` only settles the step it names. * * The fold is stateful across records within one replay; the cursor - * (`openStepUuid` / `pending` / `deferred`) is part of the frame's `fold` + * (`openStepUuid` / `pending` / `deferred`) is part of the state's `fold` * field, so live dispatch and replay share one pure transition, and every * wholesale state replacement (undo / clear / compaction) resets the cursor * structurally by returning `EMPTY_FOLD`. * - * The kernel is generic over the entry type: a `FoldFrame` pairs the - * entries reduced so far with the fold cursor, and a `FoldEntryAdapter` - * is how the kernel reads and rewrites the message an entry carries. The - * wire model folds bare `ContextMessage`s (`foldAppendMessage` / - * `foldLoopEvent` specializations below), while the display transcript - * (`contextTranscript.ts`) folds time-stamped entries — one reduction - * semantics, two read models. + * The kernel is generic over the entry type: it reduces one `ContextState` + * — the entries folded so far plus the fold cursor — into the next, and a + * `FoldEntryAdapter` is how the kernel reads and rewrites the message an + * entry carries. The wire model folds bare `ContextMessage`s + * (`foldAppendMessage` / `foldLoopEvent` specializations below), while the + * display transcript (`contextTranscript.ts`) folds time-stamped entries — + * one reduction semantics, two read models. */ import type { FinishReason } from '#/kosong/contract/provider'; import { createToolMessage, type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { TokenUsage } from '#/kosong/contract/usage'; -import type { ContextFoldState, ContextMessage, ContextState } from './types'; +import type { ContextMessage, ContextState } from './types'; import { isVacuousContentPart } from './vacuousContent'; const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = @@ -115,11 +115,6 @@ export type LoopRecordedEvent = readonly parentUuid?: string; }; -export interface FoldFrame { - readonly messages: readonly E[]; - readonly fold: ContextFoldState; -} - export interface FoldEntryAdapter { readonly messageOf: (entry: E) => ContextMessage; readonly withMessage: (entry: E, message: ContextMessage) => E; @@ -138,24 +133,24 @@ export function foldLoopEvent(state: ContextState, event: LoopRecordedEvent): Co return applyLoopEventTo(state, event, messageAdapter, (message) => message); } -export function appendMessageTo(frame: FoldFrame, entry: E): FoldFrame { - const { fold } = frame; +export function appendMessageTo(state: ContextState, entry: E): ContextState { + const { fold } = state; if (fold.pending.length > 0) { - return { ...frame, fold: { ...fold, deferred: [...fold.deferred, entry] } }; + return { ...state, fold: { ...fold, deferred: [...fold.deferred, entry] } }; } - return { ...frame, messages: [...frame.messages, entry] }; + return { ...state, messages: [...state.messages, entry] }; } export function applyLoopEventTo( - frame: FoldFrame, + state: ContextState, event: LoopRecordedEvent, adapter: FoldEntryAdapter, makeEntry: (message: ContextMessage) => E, -): FoldFrame { - const { fold } = frame; +): ContextState { + const { fold } = state; switch (event.type) { case 'step.begin': { - const settled = settleOpenStep(frame, adapter, makeEntry); + const settled = settleOpenStep(state, adapter, makeEntry); const assistant: ContextMessage = { role: 'assistant', content: [], @@ -168,23 +163,23 @@ export function applyLoopEventTo( }; } case 'step.end': { - if (fold.openStepUuid !== event.uuid) return flushDeferred(frame); + if (fold.openStepUuid !== event.uuid) return flushDeferred(state); const settled = settleOpenStep( - { ...frame, fold: { ...fold, openStepUuid: undefined } }, + { ...state, fold: { ...fold, openStepUuid: undefined } }, adapter, makeEntry, ); return flushDeferred(settled); } case 'content.part': { - if (fold.openStepUuid !== event.stepUuid) return frame; - return updateOpenAssistant(frame, adapter, (message) => ({ + if (fold.openStepUuid !== event.stepUuid) return state; + return updateOpenAssistant(state, adapter, (message) => ({ ...message, content: [...message.content, event.part], })); } case 'tool.call': { - if (fold.openStepUuid !== event.stepUuid) return frame; + if (fold.openStepUuid !== event.stepUuid) return state; const call: ToolCall = { type: 'function', id: event.toolCallId, @@ -192,8 +187,8 @@ export function applyLoopEventTo( arguments: event.args === undefined ? null : JSON.stringify(event.args), ...(event.extras !== undefined ? { extras: event.extras } : {}), }; - const withPending: FoldFrame = { - ...frame, + const withPending: ContextState = { + ...state, fold: { ...fold, pending: [...fold.pending, event.toolCallId] }, }; return updateOpenAssistant(withPending, adapter, (message) => ({ @@ -202,42 +197,42 @@ export function applyLoopEventTo( })); } case 'tool.result': { - if (!fold.pending.includes(event.toolCallId)) return frame; + if (!fold.pending.includes(event.toolCallId)) return state; const output = event.result.output; const toolMessage: ContextMessage = { ...createToolMessage(event.toolCallId, typeof output === 'string' ? output : [...output]), isError: event.result.isError, note: event.result.note, }; - const next: FoldFrame = { - messages: [...frame.messages, makeEntry(toolMessage)], + const next: ContextState = { + messages: [...state.messages, makeEntry(toolMessage)], fold: { ...fold, pending: fold.pending.filter((id) => id !== event.toolCallId) }, }; return flushDeferred(next); } default: - return frame; + return state; } } function updateOpenAssistant( - frame: FoldFrame, + state: ContextState, adapter: FoldEntryAdapter, update: (message: ContextMessage) => ContextMessage, -): FoldFrame { - const index = findOpenAssistantIndex(frame, adapter); - if (index === -1) return frame; - const messages = frame.messages.slice(); +): ContextState { + const index = findOpenAssistantIndex(state, adapter); + if (index === -1) return state; + const messages = state.messages.slice(); messages[index] = adapter.withMessage(messages[index]!, update(adapter.messageOf(messages[index]!))); - return { ...frame, messages }; + return { ...state, messages }; } function settleOpenStep( - frame: FoldFrame, + state: ContextState, adapter: FoldEntryAdapter, makeEntry: (message: ContextMessage) => E, -): FoldFrame { - const closed = closePending(frame, makeEntry); +): ContextState { + const closed = closePending(state, makeEntry); const index = findOpenAssistantIndex(closed, adapter); if (index === -1) return closed; const open = adapter.messageOf(closed.messages[index]!); @@ -252,31 +247,31 @@ function settleOpenStep( return { ...closed, messages }; } -function findOpenAssistantIndex(frame: FoldFrame, adapter: FoldEntryAdapter): number { - for (let i = frame.messages.length - 1; i >= 0; i--) { - if (adapter.messageOf(frame.messages[i]!).partial === true) return i; +function findOpenAssistantIndex(state: ContextState, adapter: FoldEntryAdapter): number { + for (let i = state.messages.length - 1; i >= 0; i--) { + if (adapter.messageOf(state.messages[i]!).partial === true) return i; } return -1; } function closePending( - frame: FoldFrame, + state: ContextState, makeEntry: (message: ContextMessage) => E, -): FoldFrame { - const { fold } = frame; - if (fold.pending.length === 0) return frame; - const messages = frame.messages.slice(); +): ContextState { + const { fold } = state; + if (fold.pending.length === 0) return state; + const messages = state.messages.slice(); for (const toolCallId of fold.pending) { messages.push(makeEntry(interruptedToolMessage(toolCallId))); } - return flushDeferred({ ...frame, messages, fold: { ...fold, pending: [] } }); + return flushDeferred({ ...state, messages, fold: { ...fold, pending: [] } }); } -function flushDeferred(frame: FoldFrame): FoldFrame { - const { fold } = frame; - if (fold.pending.length > 0 || fold.deferred.length === 0) return frame; +function flushDeferred(state: ContextState): ContextState { + const { fold } = state; + if (fold.pending.length > 0 || fold.deferred.length === 0) return state; return { - messages: [...frame.messages, ...fold.deferred], + messages: [...state.messages, ...fold.deferred], fold: { ...fold, deferred: [] }, }; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 2263b52494..29d2361769 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -160,9 +160,9 @@ export const EMPTY_FOLD: ContextFoldState = Object.freeze({ deferred: Object.freeze([]), }); -export interface ContextState { - readonly messages: readonly ContextMessage[]; - readonly fold: ContextFoldState; +export interface ContextState { + readonly messages: readonly E[]; + readonly fold: ContextFoldState; } export function freezeContextState(state: ContextState): ContextState { From e7fe16e64fcd78a305ef1f2261c655f623864c4b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 13 Aug 2026 19:28:44 +0800 Subject: [PATCH 05/16] refactor(agent-core-v2): compile-enforce part/event handling decisions in context memory - isVacuousContentPart and dehydrateRecord now switch exhaustively over ContentPart / LoopRecordedEvent variants, so a new variant fails compilation until it takes an explicit position - the transcript/model parity comparator spreads whole messages and masks only summary content, so new ContextMessage fields join the comparison automatically - correct two stale header comments: local message ids persist with append_message records, and undo's prompt-owned-injection pairing depends on them after a resume --- .../src/agent/contextMemory/contextOps.ts | 73 +++++++------------ .../src/agent/contextMemory/messageId.ts | 12 +-- .../src/agent/contextMemory/vacuousContent.ts | 18 ++++- .../contextMemory/contextTranscript.test.ts | 8 +- 4 files changed, 50 insertions(+), 61 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 8a15d99076..9310431248 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -15,8 +15,10 @@ * out-of-band reset to forget. * * The live write path emits the v1 Ops: non-loop appends (user prompts, - * injections, hook/task notices) go on the wire as `append_message` (persisted - * without local ids — the on-disk record matches v1's field set), while the + * injections, hook/task notices) go on the wire as `append_message` (the + * message persists whole, local ids included — undo pairs prompt-owned + * injections with their prompt by `id`, including after a resume; v1 + * readers ignore fields beyond their own field set), while the * agent loop streams each turn as `context.append_loop_event` records — the * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds * them into assistant / tool messages both at live dispatch time and on @@ -51,7 +53,6 @@ import type { WireRecord } from '#/wire/record'; import { buildContextCompactionShape, - createCompactionSummaryMessage, type ContextCompactionShapeInput, } from './compactionHandoff'; import { @@ -99,19 +100,29 @@ async function dehydrateRecord( if (record.type === 'context.append_loop_event') { const event = record['event'] as LoopRecordedEvent | undefined; if (event === undefined) return record; - if (event.type === 'content.part') { - const parts = await transform([event.part]); - if (parts[0] === event.part) return record; - return { ...record, event: { ...event, part: parts[0] } }; - } - if (event.type === 'tool.result') { - const output = event.result.output; - if (!Array.isArray(output)) return record; - const parts = await transform(output); - if (parts === output) return record; - return { ...record, event: { ...event, result: { ...event.result, output: [...parts] } } }; + switch (event.type) { + case 'content.part': { + const parts = await transform([event.part]); + if (parts[0] === event.part) return record; + return { ...record, event: { ...event, part: parts[0] } }; + } + case 'tool.result': { + const output = event.result.output; + if (!Array.isArray(output)) return record; + const parts = await transform(output); + if (parts === output) return record; + return { ...record, event: { ...event, result: { ...event.result, output: [...parts] } } }; + } + case 'step.begin': + case 'step.end': + case 'tool.call': + return record; + default: { + const exhaustive: never = event; + void exhaustive; + return record; + } } - return record; } return record; } @@ -229,15 +240,7 @@ interface UnknownRecord { type ContextCompactionRecord = ContextCompactionPayload | UnknownRecord; -export function applyContextCompactionRecord( - state: readonly ContextMessage[], - record: ContextCompactionRecord, -): ContextMessage[] { - const result = buildContextCompactionShape(state, readContextCompactionShapeInput(record)); - return [...result.messages]; -} - -export function readContextCompactionShapeInput( +function readContextCompactionShapeInput( record: ContextCompactionRecord, ): ContextCompactionShapeInput { const fields = record as UnknownRecord; @@ -257,7 +260,7 @@ export function readContextCompactionShapeInput( }; } -export function readContextCompactedCount(record: ContextCompactionRecord): number { +function readContextCompactedCount(record: ContextCompactionRecord): number { const fields = record as UnknownRecord; const compactedCount = fields['compactedCount']; if (typeof compactedCount === 'number') return compactedCount; @@ -276,26 +279,6 @@ export function readContextCompactedCount(record: ContextCompactionRecord): numb ); } -export function readContextCompactionSummary(record: ContextCompactionRecord): ContextMessage { - const fields = record as UnknownRecord; - const contextSummary = fields['contextSummary']; - if (typeof contextSummary === 'string') return createCompactionSummaryMessage(contextSummary); - const summary = fields['summary']; - if (typeof summary === 'string') return createCompactionSummaryMessage(summary); - if (isContextMessage(summary)) return summary; - throw new Error2( - ErrorCodes.STORAGE_DECODE_FAILED, - 'Invalid context.apply_compaction record: missing summary', - { - details: { - recordKeys: Object.keys(record), - summaryType: typeof summary, - contextSummaryType: typeof contextSummary, - }, - }, - ); -} - function readContextCompactionRawSummary(record: UnknownRecord): string { const summary = record['summary']; if (typeof summary === 'string') return summary; diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts index b764549d62..8fa3b3835d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts @@ -1,12 +1,12 @@ /** * `contextMemory` message id helpers. * - * Local message ids (`msg_`) are process-lifetime identifiers only — - * they are NOT persisted: the on-disk `context.append_message` record carries - * exactly v1's field set, and public message ids are derived from the - * transcript index (by the server layer's `ContextMessage → wire Message` - * projection), which stays stable across live reads and resume. - * `newMessageId` remains for callers that need an opaque per-process id. + * Local message ids (`msg_`) identify prompt messages: assigned at + * enqueue time and persisted with the `context.append_message` record, so + * undo can pair prompt-owned injections with their prompt by `id` across a + * resume. The server layer's `ContextMessage → wire Message` projection + * prefers this id and falls back to a transcript-index-derived id for + * messages that carry none (v1-written records). * Provider-assigned ids live on the separate `providerMessageId` field and * never collide with this namespace. */ diff --git a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts index 293d265591..932de92143 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts @@ -10,7 +10,19 @@ import type { ContentPart } from '#/kosong/contract/message'; export function isVacuousContentPart(part: ContentPart): boolean { - if (part.type === 'text') return part.text.trim().length === 0; - if (part.type === 'think') return part.encrypted === undefined && part.think.trim().length === 0; - return false; + switch (part.type) { + case 'text': + return part.text.trim().length === 0; + case 'think': + return part.encrypted === undefined && part.think.trim().length === 0; + case 'image_url': + case 'audio_url': + case 'video_url': + return false; + default: { + const exhaustive: never = part; + void exhaustive; + return false; + } + } } diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index f7d21c10a1..f7df9e564f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -347,14 +347,8 @@ describe('transcript/model fold parity', () => { function comparable(messages: readonly ContextMessage[]): unknown { return messages.map((m) => ({ - role: m.role, + ...m, content: m.origin?.kind === 'compaction_summary' ? '' : m.content, - toolCalls: m.toolCalls, - toolCallId: m.toolCallId, - isError: m.isError, - note: m.note, - origin: m.origin, - partial: m.partial, })); } From 55b9e0b8cb9bb7db1f53b4ba54f01df1a8627f46 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 14 Aug 2026 14:54:52 +0800 Subject: [PATCH 06/16] refactor(agent-core-v2): converge undo-cut decision in conversationTime The model Op and the display transcript each walked the undo anchors with their own loop, and the transcript partially removed the tail when an undo was blocked (compaction summary / clear floor / too few anchors) while the model side no-ops at the precheck. Move the walk into conversationTime as computeUndoCut/computeUndoCutFrom applied destructively by the context.undo Op and non-destructively by the transcript reducer, so a blocked undo reads identically on both sides. Also: make isUndoAnchor exhaustive over origin kinds with a never assertion, mirrors the compaction result message count via compactionHandoff, and extend UndoCut with anchorIndex distinguishing the counted anchor from the injection-extended cut point. --- packages/agent-core-v2/AGENTS.md | 2 +- .../agent/contextMemory/compactionHandoff.ts | 15 +++ .../src/agent/contextMemory/contextMemory.ts | 2 +- .../contextMemory/contextMemoryService.ts | 6 +- .../src/agent/contextMemory/contextOps.ts | 50 ++------- .../agent/contextMemory/contextTranscript.ts | 63 +++++------ .../agent/contextMemory/conversationTime.ts | 104 ++++++++++++++++-- .../src/agent/undo/undoService.ts | 2 +- .../contextMemory/contextTranscript.test.ts | 35 +++++- .../agent/contextMemory/splice-replay.test.ts | 40 +++++++ .../test/agent/contextMemory/stubs.ts | 2 +- .../agent/contextMemory/undoPrecheck.test.ts | 25 ++++- .../toolSelect/toolSelectService.test.ts | 2 +- .../externalHooksRunner/integration.test.ts | 2 +- 14 files changed, 247 insertions(+), 103 deletions(-) diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index d7509008ce..8df8d9063d 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -84,7 +84,7 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at ## Conversation undo -`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate — plus `computeUndoCut` / `computeUndoCutFrom`, the single anchor-walk decision: applied destructively by the `context.undo` Op and non-destructively by the transcript reducer, so a blocked undo reads identically on both sides) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. ## Docs diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 63a8af0e42..ebf908791a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -9,6 +9,13 @@ * wire-replay / reducer paths keep the same heuristics — their estimate * fallback only fires when a record lacks `tokensAfter`, so the measured * chain is unaffected. + * + * The result layout is `[kept user messages…, elision?, summary]` (legacy + * records: `[summary, …tail]`), so its message COUNT is fully described by + * the persisted kept-user counts: `compactionResultMessageCount` is the + * read-side mirror for projections that need only the length (the display + * transcript's `foldedLength`). Both live in this file so a layout change + * lands in one place. */ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; @@ -138,6 +145,14 @@ export function buildContextCompactionShape( }; } +export function compactionResultMessageCount( + keptUserMessageCount: number | undefined, + keptHeadUserMessageCount: number | undefined, +): number | undefined { + if (keptUserMessageCount === undefined) return undefined; + return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); +} + export function buildCompactionSummaryText(summary: string): string { const suffix = summary.trim(); return `${COMPACTION_SUMMARY_PREFIX}\n${suffix.length > 0 ? suffix : '(no summary available)'}`; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 89b5be4f6d..481480cd82 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -1,6 +1,6 @@ import { createDecorator } from "#/_base/di/instantiation"; -import type { UndoCut } from './contextOps'; +import type { UndoCut } from './conversationTime'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 4173f3cc33..57260851aa 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -30,16 +30,18 @@ import { } from './contextMemory'; import { buildContextCompactionShape, type TokenEstimate } from './compactionHandoff'; import { - computeUndoCut, ContextModel, contextAppendLoopEvent, contextAppendMessage, contextApplyCompaction, contextClear, contextUndo, +} from './contextOps'; +import { + computeUndoCut, isFullyUndoable, type UndoCut, -} from './contextOps'; +} from './conversationTime'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 9310431248..d81ef1a7db 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -28,10 +28,11 @@ * `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record * itself. * - * `context.undo` counts conversation ticks with the single `isUndoAnchor` - * predicate — the same definition the checkpoint - * protocol pushes with, so anchor counting and checkpoint pushing can never - * drift apart. + * `context.undo` applies the single undo-cut decision owned by + * `conversationTime` (`computeUndoCut` over `isUndoAnchor`) — the same walk + * the display transcript applies and the same predicate the checkpoint + * protocol pushes with, so the model, the display, and checkpoint pushing + * can never drift apart. * * Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`: * - `dehydrate(record, transform)`: at dispatch time, traverses message content @@ -56,8 +57,8 @@ import { type ContextCompactionShapeInput, } from './compactionHandoff'; import { - isPromptOwnedInjection, - isUndoAnchor, + computeUndoCut, + isFullyUndoable, isValidUndoCount, } from './conversationTime'; import { foldAppendMessage, foldLoopEvent, type LoopRecordedEvent } from './loopEventFold'; @@ -334,43 +335,6 @@ function isContextMessage(value: unknown): value is ContextMessage { return typeof message.role === 'string' && Array.isArray(message.content); } -export interface UndoCut { - readonly cutIndex: number; - readonly removedCount: number; - readonly stoppedAtCompaction: boolean; -} - -export function computeUndoCut(state: readonly ContextMessage[], count: number): UndoCut { - let remaining = count; - let cutIndex = -1; - let removedCount = 0; - let stoppedAtCompaction = false; - for (let i = state.length - 1; i >= 0 && remaining > 0; i--) { - const message = state[i]; - if (message === undefined || message.origin?.kind === 'injection') continue; - if (message.origin?.kind === 'compaction_summary') { - stoppedAtCompaction = true; - break; - } - if (isUndoAnchor(message)) { - remaining--; - removedCount++; - cutIndex = i; - while ( - cutIndex > 0 && - isPromptOwnedInjection(state[cutIndex - 1]!, message) - ) { - cutIndex--; - } - } - } - return { cutIndex, removedCount, stoppedAtCompaction }; -} - -export function isFullyUndoable(cut: UndoCut, count: number): boolean { - return cut.cutIndex >= 0 && cut.removedCount >= count; -} - export type UndoUnavailableReason = | 'empty' | 'compaction_boundary' diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 7514719ef9..2e83eb0cd2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -6,11 +6,18 @@ * * Loop events and plain appends are reduced by the shared fold kernel * (`loopEventFold.ts`) over time-stamped entries, so the display view can - * never drift from the live/replay fold; this reducer only adds the display - * bookkeeping the kernel does not own — per-entry record times, `clearFloor`, - * and `foldedLength` — plus the transcript-specific meaning of undo (splice - * the tail, keep injections with their owner), clear (keep entries, move the - * floor), and compaction (append the summary marker, keep the folded prefix). + * never drift from the live/replay fold; undo applies the shared cut + * decision from `conversationTime` (a blocked undo — compaction boundary, + * insufficient anchors, clear floor — is a no-op here exactly as in the + * model Op); and the post-compaction `foldedLength` comes from + * `compactionHandoff`'s result-count mirror rather than a local + * re-derivation of the compaction shape. What stays local is the display + * bookkeeping the shared pieces do not own — per-entry record times, + * `clearFloor`, and `foldedLength` — plus the transcript-specific + * application of those decisions: undo splices the tail but keeps orphan + * injections (prompt-owned ones leave with their prompt), clear keeps + * entries and moves the floor, and compaction appends the summary marker + * while keeping the folded prefix. */ import type { WireRecord } from '#/wire/record'; @@ -18,9 +25,10 @@ import type { WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, collectCompactableUserMessages, + compactionResultMessageCount, selectRecentUserMessages, } from './compactionHandoff'; -import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; +import { computeUndoCutFrom, isFullyUndoable } from './conversationTime'; import { appendMessageTo, applyLoopEventTo, @@ -68,32 +76,21 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const applyUndo = (count: number): void => { if (count <= 0) return; - const entries = state.messages.slice(); - let removedUserCount = 0; + const cut = computeUndoCutFrom(state.messages, count, entryAdapter.messageOf, clearFloor); + if (!isFullyUndoable(cut, count)) return; + const entries = state.messages; let removed = 0; - for (let i = entries.length - 1; i >= clearFloor; i--) { - const message = entries[i]!.message; - if (message.origin?.kind === 'injection') continue; - if (message.origin?.kind === 'compaction_summary') break; - entries.splice(i, 1); - removed++; - if (isUndoAnchor(message)) { - removedUserCount++; - if (removedUserCount >= count) { - while ( - i > clearFloor && - isPromptOwnedInjection(entries[i - 1]!.message, message) - ) { - entries.splice(i - 1, 1); - i--; - removed++; - } - break; - } + const kept: TranscriptEntry[] = []; + for (let i = cut.cutIndex; i < entries.length; i++) { + const entry = entries[i]!; + if (i > cut.anchorIndex && entry.message.origin?.kind === 'injection') { + kept.push(entry); + } else { + removed++; } } foldedLength = Math.max(0, foldedLength - removed); - state = { messages: entries, fold: EMPTY_FOLD }; + state = { messages: [...entries.slice(0, cut.cutIndex), ...kept], fold: EMPTY_FOLD }; }; const add = (record: WireRecord): void => { @@ -162,12 +159,12 @@ function recoverFoldedLength( clearFloor: number, foldedLength: number, ): number { - const keptUserMessageCount = readNumber(record, 'keptUserMessageCount'); - const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); + const resultCount = compactionResultMessageCount( + readNumber(record, 'keptUserMessageCount'), + readNumber(record, 'keptHeadUserMessageCount'), + ); + if (resultCount !== undefined) return resultCount; const compactedCount = readNumber(record, 'compactedCount'); - if (keptUserMessageCount !== undefined) { - return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); - } if (compactedCount !== undefined && compactedCount < foldedLength) { return 1 + (foldedLength - compactedCount); } diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index 5cc7359997..52a925e56e 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -2,11 +2,23 @@ * `contextMemory` domain — shared conversation clock and checkpointed * wire-Model factory. * - * Defines the undo anchor vocabulary and registers conversation-time Models - * for undo validation. `CHECKPOINTED_MODELS` stays the undo domain's read - * path; the `WireModelContribution` fold also drains it into the built-in - * layer so the checkpointed list is part of the folded wire vocabulary. - * Scope-agnostic. + * Owns the undo anchor vocabulary and the single undo-cut decision: + * `computeUndoCutFrom` walks entries backwards counting `isUndoAnchor` + * ticks — skipping injections, stopping at a compaction summary, extending + * the cut over the anchor's prompt-owned injections — bounded by an optional + * floor, and generic over the entry type so any read model whose entries + * carry a `ContextMessage` can run the same walk. The returned `UndoCut` + * separates `anchorIndex` (the counted anchor) from `cutIndex` (extended + * over its prompt-owned injections). The wire-model Op + * (`contextOps.contextUndo`) applies the cut destructively; the display + * transcript (`contextTranscript`) applies the same decision as a + * non-destructive splice — so a blocked undo (compaction boundary / + * insufficient anchors / floor) reads identically on both sides. + * + * Also registers conversation-time Models for undo validation: + * `CHECKPOINTED_MODELS` stays the undo domain's read path; the + * `WireModelContribution` fold also drains it into the built-in layer so the + * checkpointed list is part of the folded wire vocabulary. Scope-agnostic. */ import { defineModel, type ModelDef } from '#/wire/model'; @@ -16,11 +28,29 @@ import type { ContextMessage } from './types'; export function isUndoAnchor(message: ContextMessage): boolean { if (message.role !== 'user') return false; const origin = message.origin; - if (origin === undefined || origin.kind === 'user') return true; - return ( - (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && - origin.trigger === 'user-slash' - ); + if (origin === undefined) return true; + switch (origin.kind) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash'; + case 'injection': + case 'shell_command': + case 'compaction_summary': + case 'system_trigger': + case 'task': + case 'cron_job': + case 'cron_missed': + case 'hook_result': + case 'retry': + return false; + default: { + const exhaustive: never = origin; + void exhaustive; + return false; + } + } } export function isPromptOwnedInjection( @@ -39,6 +69,60 @@ export function isValidUndoCount(count: number): boolean { return Number.isSafeInteger(count) && count > 0; } +export interface UndoCut { + readonly cutIndex: number; + readonly anchorIndex: number; + readonly removedCount: number; + readonly stoppedAtCompaction: boolean; +} + +export function computeUndoCut( + messages: readonly ContextMessage[], + count: number, +): UndoCut { + return computeUndoCutFrom(messages, count, (message) => message); +} + +export function computeUndoCutFrom( + entries: readonly E[], + count: number, + messageOf: (entry: E) => ContextMessage, + floor: number = 0, +): UndoCut { + let remaining = count; + let cutIndex = -1; + let anchorIndex = -1; + let removedCount = 0; + let stoppedAtCompaction = false; + for (let i = entries.length - 1; i >= floor && remaining > 0; i--) { + const entry = entries[i]; + if (entry === undefined) continue; + const message = messageOf(entry); + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isUndoAnchor(message)) { + remaining--; + removedCount++; + anchorIndex = i; + cutIndex = i; + while ( + cutIndex > floor && + isPromptOwnedInjection(messageOf(entries[cutIndex - 1]!), message) + ) { + cutIndex--; + } + } + } + return { cutIndex, anchorIndex, removedCount, stoppedAtCompaction }; +} + +export function isFullyUndoable(cut: UndoCut, count: number): boolean { + return cut.cutIndex >= 0 && cut.removedCount >= count; +} + export interface Checkpointed { readonly current: T; readonly checkpoints: readonly T[]; diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts index e922333180..3fe754992b 100644 --- a/packages/agent-core-v2/src/agent/undo/undoService.ts +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -15,12 +15,12 @@ import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import { - computeUndoCut, formatUndoUnavailableMessage, precheckUndo, } from '#/agent/contextMemory/contextOps'; import { CHECKPOINTED_MODELS, + computeUndoCut, isUndoAnchor, isValidUndoCount, type Checkpointed, diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index f7df9e564f..bc64fd70a4 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -1,9 +1,11 @@ /** * Tests for `reduceContextTranscript` — the wire-transcript reducer used by the * snapshot and messages endpoints. Mirrors v1 `reduceWireRecords` expectations: - * compaction keeps the prefix and appends a summary marker; undo removes the - * tail but stops at compaction summaries / clear floors; clear keeps the - * transcript but resets the folded view. + * compaction keeps the prefix and appends a summary marker; undo splices the + * tail along the shared `conversationTime` cut decision — a blocked undo + * (compaction summary / clear floor / too few anchors) leaves the transcript + * unchanged, exactly as the model Op no-ops; clear keeps the transcript but + * resets the folded view. * * The `transcript/model fold parity` block replays one record stream through * both the transcript reducer and the model fold (`foldAppendMessage` / @@ -217,7 +219,7 @@ describe('reduceContextTranscript', () => { expect(texts(result)).toEqual(['keep me', 'kept answer']); }); - it('undo stops at a compaction summary', () => { + it('undo blocked at a compaction summary leaves the transcript unchanged', () => { const result = reduceContextTranscript([ appendMessage(userMessage('old')), compaction('SUM', 1, 1), @@ -225,7 +227,20 @@ describe('reduceContextTranscript', () => { appendMessage(assistantMessage('answer')), undo(2), ]); + expect(texts(result)).toEqual(['old', 'SUM', 'recent', 'answer']); + expect(result.foldedLength).toBe(4); + }); + + it('undo up to a compaction summary still applies', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('old')), + compaction('SUM', 1, 1), + appendMessage(userMessage('recent')), + appendMessage(assistantMessage('answer')), + undo(1), + ]); expect(texts(result)).toEqual(['old', 'SUM']); + expect(result.foldedLength).toBe(2); }); it('clear keeps prior transcript entries but resets the folded view', () => { @@ -251,6 +266,18 @@ describe('reduceContextTranscript', () => { expect(result.foldedLength).toBe(0); }); + it('undo blocked at a clear floor leaves the transcript unchanged', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + { type: 'context.clear' }, + appendMessage(userMessage('u2')), + appendMessage(assistantMessage('a2')), + undo(2), + ]); + expect(texts(result)).toEqual(['u1', 'u2', 'a2']); + expect(result.foldedLength).toBe(2); + }); + it('folds tool calls and results from loop events', () => { const result = reduceContextTranscript([ appendMessage(userMessage('q')), diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index fa41f21068..cb26dc66b9 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -488,4 +488,44 @@ describe('AgentContextMemoryService (wire-backed)', () => { expect(replay.wire.getModel(ContextModel).messages as readonly ContextMessage[]).toHaveLength(2); }); + it('pairs prompt-owned injections with their prompt by persisted id, live and on replay', async () => { + const host = buildHost(KEY); + const model = () => host.wire.getModel(ContextModel).messages as readonly ContextMessage[]; + + const reminder: ContextMessage = { + role: 'user', + content: [{ type: 'text', text: 'caption' }], + toolCalls: [], + origin: { kind: 'injection', variant: 'image_compression', ownerPromptId: 'prompt-1' }, + }; + const prompt: ContextMessage = { + role: 'user', + content: [{ type: 'text', text: 'undo me' }], + toolCalls: [], + id: 'prompt-1', + origin: { kind: 'user' }, + }; + const answer: ContextMessage = { + role: 'assistant', + content: [{ type: 'text', text: 'answer' }], + toolCalls: [], + }; + + host.svc.append(reminder, prompt, answer); + host.svc.undo(1); + expect(model()).toHaveLength(0); + + await host.wire.flush(); + const records = await readRecords(host.log); + + const replay = buildHost(REPLAY_KEY); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); + expect(replay.wire.getModel(ContextModel).messages).toHaveLength(0); + }); + }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef5624..30f337265d 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -14,7 +14,7 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; -import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps'; +import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/conversationTime'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; diff --git a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts index 1a760d657e..20e6844253 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { computeUndoCut, - contextUndo, + computeUndoCutFrom, isFullyUndoable, -} from '#/agent/contextMemory/contextOps'; +} from '#/agent/contextMemory/conversationTime'; +import { contextUndo } from '#/agent/contextMemory/contextOps'; import { EMPTY_FOLD, type ContextMessage, @@ -51,7 +52,7 @@ const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; describe('computeUndoCut', () => { it('finds the cut for the last real user prompt', () => { const cut = computeUndoCut([user(USER_ORIGIN), assistant()], 1); - expect(cut).toEqual({ cutIndex: 0, removedCount: 1, stoppedAtCompaction: false }); + expect(cut).toEqual({ cutIndex: 0, anchorIndex: 0, removedCount: 1, stoppedAtCompaction: false }); expect(isFullyUndoable(cut, 1)).toBe(true); }); @@ -69,7 +70,7 @@ describe('computeUndoCut', () => { it('finds nothing when the history has no real user prompt', () => { const cut = computeUndoCut([], 1); - expect(cut).toEqual({ cutIndex: -1, removedCount: 0, stoppedAtCompaction: false }); + expect(cut).toEqual({ cutIndex: -1, anchorIndex: -1, removedCount: 0, stoppedAtCompaction: false }); expect(isFullyUndoable(cut, 1)).toBe(false); }); @@ -88,7 +89,7 @@ describe('computeUndoCut', () => { it('stops at a compaction summary', () => { const cut = computeUndoCut([user(USER_ORIGIN), compaction(), assistant()], 1); - expect(cut).toEqual({ cutIndex: -1, removedCount: 0, stoppedAtCompaction: true }); + expect(cut).toEqual({ cutIndex: -1, anchorIndex: -1, removedCount: 0, stoppedAtCompaction: true }); expect(isFullyUndoable(cut, 1)).toBe(false); }); @@ -99,6 +100,20 @@ describe('computeUndoCut', () => { expect(cut.stoppedAtCompaction).toBe(true); expect(isFullyUndoable(cut, 2)).toBe(false); }); + + it('computeUndoCutFrom walks wrapped entries and stops at the given floor', () => { + const entries = [user(USER_ORIGIN), user(USER_ORIGIN), assistant()].map((message) => ({ + message, + })); + const cut = computeUndoCutFrom(entries, 2, (entry) => entry.message, 1); + expect(cut).toEqual({ + cutIndex: 1, + anchorIndex: 1, + removedCount: 1, + stoppedAtCompaction: false, + }); + expect(isFullyUndoable(cut, 2)).toBe(false); + }); }); describe('contextUndo op', () => { diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index d3009bfb0d..238ce185f8 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -19,7 +19,7 @@ import { IFlagService } from '#/app/flag/flag'; import type { ModelCapability } from '#/kosong/contract/capability'; import type { ToolCall } from '#/kosong/contract/message'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { UndoCut } from '#/agent/contextMemory/contextOps'; +import type { UndoCut } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import { diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index a7b5fbfd94..fd45eba1c2 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -19,7 +19,7 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; -import { computeUndoCut } from '#/agent/contextMemory/contextOps'; +import { computeUndoCut } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { HookDefSchema, From c5cf5623ea526a1ece24209c4db9fcf53cc1c2cd Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 14 Aug 2026 16:06:52 +0800 Subject: [PATCH 07/16] fix(agent-core-v2): drop removed prompts' injections on multi-turn transcript undo The transcript's kept-loop retained every injection after the oldest counted anchor, so with count > 1 a prompt-owned injection of a newer removed prompt (e.g. an image-compression caption) survived the display undo while the model Op removed it. Collect the removed anchors' ids on the same pass and keep only injections not owned by them, so the header's 'prompt-owned ones leave with their prompt' holds for every count. --- .../agent/contextMemory/contextTranscript.ts | 13 ++++- .../contextMemory/contextTranscript.test.ts | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 2e83eb0cd2..d7861f8189 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -28,7 +28,7 @@ import { compactionResultMessageCount, selectRecentUserMessages, } from './compactionHandoff'; -import { computeUndoCutFrom, isFullyUndoable } from './conversationTime'; +import { computeUndoCutFrom, isFullyUndoable, isUndoAnchor } from './conversationTime'; import { appendMessageTo, applyLoopEventTo, @@ -79,11 +79,20 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const cut = computeUndoCutFrom(state.messages, count, entryAdapter.messageOf, clearFloor); if (!isFullyUndoable(cut, count)) return; const entries = state.messages; + const removedPromptIds = new Set(); + for (let i = cut.cutIndex; i < entries.length; i++) { + const message = entries[i]!.message; + if (message.id !== undefined && isUndoAnchor(message)) removedPromptIds.add(message.id); + } let removed = 0; const kept: TranscriptEntry[] = []; for (let i = cut.cutIndex; i < entries.length; i++) { const entry = entries[i]!; - if (i > cut.anchorIndex && entry.message.origin?.kind === 'injection') { + const origin = entry.message.origin; + if ( + origin?.kind === 'injection' && + (origin.ownerPromptId === undefined || !removedPromptIds.has(origin.ownerPromptId)) + ) { kept.push(entry); } else { removed++; diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index bc64fd70a4..cd6356b763 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -219,6 +219,34 @@ describe('reduceContextTranscript', () => { expect(texts(result)).toEqual(['keep me', 'kept answer']); }); + it('multi-turn undo drops prompt-owned injections of every removed prompt but keeps unowned reminders', () => { + const result = reduceContextTranscript([ + appendMessage( + userMessage('caption A', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'prompt-1', + }), + ), + appendMessage({ ...userMessage('message A', { kind: 'user' }), id: 'prompt-1' }), + appendMessage(assistantMessage('reply A')), + appendMessage( + userMessage('caption B', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'prompt-2', + }), + ), + appendMessage({ ...userMessage('message B', { kind: 'user' }), id: 'prompt-2' }), + appendMessage(assistantMessage('reply B')), + appendMessage(userMessage('standing reminder', { kind: 'injection', variant: 'system' })), + undo(2), + ]); + + expect(texts(result)).toEqual(['standing reminder']); + expect(result.foldedLength).toBe(1); + }); + it('undo blocked at a compaction summary leaves the transcript unchanged', () => { const result = reduceContextTranscript([ appendMessage(userMessage('old')), @@ -416,6 +444,30 @@ describe('transcript/model fold parity', () => { ]); }); + it('matches across a multi-turn undo with prompt-owned injections on every anchor', () => { + expectParity([ + appendMessage( + userMessage('caption u1', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'p1', + }), + ), + appendMessage({ ...userMessage('u1', { kind: 'user' }), id: 'p1' }), + ...assistantStep('s1', 'a1'), + appendMessage( + userMessage('caption u2', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'p2', + }), + ), + appendMessage({ ...userMessage('u2', { kind: 'user' }), id: 'p2' }), + ...assistantStep('s2', 'a2'), + undo(2), + ]); + }); + it('matches when a tool exchange is interrupted without a retry', () => { expectParity([ appendMessage(userMessage('q', { kind: 'user' })), From a8ee42ce8fa2158d26f6d2a17ef4bdbaf271a3af Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 14 Aug 2026 16:07:21 +0800 Subject: [PATCH 08/16] refactor(agent-core-v2): accumulate request projection repairs as policy The llmRequester retry chain kept a RequestProjection union and translated it into a ProjectionPolicy per attempt; repairs were mutually exclusive, so a strict resend rejected again for body size or image format either aborted or silently dropped the strict repair. Retry state is now the ProjectionPolicy itself: each rejection adds its repair on its own axis (media: 413 -> degraded -> strip; wire: structure -> strict) without discarding the other, requestInput's translation layer and the unreachable snapshot ??= disappear, and the persisted llm.request projection name derives from the policy (the op enum gains strict-media-degraded / strict-media-stripped). Also narrows ProjectionPolicy to the variants actually produced (wire 'strict'; media 'degraded' | { strip }), dropping the dead 'default'/'keep' literals and their guard. --- .../agent-core-v2/docs/wire-manifest.d.ts | 2 +- .../contextProjector/contextProjector.ts | 4 +- .../contextProjectorService.ts | 2 +- .../src/agent/llmRequester/llmRequestOps.ts | 2 +- .../agent/llmRequester/llmRequesterService.ts | 113 +++++++++--------- .../llmRequester/llmRequesterService.test.ts | 80 ++++++++++++- 6 files changed, 143 insertions(+), 60 deletions(-) diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index aaf5e4a414..7374597b65 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -339,7 +339,7 @@ interface LlmRequestPayload { messageCount: number; turnStep?: string; attempt?: string; - projection?: 'strict' | 'media-degraded' | 'media-stripped'; + projection?: 'strict' | 'media-degraded' | 'media-stripped' | 'strict-media-degraded' | 'strict-media-stripped'; droppedCount?: number; } diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts index f9591599a7..9b29685b5a 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -26,8 +26,8 @@ export interface MediaStripSnapshot { } export interface ProjectionPolicy { - readonly wire?: 'default' | 'strict'; - readonly media?: 'keep' | 'degraded' | { readonly strip: MediaStripSnapshot }; + readonly wire?: 'strict'; + readonly media?: 'degraded' | { readonly strip: MediaStripSnapshot }; } export interface IAgentContextProjectorService { diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index ffedfa4f20..cc8123f0ab 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -75,7 +75,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi policy.wire === 'strict' ? projectStrict : project, ); const media = policy.media; - if (media === undefined || media === 'keep') return projected; + if (media === undefined) return projected; if (media === 'degraded') return degradeOlderMediaParts(projected, MEDIA_DEGRADE_KEEP_RECENT); return stripMediaPartsBySnapshot(projected, media.strip); } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index 4975c7063d..47ef9337d1 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -68,7 +68,7 @@ export const llmRequest = LlmRequestTraceModel.defineOp('llm.request', { messageCount: z.number(), turnStep: z.string().optional(), attempt: z.string().optional(), - projection: z.enum(['strict', 'media-degraded', 'media-stripped']).optional(), + projection: z.enum(['strict', 'media-degraded', 'media-stripped', 'strict-media-degraded', 'strict-media-stripped']).optional(), droppedCount: z.number().optional(), }), apply: (s) => s, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 26fd178a6f..3021fff7f3 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -7,8 +7,10 @@ * folds the completion-token budget into the profile's dialect-free intent * params, then drives a bounded request chain through the `ModelRequester` * resolved from `IModelCatalog`: one primary `requester.request(input, signal, - * params)` attempt plus projection rebuilds for request structure or media - * compatibility. Before each request the projected messages pass through `media`'s + * params)` attempt plus accumulating projection rebuilds — each repeated + * provider rejection (request structure, body size, image format) adds its own + * repair on top of the ones already applied. Before each request the projected + * messages pass through `media`'s * video resolver, which rewrites every `kimi-file://` prompt-video reference * to a provider-acceptable part (uploaded `ms://`, inline base64, or a * `