Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8780d6a
refactor(agent-core-v2): carry the context fold cursor in state and c…
7Sageer Aug 13, 2026
2d41945
test(agent-core-v2): move fold parity rationales into the test file h…
7Sageer Aug 13, 2026
e83ad3f
docs(agent-core-v2): move fold declaration comments into module headers
7Sageer Aug 13, 2026
2b47fe7
refactor(agent-core-v2): merge FoldFrame into generic ContextState
7Sageer Aug 13, 2026
e7fe16e
refactor(agent-core-v2): compile-enforce part/event handling decision…
7Sageer Aug 13, 2026
55b9e0b
refactor(agent-core-v2): converge undo-cut decision in conversationTime
7Sageer Aug 14, 2026
c5cf562
fix(agent-core-v2): drop removed prompts' injections on multi-turn tr…
7Sageer Aug 14, 2026
a8ee42c
refactor(agent-core-v2): accumulate request projection repairs as policy
7Sageer Aug 14, 2026
5ddab0c
refactor(agent-core-v2): derive the visible context window from an ap…
7Sageer Aug 14, 2026
8892e84
fix(agent-core-v2): rehydrate exactly the messages the visible window…
7Sageer Aug 14, 2026
5ba63a4
fix(agent-core-v2): pop the visible-tail swarm reminder behind a lega…
7Sageer Aug 14, 2026
a7a0b45
fix(agent-core-v2): settle open transcript frames when compaction lan…
7Sageer Aug 14, 2026
29c0a54
fix(agent-core-v2): settle open frames at the compaction marker
7Sageer Aug 17, 2026
51f31ae
Merge remote-tracking branch 'origin/main' into refactor-context
7Sageer Aug 17, 2026
641157e
refactor(agent-core-v2): tighten naming and comments in context memor…
7Sageer Aug 17, 2026
2c36afe
fix(agent-core-v2): preserve bounded context state
7Sageer Aug 17, 2026
a085428
Merge remote-tracking branch 'origin/main' into refactor-context
7Sageer Aug 17, 2026
450645f
docs(agent-core-v2): restore the domain identity line in the compacti…
7Sageer Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
89 changes: 57 additions & 32 deletions packages/agent-core-v2/src/agent/contextMemory/contextOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/

Expand All @@ -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[],
Expand Down Expand Up @@ -111,25 +116,34 @@ async function dehydrateRecord(
return record;
}

export const ContextModel = defineModel<ContextMessage[]>('contextMemory', () => [], {
blobs: {
dehydrate: dehydrateRecord,
rehydrate: async (state, transform) => {
const { changed, result } = await dehydrateMessages(state, transform);
return changed ? result : state;
export const ContextModel = defineModel<ContextState>(
'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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pop visible legacy-tail reminders on swarm exit

When a restored legacy compaction record has no keptUserMessageCount, the visible window is [summary, ...window.slice(compactedCount)]; if that legacy tail contains the swarm_mode reminder, SwarmService.exit() sees it via context.get() and publishes a pop splice, but this reducer checks only the raw append-only log tail, which is the compaction marker, so the model context keeps the stale swarm reminder after exit. Remove the visible tail entry (or map it back to the log) instead of only testing state.messages.at(-1).

Useful? React with 👍 / 👎.

return resetFold(state.slice(0, -1)) as ContextMessage[];
return freezeContextState({ messages: state.messages.slice(0, -1), fold: EMPTY_FOLD });
}

declare module '#/wire/types' {
Expand All @@ -147,17 +161,25 @@ const loopRecordedEventSchema = z.custom<LoopRecordedEvent>();

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 = {
Expand Down Expand Up @@ -196,8 +218,8 @@ type ContextCompactionPayload = z.infer<typeof contextApplyCompactionSchema>;
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 });
},
});

Expand All @@ -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(
Expand Down Expand Up @@ -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,
});
},
});
Loading