Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion packages/agent-core-v2/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ interface ContextAppendMessagePayload {
origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined;
isError?: boolean;
note?: string;
compaction?: {
compactedCount: number;
tokensBefore: number;
tokensAfter?: number;
summaryOutputTokens?: number;
keptUserMessageCount?: number;
keptHeadUserMessageCount?: number;
droppedCount?: number;
legacyTail?: boolean;
};
};
}

Expand Down Expand Up @@ -339,7 +349,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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* `contextInjector` domain — `IAgentContextInjectorService` implementation.
*
* Injects registered context providers through `loop` and `systemReminder`,
* tracks their positions in `contextMemory` through `eventBus`, and reconciles
* those positions after `wire` restoration. Each provider call receives the
* Injects registered context providers through `loop` and `systemReminder`.
* Injection positions are NOT tracked state: they are a pure read-time scan
* (`findInjections`) over the model-visible window served by `contextMemory`,
* so splices, compaction derivation, undo, and wire restoration can never
* desync them. Each provider call receives the
* newest surviving injection of its own variant (`lastInjection`) and the
* typed disclosure recorded on it (`lastDisclosure`), so providers never read
* context layout or position indexes themselves. The plain-data `isNewTurn`
Expand All @@ -24,7 +26,6 @@ import { IAgentStateService } from '#/agent/state/agentState';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IEventBus } from '#/app/event/eventBus';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IWireService } from '#/wire/wire';
import {
IAgentContextInjectorService,
type ContextInjectionContent,
Expand All @@ -35,7 +36,6 @@ import {
interface ContextInjectionEntry {
readonly provider: ContextInjectionProvider;
readonly name: string;
readonly positions: number[];
}

export const contextInjectorIsNewTurnKey = defineState<boolean>(
Expand All @@ -52,7 +52,6 @@ export class AgentContextInjectorService extends Service implements IAgentContex
@IAgentLoopService loopService: IAgentLoopService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IEventBus private readonly eventBus: IEventBus,
@IWireService wire: IWireService,
@IAgentStateService private readonly states: IAgentStateService,
) {
super();
Expand All @@ -68,17 +67,6 @@ export class AgentContextInjectorService extends Service implements IAgentContex
this.isNewTurn = true;
}),
);
this._register(
this.eventBus.subscribe('context.spliced', (e) => {
this.handleSplice(e);
}),
);
this._register(
wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => {
this.resyncPositions();
await next();
}),
);
}

private get isNewTurn(): boolean {
Expand All @@ -93,12 +81,7 @@ export class AgentContextInjectorService extends Service implements IAgentContex
name: string,
provider: ContextInjectionProvider,
) {
const positions = findInjections(this.context.get(), name);
const entry: ContextInjectionEntry = {
provider,
name,
positions,
};
const entry: ContextInjectionEntry = { provider, name };
this.entries.add(entry);
return toDisposable(() => {
this.entries.delete(entry);
Expand All @@ -115,7 +98,7 @@ export class AgentContextInjectorService extends Service implements IAgentContex
this.isNewTurn = false;
const history = this.context.get();
for (const entry of this.entries) {
const injectedPositions: readonly number[] = [...entry.positions];
const injectedPositions: readonly number[] = findInjections(history, entry.name);
const lastInjectedAt = injectedPositions.at(-1) ?? null;
const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt];
const content = await entry.provider({
Expand Down Expand Up @@ -153,54 +136,8 @@ export class AgentContextInjectorService extends Service implements IAgentContex
});
}
}

private resyncPositions(): void {
const history = this.context.get();
for (const entry of this.entries) {
const found = findInjections(history, entry.name);
entry.positions.length = 0;
entry.positions.push(...found);
}
}

private handleSplice(splice: ContextSplice): void {
let insertedInjections: Map<string, number[]> | undefined;
splice.messages.forEach((message, offset) => {
if (message.origin?.kind !== 'injection') return;
insertedInjections ??= new Map();
const positions = insertedInjections.get(message.origin.variant);
if (positions === undefined) {
insertedInjections.set(message.origin.variant, [splice.start + offset]);
} else {
positions.push(splice.start + offset);
}
});
if (insertedInjections === undefined && splice.deleteCount === 0) return;

const deletedEnd = splice.start + splice.deleteCount;
const delta = splice.messages.length - splice.deleteCount;
for (const entry of this.entries) {
const adopted = insertedInjections?.get(entry.name) ?? [];
const positions = entry.positions;
if (adopted.length === 0 && positions.length === 0) continue;
let lo = 0;
while (lo < positions.length && positions[lo]! < splice.start) lo++;
let hi = lo;
while (hi < positions.length && positions[hi]! < deletedEnd) hi++;
for (let index = hi; index < positions.length; index++) {
positions[index] = positions[index]! + delta;
}
positions.splice(lo, hi - lo, ...adopted);
}
}
}

type ContextSplice = {
readonly start: number;
readonly deleteCount: number;
readonly messages: readonly ContextMessage[];
};

function findInjections(
history: readonly ContextMessage[],
variant: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* `contextMemory` domain helper — derives the v1-compatible full-compaction
* handoff shape for live rewrites, wire replay, and snapshot reducers.
* `contextMemory` domain helper — owns the v1-compatible full-compaction
* shape vocabulary: the marker message the fold appends, the read-time
* window derivation, and the live metadata computation.
*
* Token budgeting runs through an injectable {@link TokenEstimate}: the live
* path (`AgentContextMemoryService.applyCompaction`) passes the estimator
Expand All @@ -9,12 +10,21 @@
* 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]`). `buildContextCompactionShape` computes that
* layout eagerly for the live metadata path; `createCompactionMarkerMessage`
* + `deriveCompactionWindow` are its append-only counterparts — the fold
* appends the marker verbatim and `visibleWindow` re-derives the same layout
* at read time, so a layout change still lands in this one file.
* `compactionResultMessageCount` is the read-side mirror for projections that
* need only the length (the display transcript's `foldedLength`).
*/

import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens';
import type { ContentPart } from '#/kosong/contract/message';
import summaryPrefixTemplate from './compaction-summary-prefix.md?raw';
import type { ContextMessage, PromptOrigin } from './types';
import type { CompactionMeta, ContextMessage, PromptOrigin } from './types';

export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd();
export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
Expand Down Expand Up @@ -138,6 +148,79 @@ 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);
}

/**
* The message the `context.apply_compaction` fold appends: the summary
* message the destructive rewrite used to synthesize, plus the record fields
* mirrored as {@link CompactionMeta} so the append-only log stays
* self-describing. Legacy records carrying a verbatim summary message keep it
* (meta attached) exactly as the rewrite kept it.
*/
export function createCompactionMarkerMessage(input: ContextCompactionShapeInput): ContextMessage {
const meta: CompactionMeta = {
compactedCount: input.compactedCount,
tokensBefore: input.tokensBefore,
tokensAfter: input.tokensAfter,
summaryOutputTokens: input.summaryOutputTokens,
keptUserMessageCount: input.keptUserMessageCount,
keptHeadUserMessageCount: input.keptHeadUserMessageCount,
droppedCount: input.droppedCount,
legacyTail: input.legacyTail === true ? true : undefined,
};
const base =
usesLegacyTailShape(input) && input.legacySummaryMessage !== undefined
? input.legacySummaryMessage
: createCompactionSummaryMessage(input.contextSummary ?? input.summary);
return { ...base, compaction: meta };
}

/**
* Read-time counterpart of {@link buildContextCompactionShape}: folds one
* marker into the pre-compaction visible `window`, returning the new visible
* window. The marker enters the window stripped of its `CompactionMeta` (the
* meta is log bookkeeping, not conversation content), making the result
* byte-identical to what the destructive rewrite produced. Selection is
* re-derived deterministically from the window — the media-flat token
* heuristics make it dehydrate/rehydrate-invariant, and the persisted
* kept-user counts stay informational.
*/
export function deriveCompactionWindow(
window: readonly ContextMessage[],
marker: ContextMessage,
): readonly ContextMessage[] {
const meta = marker.compaction;
const summary = stripCompactionMeta(marker);
if (meta?.legacyTail === true) {
return [summary, ...window.slice(meta.compactedCount)];
}
const selection = selectCompactionUserMessages(
collectCompactableUserMessages(window),
COMPACT_USER_MESSAGE_MAX_TOKENS,
COMPACT_USER_MESSAGE_HEAD_TOKENS,
defaultTokenEstimate.message,
);
const elision = selection.elided
? createCompactionElisionMessage(selection.omittedTokens)
: undefined;
return elision === undefined
? [...selection.head, ...selection.tail, summary]
: [...selection.head, elision, ...selection.tail, summary];
}

function stripCompactionMeta(message: ContextMessage): ContextMessage {
if (message.compaction === undefined) return message;
const { compaction: _meta, ...stripped } = message;
void _meta;
return stripped;
}

export function buildCompactionSummaryText(summary: string): string {
const suffix = summary.trim();
return `${COMPACTION_SUMMARY_PREFIX}\n${suffix.length > 0 ? suffix : '(no summary available)'}`;
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -38,8 +38,18 @@ export interface ContextCompactionResult {
export interface IAgentContextMemoryService {
readonly _serviceBrand: undefined;

/** The model-visible window: the append-only folded log derived through
* `visibleWindow.deriveVisibleMessages` (compaction markers folded away).
* This is the history every consumer — LLM requests, token counting, undo,
* injections — should read. */
get(): readonly ContextMessage[];

/** The raw append-only folded log, pre-compaction history and summary
* markers included. Log entries keep stable identities across appends —
* use it for integrity checks (e.g. the compaction safety check), never
* for model-facing content. */
getLog(): readonly ContextMessage[];

append(...messages: readonly ContextMessage[]): void;

appendLoopEvent(event: LoopRecordedEvent): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* `contextMemory` domain — `IAgentContextMemoryService` implementation.
*
* Owns per-agent conversation history through `wire`, maintains measurements
* with `tokenCounting`, and broadcasts live mutations through `event`. Every
* splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes
* with `tokenCounting`, and broadcasts live mutations through `event`. The
* wire state is an append-only folded log; `get()` serves the model-visible
* window derived by `visibleWindow.deriveVisibleMessages`, while `getLog()`
* exposes the raw log for integrity checks. Every splice-shaped mutation
* (`clear` / `applyCompaction` / `undo`) publishes
* `context.spliced` from the live path only — replay rebuilds silently — and
* `undo` additionally truncates the measured-anchor ledger when the cut
* crosses an anchor, letting `tokenCounting` restore the surviving prefix's
Expand All @@ -30,18 +33,21 @@ 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';
import { deriveVisibleMessages } from './visibleWindow';

declare module '#/app/event/eventBus' {
interface DomainEventMap {
Expand Down Expand Up @@ -75,7 +81,11 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
}

get(): readonly ContextMessage[] {
return this.wire.getModel(ContextModel) as readonly ContextMessage[];
return deriveVisibleMessages(this.getLog());
}

getLog(): readonly ContextMessage[] {
return this.wire.getModel(ContextModel).messages as readonly ContextMessage[];
}

append(...messages: readonly ContextMessage[]): void {
Expand Down
Loading