From 8da25da5e8dff79fc289fc10113827311388ddf1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 10:57:28 +0800 Subject: [PATCH 1/7] refactor(agent-core-v2): unify the loop-event fold into one core with two materializations The loop-event stream was reduced by two hand-mirrored state machines: loopEventFold.ts for the live/replayed context and contextTranscript.ts for the full transcript behind the messages endpoints, kept in sync by comments alone and already drifted (transcript dropped tool-result note metadata and never closed a dangling tool exchange at step.end). createLoopEventFold now owns the shared state machine once (settle, pending tool exchanges, deferred appends, vacuous tracking) and both views plug in as LoopEventFoldSink materializations. New parity tests pin the foldedLength === live length invariant the endpoints splice on. --- .../agent/contextMemory/contextTranscript.ts | 168 +++------ .../src/agent/contextMemory/loopEventFold.ts | 340 ++++++++++++------ .../contextMemory/contextTranscript.test.ts | 128 ++++++- 3 files changed, 413 insertions(+), 223 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 178272b479..f7acbf6c51 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,3 +1,18 @@ +/** + * `contextMemory` domain — rebuilds display history from the wire journal. + * + * Supplies transcript consumers with full pre-compaction history and folded + * context length while preserving undo/clear semantics. Loop events and + * interleaved `context.append_message`s are reduced by the shared + * `createLoopEventFold` core (`loopEventFold`) with a transcript + * materialization that keeps every entry's originating record time and tracks + * `foldedLength` — the length the live folded context would have after the + * same records, so callers can detect and append a not-yet-flushed live tail. + * Compaction, undo, and clear rewrite the materialization itself (kept + * prefix + summary marker, tail removal, clear floor) and stay local. + * Scope-agnostic. + */ + import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { WireRecord } from '#/wire/record'; @@ -7,12 +22,8 @@ import { selectRecentUserMessages, } from './compactionHandoff'; import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; -import type { LoopRecordedEvent } from './loopEventFold'; +import { createLoopEventFold, 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.'; export interface ContextTranscript { readonly entries: readonly ContextMessage[]; @@ -32,6 +43,7 @@ interface MutableMessage { toolCalls: ToolCall[]; toolCallId?: string; isError?: boolean; + note?: string; origin?: ContextMessage['origin']; } @@ -50,111 +62,46 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const transcript: MutableEntry[] = []; let foldedLength = 0; let clearFloor = 0; - const openSteps = new Map(); - const pendingToolResultIds = new Set(); - let deferred: MutableEntry[] = []; - let lastOpenStepUuid: string | undefined; + let openEntry: MutableEntry | 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 fold = createLoopEventFold({ + openAssistant: (time) => { + openEntry = { message: { role: 'assistant', content: [], toolCalls: [] }, time }; + push(openEntry); + }, + appendOpenContent: (part) => { + openEntry?.message.content.push(part); + }, + appendOpenToolCall: (call) => { + openEntry?.message.toolCalls.push(call); + }, + dropOpenAssistant: () => { + if (openEntry === undefined) return; + const index = transcript.indexOf(openEntry); + openEntry = undefined; + if (index === -1) return; + transcript.splice(index, 1); + foldedLength = Math.max(0, foldedLength - 1); + }, + sealOpenAssistant: () => { + openEntry = undefined; + }, + pushToolMessage: (message, time) => { + push({ message: message as MutableMessage, time }); + }, + pushMessage: (message, time) => { + push(toMutableEntry(message, time)); + }, + }); + + const resetOpenState = (): void => { + fold.reset(); + openEntry = undefined; }; const applyUndo = (count: number): void => { @@ -187,14 +134,13 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { 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); + fold.appendMessage(record['message'] as ContextMessage, record.time); break; } - case 'context.append_loop_event': - applyLoopEvent(record['event'] as LoopRecordedEvent, record.time); + case 'context.append_loop_event': { + fold.loopEvent(record['event'] as LoopRecordedEvent, record.time); break; + } case 'context.apply_compaction': { transcript.push({ message: { @@ -296,7 +242,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 521dc2abaa..dc4de59227 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -1,3 +1,46 @@ +/** + * `contextMemory` loop-event fold — one reduction core for the loop-event + * stream, materialized through pluggable sinks. + * + * The agent loop streams a turn as `context.append_loop_event` records + * (`step.begin` / `content.part` / `tool.call` / `tool.result` / `step.end`) + * and never writes a folded assistant message, keeping the on-disk shape + * byte-compatible with v1. `createLoopEventFold` reduces that stream — + * interleaved with non-loop `context.append_message`s — through a + * `LoopEventFoldSink`, so every consumer shares a single state machine: the + * `contextMemoryKey` folds in `contextOps` materialize the folded live + * `ContextMessage[]` (at live dispatch time and again on replay) through + * `foldLoopEvent` / `foldAppendMessage` / `resetFold`, while + * `contextTranscript` materializes the full pre-compaction transcript with + * per-entry record times and `foldedLength`. + * + * Semantics mirror the v1 fold exactly: + * - `step.begin` → settle the step left open (a failed attempt has no + * `step.end`), then open an assistant message + * - `content.part`→ append to the open assistant's content + * - `tool.call` → append to the open assistant's `toolCalls`, mark pending + * - `tool.result` → push a `tool` message (with the v1 output wrapping), + * clear its pending id + * - `step.end` → settle the open step + * "Settle" closes any tool exchange left open (interrupted result messages), + * then drops the open assistant when nothing sendable was recorded (no tool + * calls; every content part vacuous — an output-free assistant only trips + * provider message validation) and seals it otherwise. v1 never produced + * `step.begin` without `step.end` (its retries stayed inside one request), so + * the drop/seal rule is the v2 extension that makes loop-level retries — a + * retried attempt is its own `step.begin` — replay to the same history the + * live loop folded. + * 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. + * + * The live-view entry points stay pure transforms: cross-record state rides + * in a `WeakMap` keyed by each committed state array (immer drafts resolve to + * their `original`), so the public `getState(contextMemoryKey)` view stays a + * plain `ContextMessage[]` and concurrent folds of different agent scopes + * never share fold state. + */ + import { isDraft, original } from 'immer'; import type { FinishReason } from '#/kosong/contract/provider'; @@ -64,123 +107,219 @@ export type LoopRecordedEvent = readonly parentUuid?: string; }; -interface FoldCtx { - openStepUuid: string | undefined; - pending: Set; - deferred: ContextMessage[]; +export interface LoopEventFoldSink { + openAssistant(time: number | undefined): void; + appendOpenContent(part: ContentPart): void; + appendOpenToolCall(call: ToolCall): void; + dropOpenAssistant(): void; + sealOpenAssistant(): void; + pushToolMessage(message: ContextMessage, time: number | undefined): void; + pushMessage(message: ContextMessage, time: number | undefined): void; } -const foldCtxMap = new WeakMap(); +export interface LoopEventFold { + appendMessage(message: ContextMessage, time?: number): void; + loopEvent(event: LoopRecordedEvent, time?: number): void; + reset(): void; +} -function ctxOf(state: readonly ContextMessage[]): FoldCtx { - const key = (isDraft(state) ? original(state as any) : state) as object; - let ctx = foldCtxMap.get(key); - if (ctx === undefined) { - ctx = { openStepUuid: undefined, pending: new Set(), deferred: [] }; - foldCtxMap.set(key, ctx); - } - return ctx; +export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { + let openStepUuid: string | undefined; + let openHasToolCalls = false; + let openVacuous = true; + const pending = new Set(); + let deferred: { message: ContextMessage; time: number | undefined }[] = []; + + const flushDeferred = (): void => { + if (pending.size > 0 || deferred.length === 0) return; + for (const entry of deferred) sink.pushMessage(entry.message, entry.time); + deferred = []; + }; + const closePending = (time: number | undefined): void => { + if (pending.size === 0) return; + for (const toolCallId of pending) { + sink.pushToolMessage(interruptedToolMessage(toolCallId), time); + } + pending.clear(); + flushDeferred(); + }; + const settleOpen = (time: number | undefined): void => { + if (openStepUuid === undefined) return; + closePending(time); + if (!openHasToolCalls && openVacuous) { + sink.dropOpenAssistant(); + } else { + sink.sealOpenAssistant(); + } + openStepUuid = undefined; + }; + + return { + appendMessage(message, time) { + if (pending.size > 0) { + deferred.push({ message, time }); + return; + } + sink.pushMessage(message, time); + }, + loopEvent(event, time) { + switch (event.type) { + case 'step.begin': { + settleOpen(time); + sink.openAssistant(time); + openStepUuid = event.uuid; + openHasToolCalls = false; + openVacuous = true; + return; + } + case 'step.end': { + settleOpen(time); + flushDeferred(); + return; + } + case 'content.part': { + if (openStepUuid === undefined || event.stepUuid !== openStepUuid) return; + sink.appendOpenContent(event.part); + openVacuous = openVacuous && isVacuousContentPart(event.part); + return; + } + case 'tool.call': { + if (openStepUuid === undefined || event.stepUuid !== openStepUuid) 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 } : {}), + }; + sink.appendOpenToolCall(call); + pending.add(event.toolCallId); + openHasToolCalls = true; + return; + } + case 'tool.result': { + if (!pending.has(event.toolCallId)) return; + pending.delete(event.toolCallId); + const output = event.result.output; + sink.pushToolMessage( + { + ...createToolMessage( + event.toolCallId, + typeof output === 'string' ? output : [...output], + ), + isError: event.result.isError, + note: event.result.note, + }, + time, + ); + flushDeferred(); + return; + } + } + }, + reset() { + openStepUuid = undefined; + openHasToolCalls = false; + openVacuous = true; + pending.clear(); + deferred = []; + }, + }; } -function bind(state: readonly ContextMessage[], ctx: FoldCtx): readonly ContextMessage[] { - foldCtxMap.set(state, ctx); - return state; +interface ImmutableFoldSink extends LoopEventFoldSink { + current(): readonly ContextMessage[]; +} + +interface BoundFold { + readonly fold: LoopEventFold; + readonly sink: ImmutableFoldSink; } +const boundFoldMap = new WeakMap(); + 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; - } - return bind([...state, message], ctx); + const bound = boundOf(state); + bound.fold.appendMessage(message, undefined); + return bind(bound, bound.sink.current()); } export function foldLoopEvent( state: readonly ContextMessage[], event: LoopRecordedEvent, ): readonly ContextMessage[] { - const ctx = ctxOf(state); - 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); - } - case 'step.end': { - ctx.openStepUuid = undefined; - const s = settleOpenStep(state, ctx); - return bind(flushDeferred(s, ctx), ctx); - } - case 'content.part': - return bind(appendToOpenAssistant(state, (message) => ({ - ...message, - content: [...message.content, event.part], - })), ctx); - case 'tool.call': { - 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 } : {}), - }; - ctx.pending.add(event.toolCallId); - return bind(appendToOpenAssistant(state, (message) => ({ - ...message, - toolCalls: [...message.toolCalls, call], - })), ctx); - } - case 'tool.result': { - if (!ctx.pending.has(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, - }; - ctx.pending.delete(event.toolCallId); - return bind(flushDeferred([...state, toolMessage], ctx), ctx); - } - default: - return state; - } + const bound = boundOf(state); + bound.fold.loopEvent(event, undefined); + return bind(bound, bound.sink.current()); } export function resetFold(state: readonly ContextMessage[]): readonly ContextMessage[] { - foldCtxMap.set(state, { openStepUuid: undefined, pending: new Set(), deferred: [] }); + const sink = createImmutableFoldSink(state); + boundFoldMap.set(state, { fold: createLoopEventFold(sink), sink }); return state; } -function appendToOpenAssistant( - state: readonly ContextMessage[], - 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; +function boundOf(state: readonly ContextMessage[]): BoundFold { + const key = keyOf(state); + let bound = boundFoldMap.get(key); + if (bound === undefined || bound.sink.current() !== key) { + const sink = createImmutableFoldSink(key); + bound = { fold: createLoopEventFold(sink), sink }; + boundFoldMap.set(key, bound); + } + return bound; } -function settleOpenStep( - state: readonly ContextMessage[], - ctx: FoldCtx, -): readonly ContextMessage[] { - const closed = closePending(state, ctx); - const index = findOpenAssistantIndex(closed); - if (index === -1) return closed; - const open = closed[index]!; - if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) { - return [...closed.slice(0, index), ...closed.slice(index + 1)]; - } - const next = closed.slice(); - next[index] = { ...open, partial: undefined }; - return next; +function bind(bound: BoundFold, state: readonly ContextMessage[]): readonly ContextMessage[] { + boundFoldMap.set(state, bound); + return state; +} + +function keyOf(state: readonly ContextMessage[]): readonly ContextMessage[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (isDraft(state) ? original(state as any) : state) as readonly ContextMessage[]; +} + +function createImmutableFoldSink(initial: readonly ContextMessage[]): ImmutableFoldSink { + let current = initial; + let openIndex = findOpenAssistantIndex(initial); + const updateOpen = (update: (message: ContextMessage) => ContextMessage): void => { + if (openIndex === -1) return; + const next = current.slice(); + next[openIndex] = update(next[openIndex]!); + current = next; + }; + return { + current: () => current, + openAssistant: () => { + current = [...current, { role: 'assistant', content: [], toolCalls: [], partial: true }]; + openIndex = current.length - 1; + }, + appendOpenContent: (part) => { + updateOpen((message) => ({ ...message, content: [...message.content, part] })); + }, + appendOpenToolCall: (call) => { + updateOpen((message) => ({ ...message, toolCalls: [...message.toolCalls, call] })); + }, + dropOpenAssistant: () => { + if (openIndex === -1) return; + current = [...current.slice(0, openIndex), ...current.slice(openIndex + 1)]; + openIndex = -1; + }, + sealOpenAssistant: () => { + updateOpen((message) => ({ ...message, partial: undefined })); + openIndex = -1; + }, + pushToolMessage: (message) => { + current = [...current, message]; + }, + pushMessage: (message) => { + current = [...current, message]; + }, + }; } function findOpenAssistantIndex(state: readonly ContextMessage[]): number { @@ -190,23 +329,6 @@ function findOpenAssistantIndex(state: readonly ContextMessage[]): number { 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)); - } - ctx.pending.clear(); - return flushDeferred(next, ctx); -} - -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 interruptedToolMessage(toolCallId: string): ContextMessage { return { ...createToolMessage(toolCallId, TOOL_INTERRUPTED_ON_RESUME_OUTPUT), 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 20c71b3283..19131da94f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from 'vitest'; +import { + applyContextCompactionRecord, + computeUndoCut, + isFullyUndoable, +} from '#/agent/contextMemory/contextOps'; import { reduceContextTranscript, type ContextTranscript, } from '#/agent/contextMemory/contextTranscript'; -import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; +import { + foldAppendMessage, + foldLoopEvent, + resetFold, + type LoopRecordedEvent, +} from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import type { WireRecord } from '#/wire/record'; @@ -284,3 +294,119 @@ describe('reduceContextTranscript', () => { expect(result.foldedLength).toBe(4); }); }); + +describe('live fold parity', () => { + function foldLive(records: WireRecord[]): readonly ContextMessage[] { + let state: readonly ContextMessage[] = []; + for (const record of records) { + switch (record.type) { + case 'context.append_message': + state = foldAppendMessage(state, record['message'] as ContextMessage); + break; + case 'context.append_loop_event': + state = foldLoopEvent(state, record['event'] as LoopRecordedEvent); + break; + case 'context.apply_compaction': + state = applyContextCompactionRecord(state, record); + break; + case 'context.undo': { + const count = record['count'] as number; + const cut = computeUndoCut(state, count); + if (isFullyUndoable(cut, count)) state = resetFold(state.slice(0, cut.cutIndex)); + break; + } + case 'context.clear': + state = state.length === 0 ? state : resetFold([]); + break; + } + } + return state; + } + + function comparable(messages: readonly ContextMessage[]): unknown { + return messages.map((m) => ({ + role: m.role, + content: m.content, + toolCalls: m.toolCalls, + toolCallId: m.toolCallId, + isError: m.isError, + note: m.note, + })); + } + + it('matches the live folded view message-for-message on a plain stream', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'a1' } }), + loopEvent({ + type: 'tool.call', + stepUuid: 's1', + toolCallId: 'c1', + name: 'Bash', + args: { command: 'echo hi' }, + }), + appendMessage(userMessage('inj', { kind: 'injection', variant: 'test' })), + loopEvent({ + type: 'tool.result', + toolCallId: 'c1', + result: { output: 'hi', isError: false, note: 'note' }, + }), + loopEvent({ type: 'step.end', uuid: 's1' }), + loopEvent({ type: 'step.begin', uuid: 's2' }), + loopEvent({ type: 'content.part', stepUuid: 's2', part: { type: 'think', think: '' } }), + loopEvent({ type: 'step.end', uuid: 's2' }), + loopEvent({ type: 'step.begin', uuid: 's3' }), + loopEvent({ type: 'step.begin', uuid: 's4' }), + loopEvent({ type: 'content.part', stepUuid: 's4', part: { type: 'text', text: 'recovered' } }), + loopEvent({ type: 'step.end', uuid: 's4' }), + appendMessage(userMessage('u2')), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(comparable(transcript.entries)).toEqual(comparable(live)); + expect(transcript.entries.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + 'user', + ]); + expect(transcript.foldedLength).toBe(live.length); + }); + + it('tracks the live context length across compaction', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + appendMessage(userMessage('u2')), + ...assistantStep('s2', 'a2'), + compaction('SUM', 4, 2), + appendMessage(userMessage('u3')), + ...assistantStep('s3', 'a3'), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(live).toHaveLength(5); + expect(transcript.foldedLength).toBe(live.length); + expect(live[2]!.origin).toEqual({ kind: 'compaction_summary' }); + }); + + it('tracks the live context length across clear and undo', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + { type: 'context.clear' }, + appendMessage(userMessage('u2')), + ...assistantStep('s2', 'a2'), + appendMessage(userMessage('u3')), + ...assistantStep('s3', 'a3'), + undo(1), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(comparable(live)).toEqual(comparable(transcript.entries.slice(-2))); + expect(transcript.foldedLength).toBe(live.length); + }); +}); From a0fa8877e187b5050cb4bc9570c1ce24a7fe6bbe Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 12:50:15 +0800 Subject: [PATCH 2/7] fix(agent-core-v2): drop every removed prompt's injections on multi-turn transcript undo The transcript undo only walked prompt-owned injections off the oldest counted anchor, so with count > 1 an injection owned by a newer removed prompt (e.g. an image-compression caption) survived the display undo while the live context removed it. Collect every counted anchor's id during the walk and sweep their owned injections afterwards, keeping the transcript's 'prompt-owned ones leave with their prompt' contract for every count and matching the live view. --- .../agent/contextMemory/contextTranscript.ts | 27 +++++++------ .../contextMemory/contextTranscript.test.ts | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index f7acbf6c51..5bf5b6b063 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -21,7 +21,7 @@ import { collectCompactableUserMessages, selectRecentUserMessages, } from './compactionHandoff'; -import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; +import { isUndoAnchor } from './conversationTime'; import { createLoopEventFold, type LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -107,6 +107,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const applyUndo = (count: number): void => { if (count <= 0) return; let removedUserCount = 0; + const removedAnchorIds = new Set(); for (let i = transcript.length - 1; i >= clearFloor; i--) { const message = transcript[i]!.message; if (message.origin?.kind === 'injection') continue; @@ -115,17 +116,19 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { foldedLength = Math.max(0, foldedLength - 1); if (isUndoAnchor(message)) { removedUserCount++; - if (removedUserCount >= count) { - while ( - i > clearFloor && - isPromptOwnedInjection(transcript[i - 1]!.message, message) - ) { - transcript.splice(i - 1, 1); - i--; - foldedLength = Math.max(0, foldedLength - 1); - } - break; - } + if (message.id !== undefined) removedAnchorIds.add(message.id); + if (removedUserCount >= count) break; + } + } + for (let i = transcript.length - 1; i >= clearFloor && removedAnchorIds.size > 0; i--) { + const origin = transcript[i]!.message.origin; + if ( + origin?.kind === 'injection' && + origin.ownerPromptId !== undefined && + removedAnchorIds.has(origin.ownerPromptId) + ) { + transcript.splice(i, 1); + foldedLength = Math.max(0, foldedLength - 1); } } resetOpenState(); 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 19131da94f..42bde4d644 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -409,4 +409,44 @@ describe('live fold parity', () => { expect(comparable(live)).toEqual(comparable(transcript.entries.slice(-2))); expect(transcript.foldedLength).toBe(live.length); }); + + it('removes injections owned by every removed prompt on multi-turn undo, matching the live view', () => { + const records: WireRecord[] = [ + appendMessage( + userMessage('injA', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'p1', + }), + ), + appendMessage({ ...userMessage('u1', { kind: 'user' }), id: 'p1' }), + ...assistantStep('s1', 'a1'), + appendMessage( + userMessage('injB', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'p2', + }), + ), + appendMessage({ ...userMessage('u2', { kind: 'user' }), id: 'p2' }), + ...assistantStep('s2', 'a2'), + undo(2), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(comparable(transcript.entries)).toEqual(comparable(live)); + expect(transcript.entries).toHaveLength(0); + expect(transcript.foldedLength).toBe(live.length); + }); + + it('keeps injections not owned by any removed prompt across undo', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('note', { kind: 'injection', variant: 'test' })), + appendMessage(userMessage('u1')), + appendMessage(assistantMessage('a1')), + undo(1), + ]); + expect(texts(result)).toEqual(['note']); + expect(result.foldedLength).toBe(1); + }); }); From 3c4bc4ebfdcaae605663d9c54d4dad308ce6569b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 13:01:09 +0800 Subject: [PATCH 3/7] refactor(agent-core-v2): drop module headers from the context fold modules The comment-free zone lint only allows JSDoc on exported symbols. --- .../agent/contextMemory/contextTranscript.ts | 15 ------- .../src/agent/contextMemory/loopEventFold.ts | 43 ------------------- 2 files changed, 58 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 5bf5b6b063..4ce0542b5b 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,18 +1,3 @@ -/** - * `contextMemory` domain — rebuilds display history from the wire journal. - * - * Supplies transcript consumers with full pre-compaction history and folded - * context length while preserving undo/clear semantics. Loop events and - * interleaved `context.append_message`s are reduced by the shared - * `createLoopEventFold` core (`loopEventFold`) with a transcript - * materialization that keeps every entry's originating record time and tracks - * `foldedLength` — the length the live folded context would have after the - * same records, so callers can detect and append a not-yet-flushed live tail. - * Compaction, undo, and clear rewrite the materialization itself (kept - * prefix + summary marker, tail removal, clear floor) and stay local. - * Scope-agnostic. - */ - import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { WireRecord } from '#/wire/record'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index dc4de59227..25dbba8146 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -1,46 +1,3 @@ -/** - * `contextMemory` loop-event fold — one reduction core for the loop-event - * stream, materialized through pluggable sinks. - * - * The agent loop streams a turn as `context.append_loop_event` records - * (`step.begin` / `content.part` / `tool.call` / `tool.result` / `step.end`) - * and never writes a folded assistant message, keeping the on-disk shape - * byte-compatible with v1. `createLoopEventFold` reduces that stream — - * interleaved with non-loop `context.append_message`s — through a - * `LoopEventFoldSink`, so every consumer shares a single state machine: the - * `contextMemoryKey` folds in `contextOps` materialize the folded live - * `ContextMessage[]` (at live dispatch time and again on replay) through - * `foldLoopEvent` / `foldAppendMessage` / `resetFold`, while - * `contextTranscript` materializes the full pre-compaction transcript with - * per-entry record times and `foldedLength`. - * - * Semantics mirror the v1 fold exactly: - * - `step.begin` → settle the step left open (a failed attempt has no - * `step.end`), then open an assistant message - * - `content.part`→ append to the open assistant's content - * - `tool.call` → append to the open assistant's `toolCalls`, mark pending - * - `tool.result` → push a `tool` message (with the v1 output wrapping), - * clear its pending id - * - `step.end` → settle the open step - * "Settle" closes any tool exchange left open (interrupted result messages), - * then drops the open assistant when nothing sendable was recorded (no tool - * calls; every content part vacuous — an output-free assistant only trips - * provider message validation) and seals it otherwise. v1 never produced - * `step.begin` without `step.end` (its retries stayed inside one request), so - * the drop/seal rule is the v2 extension that makes loop-level retries — a - * retried attempt is its own `step.begin` — replay to the same history the - * live loop folded. - * 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. - * - * The live-view entry points stay pure transforms: cross-record state rides - * in a `WeakMap` keyed by each committed state array (immer drafts resolve to - * their `original`), so the public `getState(contextMemoryKey)` view stays a - * plain `ContextMessage[]` and concurrent folds of different agent scopes - * never share fold state. - */ - import { isDraft, original } from 'immer'; import type { FinishReason } from '#/kosong/contract/provider'; From f4146bbbf7e1b95165ef99da8acdc3ead672a886 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 17:06:09 +0800 Subject: [PATCH 4/7] fix(agent-core-v2): recover fold state after rehydration --- .../src/agent/contextMemory/loopEventFold.ts | 55 ++++++++++++++++--- .../agent/contextMemory/splice-replay.test.ts | 45 +++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 25dbba8146..56cb912c23 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -81,10 +81,23 @@ export interface LoopEventFold { } export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { - let openStepUuid: string | undefined; - let openHasToolCalls = false; - let openVacuous = true; - const pending = new Set(); + return createLoopEventFoldWithState(sink); +} + +interface InitialFoldState { + readonly openHasToolCalls: boolean; + readonly openVacuous: boolean; + readonly pendingToolCallIds: readonly string[]; +} + +function createLoopEventFoldWithState( + sink: LoopEventFoldSink, + initial?: InitialFoldState, +): LoopEventFold { + let openStepUuid: string | null | undefined = initial === undefined ? undefined : null; + let openHasToolCalls = initial?.openHasToolCalls ?? false; + let openVacuous = initial?.openVacuous ?? true; + const pending = new Set(initial?.pendingToolCallIds); let deferred: { message: ContextMessage; time: number | undefined }[] = []; const flushDeferred = (): void => { @@ -110,6 +123,14 @@ export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { } openStepUuid = undefined; }; + const acceptsOpenStep = (stepUuid: string): boolean => { + if (openStepUuid === undefined) return false; + if (openStepUuid === null) { + openStepUuid = stepUuid; + return true; + } + return stepUuid === openStepUuid; + }; return { appendMessage(message, time) { @@ -135,13 +156,13 @@ export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { return; } case 'content.part': { - if (openStepUuid === undefined || event.stepUuid !== openStepUuid) return; + if (!acceptsOpenStep(event.stepUuid)) return; sink.appendOpenContent(event.part); openVacuous = openVacuous && isVacuousContentPart(event.part); return; } case 'tool.call': { - if (openStepUuid === undefined || event.stepUuid !== openStepUuid) return; + if (!acceptsOpenStep(event.stepUuid)) return; const call: ToolCall = { type: 'function', id: event.toolCallId, @@ -224,7 +245,7 @@ function boundOf(state: readonly ContextMessage[]): BoundFold { let bound = boundFoldMap.get(key); if (bound === undefined || bound.sink.current() !== key) { const sink = createImmutableFoldSink(key); - bound = { fold: createLoopEventFold(sink), sink }; + bound = { fold: createLoopEventFoldWithState(sink, recoverFoldState(key)), sink }; boundFoldMap.set(key, bound); } return bound; @@ -286,6 +307,26 @@ function findOpenAssistantIndex(state: readonly ContextMessage[]): number { return -1; } +function recoverFoldState(state: readonly ContextMessage[]): InitialFoldState | undefined { + const openIndex = findOpenAssistantIndex(state); + if (openIndex === -1) return undefined; + const open = state[openIndex]!; + const resolvedToolCallIds = new Set(); + for (let i = openIndex + 1; i < state.length; i++) { + const message = state[i]!; + if (message.role === 'tool' && message.toolCallId !== undefined) { + resolvedToolCallIds.add(message.toolCallId); + } + } + return { + openHasToolCalls: open.toolCalls.length > 0, + openVacuous: open.content.every(isVacuousContentPart), + pendingToolCallIds: open.toolCalls + .map((call) => call.id) + .filter((toolCallId) => !resolvedToolCallIds.has(toolCallId)), + }; +} + function interruptedToolMessage(toolCallId: string): ContextMessage { return { ...createToolMessage(toolCallId, TOOL_INTERRUPTED_ON_RESUME_OUTPUT), 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 0bf9e6454e..f6b68621cb 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 @@ -7,6 +7,7 @@ import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService'; import { + ContextAppendLoopEvent, ContextAppendMessage, ContextApplyCompaction, ContextClear, @@ -475,6 +476,50 @@ describe('AgentContextMemoryService (wire-backed)', () => { expect(mediaUrl(rebuilt[0]!)).toBe(dataUri); }); + it('settles an open step when blob rehydration replaces the folded context state', async () => { + const host = buildHost(KEY); + const big = 'A'.repeat(200); + + await host.dispatcher.dispatch(new ContextAppendMessage({ message: imageMessage(big) })); + await host.dispatcher.dispatch( + new ContextAppendLoopEvent({ event: { type: 'step.begin', uuid: 'interrupted' } }), + ); + await host.dispatcher.flush(); + const records = await readRecords(host.log); + + const replay = buildHost(REPLAY_KEY); + await restoreTestEventDispatcher( + replay.dispatcher, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); + expect(blob.loadCalls).toBeGreaterThanOrEqual(1); + + await replay.dispatcher.dispatch(new ContextAppendMessage({ message: userMessage('retry') })); + await replay.dispatcher.dispatch( + new ContextAppendLoopEvent({ event: { type: 'step.begin', uuid: 'recovered' } }), + ); + await replay.dispatcher.dispatch( + new ContextAppendLoopEvent({ + event: { + type: 'content.part', + stepUuid: 'recovered', + part: { type: 'text', text: 'answer' }, + }, + }), + ); + await replay.dispatcher.dispatch( + new ContextAppendLoopEvent({ event: { type: 'step.end', uuid: 'recovered' } }), + ); + + const rebuilt = replay.agentState.get(contextMemoryKey); + expect(rebuilt.map((message) => message.role)).toEqual(['user', 'user', 'assistant']); + expect(textOf(rebuilt[1]!)).toBe('retry'); + expect(textOf(rebuilt[2]!)).toBe('answer'); + expect(rebuilt.some((message) => message.partial === true)).toBe(false); + }); + it('publishes context.spliced on live dispatch and is silent on replay', async () => { const host = buildHost(KEY); const live: { start: number; deleteCount: number }[] = []; From f3d447fc485b55987584d09da8187afe559324ab Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 17:45:15 +0800 Subject: [PATCH 5/7] fix(agent-core-v2): scope undo injections to their prompt --- .../agent/contextMemory/contextTranscript.ts | 23 ++++++--------- .../contextMemory/contextTranscript.test.ts | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 4ce0542b5b..f3e216e0b7 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -6,7 +6,7 @@ import { collectCompactableUserMessages, selectRecentUserMessages, } from './compactionHandoff'; -import { isUndoAnchor } from './conversationTime'; +import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; import { createLoopEventFold, type LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -92,7 +92,6 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const applyUndo = (count: number): void => { if (count <= 0) return; let removedUserCount = 0; - const removedAnchorIds = new Set(); for (let i = transcript.length - 1; i >= clearFloor; i--) { const message = transcript[i]!.message; if (message.origin?.kind === 'injection') continue; @@ -101,21 +100,17 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { foldedLength = Math.max(0, foldedLength - 1); if (isUndoAnchor(message)) { removedUserCount++; - if (message.id !== undefined) removedAnchorIds.add(message.id); + while ( + i > clearFloor && + isPromptOwnedInjection(transcript[i - 1]!.message, message) + ) { + transcript.splice(i - 1, 1); + i--; + foldedLength = Math.max(0, foldedLength - 1); + } if (removedUserCount >= count) break; } } - for (let i = transcript.length - 1; i >= clearFloor && removedAnchorIds.size > 0; i--) { - const origin = transcript[i]!.message.origin; - if ( - origin?.kind === 'injection' && - origin.ownerPromptId !== undefined && - removedAnchorIds.has(origin.ownerPromptId) - ) { - transcript.splice(i, 1); - foldedLength = Math.max(0, foldedLength - 1); - } - } resetOpenState(); }; 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 42bde4d644..75d13082e9 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -439,6 +439,35 @@ describe('live fold parity', () => { expect(transcript.foldedLength).toBe(live.length); }); + it('keeps the older prompt injection when the removed prompt reuses its id', () => { + const records: WireRecord[] = [ + appendMessage( + userMessage('injA', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'shared', + }), + ), + appendMessage({ ...userMessage('u1', { kind: 'user' }), id: 'shared' }), + ...assistantStep('s1', 'a1'), + appendMessage( + userMessage('injB', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'shared', + }), + ), + appendMessage({ ...userMessage('u2', { kind: 'user' }), id: 'shared' }), + ...assistantStep('s2', 'a2'), + undo(1), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(texts(transcript)).toEqual(['injA', 'u1', 'a1']); + expect(comparable(transcript.entries)).toEqual(comparable(live)); + expect(transcript.foldedLength).toBe(3); + }); + it('keeps injections not owned by any removed prompt across undo', () => { const result = reduceContextTranscript([ appendMessage(userMessage('note', { kind: 'injection', variant: 'test' })), From dd77f9f86f54559c1402c1da24a0c146bc04a99c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 21:12:53 +0800 Subject: [PATCH 6/7] fix(agent-core-v2): settle open transcript frames when compaction lands mid-fold An overflow-triggered compaction arrives with the failed attempt's frame still open. The transcript appended the summary marker and reset the fold but left the frame, so a vacuous partial stayed in the entries while the live context dropped it, and a pending tool exchange lost its interrupted result. Settle through the shared fold core at the marker instead: close pending tool calls, drop or seal the open frame, then append the summary. recoverFoldedLength recomputes the absolute count right after either way. --- .../agent/contextMemory/contextTranscript.ts | 2 +- .../src/agent/contextMemory/loopEventFold.ts | 5 +++ .../contextMemory/contextTranscript.test.ts | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index f3e216e0b7..303dfbb995 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -125,6 +125,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { break; } case 'context.apply_compaction': { + fold.settle(record.time); transcript.push({ message: { role: 'user', @@ -135,7 +136,6 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { time: record.time, }); foldedLength = recoverFoldedLength(record, transcript, clearFloor, foldedLength); - resetOpenState(); break; } case 'context.undo': diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 56cb912c23..f10abbd935 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -77,6 +77,7 @@ export interface LoopEventFoldSink { export interface LoopEventFold { appendMessage(message: ContextMessage, time?: number): void; loopEvent(event: LoopRecordedEvent, time?: number): void; + settle(time?: number): void; reset(): void; } @@ -195,6 +196,10 @@ function createLoopEventFoldWithState( } } }, + settle(time) { + settleOpen(time); + flushDeferred(); + }, reset() { openStepUuid = undefined; openHasToolCalls = 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 75d13082e9..20854bd0ee 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -393,6 +393,43 @@ describe('live fold parity', () => { expect(live[2]!.origin).toEqual({ kind: 'compaction_summary' }); }); + it('settles a frame left open by a failed attempt when compaction lands mid-fold', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + loopEvent({ type: 'step.begin', uuid: 's2' }), + compaction('SUM', 3, 1), + ...assistantStep('s3', 'a3'), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(live.map((m) => m.role)).toEqual(['user', 'user', 'assistant']); + expect(texts(transcript)).toEqual(['u1', 'a1', 'SUM', 'a3']); + expect(transcript.foldedLength).toBe(live.length); + }); + + it('closes a pending tool exchange when compaction lands mid-fold', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + loopEvent({ type: 'step.begin', uuid: 's2' }), + loopEvent({ type: 'tool.call', stepUuid: 's2', toolCallId: 'c1', name: 'Bash' }), + compaction('SUM', 2, 1), + ...assistantStep('s3', 'a3'), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(transcript.entries.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + expect(transcript.entries[2]!.toolCallId).toBe('c1'); + expect(transcript.entries[2]!.isError).toBe(true); + expect(transcript.foldedLength).toBe(live.length); + }); + it('tracks the live context length across clear and undo', () => { const records: WireRecord[] = [ appendMessage(userMessage('u1')), From 7f7dc705d6d7412dbc6d73a3e66f61ad84889652 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 18 Aug 2026 21:24:38 +0800 Subject: [PATCH 7/7] fix(agent-core-v2): keep legacy compaction recovery on the pre-settlement count A legacy context.apply_compaction record (compactedCount without keptUserMessageCount) recovers foldedLength as 1 + (foldedLength - compactedCount), and the live legacy tail shape keeps the unsettled open frame inside history.slice(compactedCount). Settling the fold for those records shifted foldedLength by the settlement delta before the recovery read it, leaving the transcript count one off the live context. Gate the settle to modern records; legacy records keep the previous freeze-and-reset behavior. --- .../agent/contextMemory/contextTranscript.ts | 6 ++++- .../contextMemory/contextTranscript.test.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 303dfbb995..75df69068b 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -125,7 +125,11 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { break; } case 'context.apply_compaction': { - fold.settle(record.time); + if (readNumber(record, 'keptUserMessageCount') !== undefined) { + fold.settle(record.time); + } else { + resetOpenState(); + } transcript.push({ message: { role: 'user', 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 20854bd0ee..9eb10056f3 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -430,6 +430,28 @@ describe('live fold parity', () => { expect(transcript.foldedLength).toBe(live.length); }); + it('keeps legacy compaction recovery on the pre-settlement count', () => { + const records: WireRecord[] = [ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + loopEvent({ type: 'step.begin', uuid: 's2' }), + compaction('SUM', 1), + ...assistantStep('s3', 'a3'), + ]; + const live = foldLive(records); + const transcript = reduceContextTranscript(records); + expect(live.map((m) => m.role)).toEqual(['user', 'assistant', 'assistant', 'assistant']); + expect(live[2]!.partial).toBe(true); + expect(transcript.entries.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'assistant', + 'user', + 'assistant', + ]); + expect(transcript.foldedLength).toBe(live.length); + }); + it('tracks the live context length across clear and undo', () => { const records: WireRecord[] = [ appendMessage(userMessage('u1')),