diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index f2ef59abf..4d42da3e1 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -70,10 +70,10 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "record\nAgent\n IAgentRecordService" as record #FDEBD0
rectangle "blobStore\nAgent\n IAgentBlobStoreService" as blobStore #FDEBD0
rectangle "contextMemory\nAgent\n IAgentContextMemoryService" as contextMemory #FDEBD0
+ rectangle "contextOps\nAgent\n IAgentContextOpsService" as contextOps #FDEBD0
rectangle "contextProjector\nAgent\n IAgentContextProjectorService" as contextProjector #FDEBD0
rectangle "contextInjector\nAgent\n IAgentContextInjectorService" as contextInjector #FDEBD0
rectangle "contextSize\nAgent\n IAgentContextSizeService" as contextSize #FDEBD0
- rectangle "systemReminder\nAgent\n IAgentSystemReminderService" as systemReminder #FDEBD0
rectangle "profile\nAgent\n IAgentProfileService" as profile #FDEBD0
rectangle "prompt\nAgent\n IAgentPromptService" as prompt #FDEBD0
rectangle "turn\nAgent\n IAgentTurnService" as turn #FDEBD0
@@ -164,14 +164,14 @@ filestore --> storage #34495E
record --> wireRecord #34495E
contextMemory --> wireRecord #34495E
contextMemory --> record #34495E
+contextOps --> contextMemory #34495E
contextInjector --> contextMemory #34495E
+contextInjector --> contextOps #34495E
contextInjector --> turn #34495E
contextInjector --> loop #34495E
-contextInjector --> systemReminder #34495E
contextSize --> contextMemory #34495E
contextSize --> record #34495E
contextSize --> wireRecord #34495E
-systemReminder --> contextMemory #34495E
profile --> wireRecord #34495E
profile --> record #34495E
profile --> record #34495E
@@ -180,6 +180,7 @@ profile --> config #34495E
profile --> modelProvider #34495E
profile --> chatProvider #34495E
prompt --> contextMemory #34495E
+prompt --> contextOps #34495E
prompt --> turn #34495E
prompt --> loop #34495E
prompt --> wireRecord #34495E
@@ -188,8 +189,9 @@ turn --> loop #34495E
turn --> record #34495E
turn --> wireRecord #34495E
turn --> contextMemory #34495E
+turn --> contextOps #34495E
turn --> telemetry #34495E
-loop --> contextMemory #34495E
+loop --> contextOps #34495E
loop --> contextSize #34495E
loop --> llmRequester #34495E
loop --> record #34495E
@@ -237,11 +239,11 @@ plan --> contextInjector #34495E
plan --> telemetry #34495E
goal --> wireRecord #34495E
goal --> record #34495E
-goal --> systemReminder #34495E
goal --> record #34495E
goal --> telemetry #34495E
goal --> contextInjector #34495E
goal --> contextMemory #34495E
+goal --> contextOps #34495E
goal --> turn #34495E
goal --> loop #34495E
goal --> toolRegistry #34495E
@@ -263,6 +265,7 @@ task --> wireRecord #34495E
task --> telemetry #34495E
task --> prompt #34495E
task --> contextMemory #34495E
+task --> contextOps #34495E
task --> config #34495E
task --> storage #34495E
task --> session_context #34495E
@@ -276,7 +279,8 @@ cron --> config #34495E
cron --> storage #34495E
swarm --> wireRecord #34495E
swarm --> record #34495E
-swarm --> systemReminder #34495E
+swarm --> contextMemory #34495E
+swarm --> contextOps #34495E
mcp --> toolRegistry #34495E
mcp --> record #34495E
mcp --> toolExecutor #34495E
@@ -348,7 +352,7 @@ flag ..> config #16A085 : onDidChangeConfiguration
permissionMode ..> wireRecord #16A085 : permission.set_mode
userTool ..> wireRecord #16A085 : tools.register_/unregister_user_tool
profile ..> wireRecord #16A085 : config.update / tools.set_active_tools
-contextMemory ..> wireRecord #16A085 : context.splice
+contextMemory ..> wireRecord #16A085 : context. ops
cron ..> wireRecord #16A085 : cron.add / delete / cursor
todo ..> wireRecord #16A085 : todo.set
fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
@@ -375,7 +379,6 @@ toolStore ..> wireRecord #16A085 : tools.update_store
usage ..> wireRecord #16A085 : usage.record
contextSize ..> contextMemory #16A085 : hooks.onSpliced
contextSize ..> wireRecord #16A085 : context_size.measured
-loop ..> contextMemory #16A085 : hooks.onSpliced
loop ..> wireRecord #16A085 : hooks.onResumeEnded
plan ..> wireRecord #16A085 : plan_mode.enter/cancel/exit
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index b562fcc0d..f0539d490 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 5c00e6070..2a77444d2 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -139,8 +139,8 @@ const DOMAIN_LAYER = new Map([
['usage', 4],
['toolDedupe', 4],
['contextMemory', 4],
+ ['contextOps', 4],
['contextInjector', 4],
- ['systemReminder', 4],
['contextProjector', 4],
['contextSize', 4],
['fullCompaction', 4],
@@ -297,10 +297,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'permissionPolicy>profile',
'permissionRules>replayBuilder',
'record>replayBuilder',
- // `record` owns the replay read model, whose `message` records carry
- // `ContextMessage` (L4). `removeLastMessages` takes a set of them, so the
- // projection side references the context message type by structure only.
- 'record>contextMemory',
'plugin>externalHooks',
'plugin>mcp',
'profile>session',
@@ -314,6 +310,9 @@ const ALLOWED_EXCEPTIONS = new Set([
'toolExecutor>loop',
'userTool>profile',
'wireRecord>contextMemory',
+ // The v1.5 migration emits `context.*` operation records and references the
+ // arg tuple types declared by their owning domains (L4) — type-only.
+ 'wireRecord>contextOps',
'wireRecord>loop',
'wireRecord>tool',
'hostFolderBrowser>os/backends',
diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts
index b01c7e6af..560fdcfee 100644
--- a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts
+++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts
@@ -236,7 +236,7 @@ function sameRef(a: EdgeRef, b: EdgeRef): boolean {
/**
* Collect every top-level `interface` declaration in the tree, keyed by
* name. Used to pull each service's public callable surface out of its
- * token interface (e.g. `interface IAgentSystemReminderService { ... }`)
+ * token interface (e.g. `interface IAgentContextOpsService { ... }`)
* so the graph view can render every method as a port row even when
* nothing calls into it yet.
*
diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts
index df621905f..2448c14c5 100644
--- a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts
+++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts
@@ -30,12 +30,12 @@ export interface ServiceNode {
* bound at App and Session).
*/
id: string;
- /** Token identifier (e.g. `IAgentSystemReminderService`). */
+ /** Token identifier (e.g. `IAgentContextOpsService`). */
token: string;
- /** Impl class name (e.g. `AgentSystemReminderService`). */
+ /** Impl class name (e.g. `AgentContextOpsService`). */
impl: string;
scope: ServiceScope;
- /** First folder under `src/` (e.g. `systemReminder`). */
+ /** First folder under `src/` (e.g. `contextOps`). */
domain: string;
/** Repo-relative path of the impl file. */
file: string;
diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
index a578bed6f..799432df6 100644
--- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
+++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
@@ -5,9 +5,9 @@ import {
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService } from '#/agent/turn';
import type { ContextMessage } from '#/agent/contextMemory';
import {
@@ -33,7 +33,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
- @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
) {
super();
this._register(
@@ -89,7 +89,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
});
if (!this.entries.has(entry)) continue;
if (content === undefined || content.trim().length === 0) continue;
- this.reminders.appendSystemReminder(content, {
+ this.contextOps.appendSystemReminder(content, {
kind: 'injection',
variant: entry.variant,
});
diff --git a/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts b/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts
index d157d0417..ea59ea9c2 100644
--- a/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts
+++ b/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts
@@ -17,12 +17,12 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { ILogService } from '#/app/log';
import { IPluginService } from '#/app/plugin';
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import type { SkillCatalog, SkillDefinition } from '#/app/globalSkillCatalog/types';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentContextInjectorService } from './contextInjector';
@@ -43,7 +43,7 @@ export class PluginSessionStartInjectorService
constructor(
@IAgentContextInjectorService private readonly injector: IAgentContextInjectorService,
- @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IPluginService private readonly plugins: IPluginService,
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@@ -80,12 +80,12 @@ export class PluginSessionStartInjectorService
log: this.log,
});
if (reminder !== undefined) {
- this.reminders.appendSystemReminder(
+ this.contextOps.appendSystemReminder(
`${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`,
{ kind: 'injection', variant: INJECTION_VARIANT },
);
} else if (shouldNeutralizePluginSessionStart(this.context.get())) {
- this.reminders.appendSystemReminder(
+ this.contextOps.appendSystemReminder(
'There are currently no active plugin session starts. ' +
'This supersedes any earlier plugin_session_start reminder in this session.',
{ kind: 'injection', variant: INJECTION_VARIANT },
diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts
index 525537164..673b747c7 100644
--- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts
+++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts
@@ -1,26 +1,55 @@
import { createDecorator } from "#/_base/di";
+import type { ContentPart } from '#/app/llmProtocol';
import type { Hooks } from '#/hooks';
+import type { AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
import type { ContextMessage } from './types';
+export interface ContextSplicedEvent {
+ start: number;
+ deleteCount: number;
+ messages: ContextMessage[];
+ tokens?: number;
+}
+
+export type ContextSplice = (
+ start: number,
+ deleteCount: number,
+ insert: readonly ContextMessage[],
+ tokens?: number,
+) => void;
+
+export interface ContextReplayWriter {
+ push(record: AgentReplayRecordPayload): void;
+ removeMessages(messageIds: ReadonlySet): void;
+ cut(): void;
+}
+
+export interface ContextOperationBlobTarget {
+ readonly parts: readonly ContentPart[];
+ replace(args: T, parts: readonly ContentPart[]): T;
+}
+
+export interface ContextOperationDefinition {
+ readonly type: string;
+ readonly apply: (splice: ContextSplice, ...args: T) => void;
+ readonly replay: (replay: ContextReplayWriter, ...args: T) => void;
+ readonly blobs?: (args: T) => Iterable>;
+}
+
+export type ContextOperation = (...args: T) => void;
+
export interface IAgentContextMemoryService {
readonly _serviceBrand: undefined;
+ defineOperation(
+ definition: ContextOperationDefinition,
+ ): ContextOperation;
+
get(): readonly ContextMessage[];
- splice(
- start: number,
- deleteCount: number,
- messages: readonly ContextMessage[],
- tokens?: number,
- ): void;
readonly hooks: Hooks<{
- onSpliced: {
- start: number;
- deleteCount: number;
- messages: ContextMessage[];
- tokens?: number;
- };
+ onSpliced: ContextSplicedEvent;
}>;
}
diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
index c0f32e960..0c7554a6f 100644
--- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
+++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
@@ -3,34 +3,36 @@ import {
} from "#/_base/di";
import { OrderedHookSlot } from '#/hooks';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
-import { IAgentContextMemoryService } from './contextMemory';
+import type { WireRecordBlobSelector } from '#/agent/wireRecord';
+import {
+ IAgentContextMemoryService,
+ type ContextOperation,
+ type ContextOperationDefinition,
+ type ContextReplayWriter,
+ type ContextSplice,
+ type ContextSplicedEvent,
+} from './contextMemory';
import { ensureMessageId } from './messageId';
import type { ContextMessage } from './types';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+const CONTEXT_OPERATION_PREFIX = 'context.';
+
declare module '#/agent/wireRecord' {
interface WireRecordMap {
- 'context.splice': {
- start: number;
- deleteCount: number;
- messages: readonly ContextMessage[];
- tokens?: number;
- };
+ [type: `${typeof CONTEXT_OPERATION_PREFIX}${string}`]: { readonly args: readonly unknown[] };
}
}
export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService {
declare readonly _serviceBrand: undefined;
private readonly history: ContextMessage[] = [];
+ private readonly operationTypes = new Set();
+ private applying = false;
readonly hooks = {
- onSpliced: new OrderedHookSlot<{
- start: number;
- deleteCount: number;
- messages: ContextMessage[];
- tokens?: number;
- }>(),
+ onSpliced: new OrderedHookSlot(),
};
constructor(
@@ -38,71 +40,114 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
) {
super();
this._register(
- record.define('context.splice', {
- resume: (r) => {
- this.applySplice(r);
+ this.record.hooks.onRestoredRecord.register('context-operation-guard', async (ctx, next) => {
+ const type = ctx.record.type;
+ if (type.startsWith(CONTEXT_OPERATION_PREFIX) && !this.operationTypes.has(type)) {
+ throw new Error(`No context operation registered for restored record "${type}"`);
+ }
+ await next();
+ }),
+ );
+ }
+
+ defineOperation(
+ definition: ContextOperationDefinition,
+ ): ContextOperation {
+ const recordType = `${CONTEXT_OPERATION_PREFIX}${definition.type}` as const;
+ if (this.operationTypes.has(recordType)) {
+ throw new Error(`Context operation "${recordType}" is already defined`);
+ }
+ this.operationTypes.add(recordType);
+ const blobs = definition.blobs;
+ this._register(
+ this.record.define(recordType, {
+ resume: (record) => {
+ this.runOperation(definition, record.args as T);
},
- blobs: (r) =>
- r.messages.map((message, index) => ({
- parts: message.content,
- replace: (current, content) => ({
- ...current,
- messages: current.messages.map((item, itemIndex) =>
- itemIndex === index ? { ...item, content: [...content] } : item,
- ),
- }),
- })),
+ blobs:
+ blobs === undefined
+ ? undefined
+ : (adaptBlobSelector(blobs) as unknown as WireRecordBlobSelector<
+ AgentRecord
+ >),
}),
);
+ return (...args: T): void => {
+ if (this.applying) {
+ throw new Error(
+ `Context operation "${recordType}" invoked while another operation is applying`,
+ );
+ }
+ this.record.append({ type: recordType, args });
+ this.runOperation(definition, args);
+ };
}
get(): readonly ContextMessage[] {
return [...this.history];
}
- splice(
- start: number,
- deleteCount: number,
- messages: readonly ContextMessage[],
- tokens?: number,
+ private runOperation(
+ definition: ContextOperationDefinition,
+ args: T,
): void {
- const stamped = messages.map(ensureMessageId);
- const record: AgentRecord<'context.splice'> = {
- type: 'context.splice',
- start,
- deleteCount,
- messages: stamped,
- tokens,
- };
- this.record.append(record);
- this.applySplice(record);
+ this.applying = true;
+ try {
+ definition.apply(this.splice, ...args);
+ definition.replay(this.replayWriter, ...args);
+ } finally {
+ this.applying = false;
+ }
}
- private applySplice(record: AgentRecord<'context.splice'>): void {
- // A boundary splice (`start === 0 && deleteCount > 0`, i.e. compaction or
- // clear — see `isUndoBoundaryRecord`) never touches the replay: the removed
- // transcript stays visible, and what it inserts (a compaction summary) is
- // context machinery represented by its owner's record, not a message.
- // Every other splice mirrors itself into the replay.
- const boundary = record.start === 0 && record.deleteCount > 0;
- const removedMessages = boundary
- ? []
- : this.history.slice(record.start, record.start + record.deleteCount);
- const messages = record.messages.map(ensureMessageId);
- this.history.splice(record.start, record.deleteCount, ...messages);
- if (!boundary) {
- this.record.removeLastMessages(new Set(removedMessages));
- for (const message of messages) {
- this.record.push({ type: 'message', message });
- }
- }
+ private readonly splice: ContextSplice = (start, deleteCount, insert, tokens) => {
+ const boundedStart = normalizeSpliceStart(start, this.history.length);
+ const boundedDeleteCount = clampDeleteCount(deleteCount, this.history.length - boundedStart);
+ const messages = insert.map(ensureMessageId);
+ this.history.splice(boundedStart, boundedDeleteCount, ...messages);
void this.hooks.onSpliced.run({
- start: record.start,
- deleteCount: record.deleteCount,
+ start: boundedStart,
+ deleteCount: boundedDeleteCount,
messages,
- tokens: record.tokens,
+ tokens,
});
- }
+ };
+
+ private readonly replayWriter: ContextReplayWriter = {
+ push: (record) => {
+ this.record.push(record);
+ },
+ removeMessages: (messageIds) => {
+ this.record.removeMessages(messageIds);
+ },
+ cut: () => {
+ this.record.cut();
+ },
+ };
+}
+
+function normalizeSpliceStart(start: number, length: number): number {
+ if (Number.isNaN(start)) return length;
+ if (start < 0) return Math.max(0, length + Math.floor(start));
+ return Math.min(Math.floor(start), length);
+}
+
+function clampDeleteCount(deleteCount: number, max: number): number {
+ if (Number.isNaN(deleteCount) || deleteCount <= 0) return 0;
+ return Math.min(Math.floor(deleteCount), Math.max(0, max));
+}
+
+function adaptBlobSelector(
+ blobs: NonNullable['blobs']>,
+): WireRecordBlobSelector<{ args: T }> {
+ return (record) =>
+ Array.from(blobs(record.args), (target) => ({
+ parts: target.parts,
+ replace: (current: { args: T }, parts: NonNullable) => ({
+ ...current,
+ args: target.replace(current.args, parts),
+ }),
+ }));
}
registerScopedService(
diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts
index 0de6b253e..cba479403 100644
--- a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts
+++ b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts
@@ -2,11 +2,11 @@
* `contextMemory` message id helpers.
*
* Every `ContextMessage` gets a stable local id (`msg_`) when it enters
- * `IAgentContextMemoryService` — see `AgentContextMemoryService.splice`. The id is persisted in
- * the `context.splice` wire record, so it is stable across restarts. It is the
- * identity used for message lookup and snapshot correlation. Provider-assigned
- * ids live on the separate
- * `providerMessageId` field and never collide with this namespace.
+ * `IAgentContextMemoryService`. The id is persisted in context operation wire
+ * records, so it is stable across restarts. It is the identity used for message
+ * lookup, snapshot correlation, and replay-record removal. Provider-assigned ids
+ * live on the separate `providerMessageId` field and never collide with this
+ * namespace.
*/
import { ulid } from 'ulid';
diff --git a/packages/agent-core-v2/src/agent/contextOps/contextOps.ts b/packages/agent-core-v2/src/agent/contextOps/contextOps.ts
new file mode 100644
index 000000000..98bf8d0d7
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/contextOps/contextOps.ts
@@ -0,0 +1,55 @@
+/**
+ * `contextOps` domain (L4) — `IAgentContextOpsService` contract.
+ *
+ * The standard positional context operations shared across domains:
+ * `context.append` (append messages at the end), `context.remove` (remove
+ * resolved index/id targets), `context.clear` (drop the whole history and cut
+ * the replay segment), and `context.append_system_reminder` (append a
+ * `` user message). The legacy `context.replace` replay
+ * operation is registered by the implementation for old migrated wire only.
+ * Owning them in one service gives every migrated record type a single
+ * registrant that the restore preamble can resolve before replaying the wire
+ * log.
+ */
+
+import { createDecorator } from "#/_base/di";
+import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
+
+export type ContextAppendArgs = readonly ContextMessage[];
+export type ContextAppendSystemReminderArgs = [message: string, origin: PromptOrigin];
+export type ContextReplaceArgs = [index: number, message: ContextMessage];
+
+/**
+ * One resolved removal target. `index` is the position at the moment the
+ * removal applies — a multi-target removal lists indices in descending order
+ * so earlier removals do not shift later ones. `messageId` identifies the
+ * message in the replay read model.
+ */
+export interface ContextRemovalTarget {
+ readonly index: number;
+ readonly messageId?: string;
+}
+
+export type ContextRemoveArgs = [removals: readonly ContextRemovalTarget[]];
+export type ContextClearArgs = [];
+
+export interface IAgentContextOpsService {
+ readonly _serviceBrand: undefined;
+
+ /** Append messages at the end of the history (ids stamped when missing). */
+ append(...messages: readonly ContextMessage[]): void;
+
+ /**
+ * Append a `` message to the end of the history.
+ * Returns the message inserted into history.
+ */
+ appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage;
+
+ /** Remove the resolved targets (descending indices) from the history. */
+ remove(removals: readonly ContextRemovalTarget[]): void;
+
+ /** Drop the whole history; replay cuts a new segment at this point. */
+ clear(): void;
+}
+
+export const IAgentContextOpsService = createDecorator('agentContextOpsService');
diff --git a/packages/agent-core-v2/src/agent/contextOps/contextOpsService.ts b/packages/agent-core-v2/src/agent/contextOps/contextOpsService.ts
new file mode 100644
index 000000000..b40adb4ee
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/contextOps/contextOpsService.ts
@@ -0,0 +1,210 @@
+/**
+ * `contextOps` domain (L4) — `IAgentContextOpsService` implementation.
+ *
+ * Defines the standard operations against `contextMemory`'s operation
+ * registry. `append` and `append_system_reminder` splice at the end
+ * (Infinity start) and mirror each message into the replay read model;
+ * `remove` splices out each resolved target and drops the matching replay
+ * messages; `clear` drops the whole history and cuts a new replay segment;
+ * legacy `replace` swaps one message in place for old migrated wire. Structured
+ * message content is offloaded to the blob store through the operations that
+ * carry full messages. Bound at Agent scope.
+ */
+
+import { Disposable } from "#/_base/di";
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import {
+ ensureMessageId,
+ IAgentContextMemoryService,
+ type ContextMessage,
+ type ContextOperation,
+ type ContextOperationBlobTarget,
+ type PromptOrigin,
+} from '#/agent/contextMemory';
+
+import {
+ IAgentContextOpsService,
+ type ContextAppendArgs,
+ type ContextAppendSystemReminderArgs,
+ type ContextClearArgs,
+ type ContextRemovalTarget,
+ type ContextRemoveArgs,
+ type ContextReplaceArgs,
+} from './contextOps';
+
+export function removalMessageIds(removals: readonly ContextRemovalTarget[]): Set {
+ const ids = new Set();
+ for (const removal of removals) {
+ if (removal.messageId !== undefined) ids.add(removal.messageId);
+ }
+ return ids;
+}
+
+function normalizeAppendMessages(messages: readonly ContextMessage[]): readonly ContextMessage[] {
+ const first = messages[0];
+ if (messages.length === 1 && Array.isArray(first)) {
+ return first as readonly ContextMessage[];
+ }
+ return messages;
+}
+
+function messageContentBlobTarget(
+ message: ContextMessage,
+ rebuild: (args: T, message: ContextMessage) => T,
+ read: (args: T) => ContextMessage,
+): ContextOperationBlobTarget {
+ return {
+ parts: message.content,
+ replace: (args, parts) => {
+ const current = read(args);
+ return rebuild(args, { ...current, content: [...parts] });
+ },
+ };
+}
+
+function toSystemReminderText(content: string): string {
+ const trimmed = content.trim();
+ if (trimmed.startsWith('') && trimmed.endsWith('')) {
+ return trimmed;
+ }
+ return `\n${trimmed}\n`;
+}
+
+function toSystemReminderMessage(content: string, origin: PromptOrigin): ContextMessage {
+ return ensureMessageId({
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: toSystemReminderText(content),
+ },
+ ],
+ toolCalls: [],
+ origin,
+ });
+}
+
+export class AgentContextOpsService extends Disposable implements IAgentContextOpsService {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly appendOperation: ContextOperation;
+ private readonly appendSystemReminderOperation: ContextOperation;
+ private readonly removeOperation: ContextOperation;
+ private readonly clearOperation: ContextOperation;
+
+ constructor(
+ @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ ) {
+ super();
+
+ this.appendOperation = context.defineOperation({
+ type: 'append',
+ apply: (splice, ...messages) => {
+ splice(Number.POSITIVE_INFINITY, 0, normalizeAppendMessages(messages));
+ },
+ replay: (replay, ...messages) => {
+ for (const message of normalizeAppendMessages(messages)) {
+ replay.push({ type: 'message', message });
+ }
+ },
+ blobs: (messages) =>
+ normalizeAppendMessages(messages).map((message, index) =>
+ messageContentBlobTarget(
+ message,
+ (current, next) =>
+ normalizeAppendMessages(current).map((item, itemIndex) =>
+ itemIndex === index ? next : item,
+ ),
+ (current) => normalizeAppendMessages(current)[index]!,
+ ),
+ ),
+ });
+
+ this.appendSystemReminderOperation =
+ context.defineOperation({
+ type: 'append_system_reminder',
+ apply: (splice, message, origin) => {
+ splice(Number.POSITIVE_INFINITY, 0, [toSystemReminderMessage(message, origin)]);
+ },
+ replay: (replay, message, origin) => {
+ replay.push({
+ type: 'message',
+ message: toSystemReminderMessage(message, origin),
+ });
+ },
+ });
+
+ this.removeOperation = context.defineOperation({
+ type: 'remove',
+ apply: (splice, removals) => {
+ for (const removal of removals) {
+ splice(removal.index, 1, []);
+ }
+ },
+ replay: (replay, removals) => {
+ replay.removeMessages(removalMessageIds(removals));
+ },
+ });
+
+ this.clearOperation = context.defineOperation({
+ type: 'clear',
+ apply: (splice) => {
+ splice(0, Number.POSITIVE_INFINITY, []);
+ },
+ replay: (replay) => {
+ replay.cut();
+ },
+ });
+
+ // Migration-only: the v1.5 migration rewrites incremental streaming
+ // updates (v1.4 `update_message`) into in-place replaces. No live caller.
+ context.defineOperation({
+ type: 'replace',
+ apply: (splice, index, message) => {
+ splice(index, 1, [message]);
+ },
+ replay: (replay, _index, message) => {
+ if (message.id !== undefined) {
+ replay.removeMessages(new Set([message.id]));
+ }
+ replay.push({ type: 'message', message });
+ },
+ blobs: ([, message]) => [
+ messageContentBlobTarget(
+ message,
+ ([index], next) => [index, next],
+ ([, current]) => current,
+ ),
+ ],
+ });
+ }
+
+ append(...messages: readonly ContextMessage[]): void {
+ if (messages.length === 0) return;
+ this.appendOperation(...messages.map(ensureMessageId));
+ }
+
+ appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage {
+ this.appendSystemReminderOperation(content.trim(), origin);
+ return this.context.get().at(-1)!;
+ }
+
+ remove(removals: readonly ContextRemovalTarget[]): void {
+ if (removals.length === 0) return;
+ this.removeOperation(removals);
+ }
+
+ clear(): void {
+ if (this.context.get().length === 0) return;
+ this.clearOperation();
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Agent,
+ IAgentContextOpsService,
+ AgentContextOpsService,
+ InstantiationType.Delayed,
+ 'contextOps',
+);
diff --git a/packages/agent-core-v2/src/agent/contextOps/index.ts b/packages/agent-core-v2/src/agent/contextOps/index.ts
new file mode 100644
index 000000000..15c5a3b41
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/contextOps/index.ts
@@ -0,0 +1,2 @@
+export * from './contextOps';
+export * from './contextOpsService';
diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
index df88503e8..0d33a5025 100644
--- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
+++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
@@ -6,7 +6,7 @@
* (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, `fullCompaction`, and
* `task`) and translates those minimal contexts into the configured external
* HookEngine events. Appends UserPromptSubmit hook results and Stop hook
- * continuation prompts through `contextMemory`. The `SubagentStart` /
+ * continuation prompts through `contextOps`. The `SubagentStart` /
* `SubagentStop` pair is the one
* exception: the `agentLifecycle` tool wrapper has no hook service of its own,
* so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on
@@ -19,7 +19,8 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { isUserCancellation } from '#/_base/utils/abort';
import { isPlainRecord } from '#/_base/utils/canonical-args';
import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task';
-import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
+import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import {
IAgentFullCompactionService,
type FullCompactionDidCompactContext,
@@ -87,7 +88,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
constructor(
private readonly options: ExternalHooksServiceOptions = {},
- @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IInstantiationService private readonly instantiation: IInstantiationService,
@IConfigService private readonly config: IConfigService,
@@ -224,12 +225,12 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
const reason = await this.runStop(ctx);
if (reason !== undefined) {
this.stopHookContinuationUsed = true;
- this.context.splice(this.context.get().length, 0, [{
+ this.contextOps.append({
role: 'user',
content: [{ type: 'text', text: reason }],
toolCalls: [],
origin: { kind: 'system_trigger', name: 'stop_hook' },
- }]);
+ });
ctx.continue = true;
return;
}
@@ -321,12 +322,12 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
const block = renderUserPromptHookBlockResult(results);
if (block !== undefined) {
- this.context.splice(this.context.get().length, 0, [{
+ this.contextOps.append({
role: 'assistant',
content: [{ type: 'text', text: block.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: block.event, blocked: true },
- }]);
+ });
this.record.signal({
type: 'hook.result',
hookEvent: block.event,
@@ -338,12 +339,12 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
const append = renderUserPromptHookResult(results);
if (append !== undefined) {
- this.context.splice(this.context.get().length, 0, [{
+ this.contextOps.append({
role: 'user',
content: [{ type: 'text', text: append.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: append.event },
- }]);
+ });
this.record.signal({
type: 'hook.result',
hookEvent: append.event,
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
index b2a681a28..457a55a1f 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
@@ -5,8 +5,8 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { renderPrompt } from "#/_base/utils/render-prompt";
import { estimateTokens, estimateTokensForMessages } from "#/_base/utils/tokens";
-import type { ContextMessage } from '#/agent/contextMemory';
-import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import type { ContextMessage, ContextOperation } from '#/agent/contextMemory';
+import { ensureMessageId, IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import {
IAgentLLMRequesterService,
@@ -47,6 +47,12 @@ import {
} from './types';
import { OrderedHookSlot } from '#/hooks';
+export type ContextCompactArgs = [
+ compactedCount: number,
+ summary: ContextMessage,
+ tokensAfter?: number,
+];
+
declare module '#/agent/wireRecord' {
interface WireRecordMap {
'full_compaction.begin': CompactionBeginData;
@@ -93,6 +99,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
};
private readonly strategy: CompactionStrategy;
+ private readonly compactOperation: ContextOperation;
private compactionCountInTurn = 0;
private compacting: ActiveCompaction | null = null;
// Token count right after the last successful compaction. While nothing new
@@ -145,6 +152,29 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
await this.onLoopError(ctx, next);
}),
);
+ // Replace the compacted prefix with the summary message. The summary
+ // never enters the replay as a message — the compaction card (pushed by
+ // `full_compaction.begin`) is its only replay presence — but the history
+ // reset marks a segment boundary for partial-resume windowing.
+ this.compactOperation = this.context.defineOperation({
+ type: 'compact',
+ apply: (splice, compactedCount, summary, tokensAfter) => {
+ splice(0, compactedCount, [summary], tokensAfter);
+ },
+ replay: (replay) => {
+ replay.cut();
+ },
+ blobs: ([, summary]) => [
+ {
+ parts: summary.content,
+ replace: ([compactedCount, current, tokensAfter], parts) => [
+ compactedCount,
+ { ...current, content: [...parts] },
+ tokensAfter,
+ ],
+ },
+ ],
+ });
this._register(
record.define('full_compaction.begin', {
resume: (r) => {
@@ -165,8 +195,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this._register(
record.define('full_compaction.complete', {
resume: (r) => {
- // The summary message never enters the replay (its splice is a
- // boundary); the compaction record is its only replay presence.
+ // The summary message never enters the replay (`context.compact`
+ // only cuts); the compaction record is its only replay presence.
const message = compactionSummaryMessage(this.context.get());
if (message === undefined) return;
const summary = contextMessageText(message);
@@ -496,10 +526,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
...usageTelemetry(attempt.usage),
});
- this.context.splice(
- 0,
+ this.compactOperation(
compactedCount,
- [createCompactionSummaryMessage(summary)],
+ ensureMessageId(createCompactionSummaryMessage(summary)),
result.tokensAfter,
);
return result;
diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts
index f57a9dd25..b20df60bf 100644
--- a/packages/agent-core-v2/src/agent/goal/goalService.ts
+++ b/packages/agent-core-v2/src/agent/goal/goalService.ts
@@ -4,7 +4,7 @@
* Owns the per-agent goal lifecycle; persists records and broadcasts through
* `record`, injects reminders through `contextInjector`, drives continuation
* turns through `turn`, participates in steps through `loop`, updates context
- * through `contextMemory`, writes system reminders through `systemReminder`,
+ * through `contextMemory`, writes system reminders through `contextOps`,
* registers model tools through `toolRegistry`, and reports telemetry through
* `telemetry`. Bound at Agent scope.
*/
@@ -21,6 +21,7 @@ import {
type ContextMessage,
type PromptOrigin,
} from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { GoalInjection, type GoalInjectionOptions } from '#/agent/goal/injection/goalInjection';
import {
buildGoalBlockedReasonPrompt,
@@ -32,7 +33,6 @@ import {
type TurnBeforeStepContext,
} from '#/agent/loop';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService, type Turn, type TurnEndedContext } from '#/agent/turn';
import type { TokenUsage } from '#/app/llmProtocol';
import type { TelemetryProperties } from '#/app/telemetry';
@@ -147,10 +147,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
constructor(
private readonly options: GoalServiceOptions = {},
@IAgentRecordService private readonly record: IAgentRecordService,
- @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
) {
@@ -356,7 +356,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const snapshot = this.toSnapshot(state);
this.clearInternal(actor);
if (actor === 'user') {
- this.reminders.appendSystemReminder(GOAL_CANCELLED_REMINDER, {
+ this.contextOps.appendSystemReminder(GOAL_CANCELLED_REMINDER, {
kind: 'system_trigger',
name: 'goal_cancelled',
});
@@ -378,7 +378,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
this.appendStatusUpdate(state, actor, input.reason);
const snapshot = this.toSnapshot(state);
if (actor === 'model') {
- this.reminders.appendSystemReminder(buildGoalBlockedReasonPrompt(snapshot), {
+ this.contextOps.appendSystemReminder(buildGoalBlockedReasonPrompt(snapshot), {
kind: 'system_trigger',
name: GOAL_BLOCKED_REMINDER_NAME,
});
@@ -404,7 +404,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
actor,
});
if (actor === 'model') {
- this.reminders.appendSystemReminder(buildGoalCompletionSummaryPrompt(snapshot), {
+ this.contextOps.appendSystemReminder(buildGoalCompletionSummaryPrompt(snapshot), {
kind: 'system_trigger',
name: GOAL_COMPLETION_REMINDER_NAME,
});
@@ -503,7 +503,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
toolCalls: [],
origin: GOAL_CONTINUATION_ORIGIN,
});
- this.context.splice(this.context.get().length, 0, [message]);
+ this.contextOps.append(message);
this.turnService.launch();
}
@@ -591,7 +591,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const hadGoal = this.state !== undefined;
this.state = undefined;
if (!hadGoal) return;
- this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, {
+ this.contextOps.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, {
kind: 'system_trigger',
name: 'goal_fork_cleared',
});
diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts
index 3062d55cd..eb37c2f3a 100644
--- a/packages/agent-core-v2/src/agent/loop/loopService.ts
+++ b/packages/agent-core-v2/src/agent/loop/loopService.ts
@@ -17,7 +17,8 @@ import {
import { ErrorCodes, KimiError } from '#/errors';
import { OrderedHookSlot } from '#/hooks';
-import { IAgentContextMemoryService, newMessageId, type ContextMessage } from '../contextMemory';
+import { newMessageId } from '../contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection';
import {
createMaxStepsExceededError,
@@ -50,7 +51,7 @@ export class AgentLoopService implements IAgentLoopService {
};
constructor(
- @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService,
@@ -181,7 +182,7 @@ export class AgentLoopService implements IAgentLoopService {
const { providerFinishReason, message } = response;
let finishReason = providerFinishReason ?? 'completed';
- this.append({
+ this.contextOps.append({
id: newMessageId(),
role: 'assistant',
content: response.message.content,
@@ -197,7 +198,7 @@ export class AgentLoopService implements IAgentLoopService {
turnId,
})) {
const { result } = toolResult;
- this.append({
+ this.contextOps.append({
...createToolMessage(toolResult.toolCallId, toolResultOutputForModel(result)),
role: 'tool',
isError: result.isError,
@@ -237,10 +238,6 @@ export class AgentLoopService implements IAgentLoopService {
};
}
- private append(message: ContextMessage): void {
- this.context.splice(this.context.get().length, 0, [message]);
- }
-
private emitStepCompleted(
turnId: number,
step: number,
diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts
index 556a6fa86..a6150bd9f 100644
--- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts
+++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts
@@ -1,5 +1,5 @@
-import { IAgentSwarmService } from '#/agent/swarm';
-import type { IAgentSwarmService as AgentSwarmService } from '#/agent/swarm';
+import { IAgentSwarmService } from '#/agent/swarm/swarm';
+import type { IAgentSwarmService as AgentSwarmService } from '#/agent/swarm/swarm';
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import type {
PermissionPolicy,
diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts
index 45da94144..b22994199 100644
--- a/packages/agent-core-v2/src/agent/prompt/promptService.ts
+++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts
@@ -6,7 +6,13 @@ import {
ensureMessageId,
IAgentContextMemoryService,
type ContextMessage,
+ type ContextOperation,
} from '#/agent/contextMemory';
+import {
+ IAgentContextOpsService,
+ removalMessageIds,
+ type ContextRemovalTarget,
+} from '#/agent/contextOps';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService, type Turn } from '#/agent/turn';
@@ -23,10 +29,13 @@ interface QueuedSteer {
removed: boolean;
}
+export type ContextUndoArgs = [removals: readonly ContextRemovalTarget[]];
+
export class AgentPromptService implements IAgentPromptService {
declare readonly _serviceBrand: undefined;
private readonly steerQueue: QueuedSteer[] = [];
private observedTurn: Turn | undefined;
+ private readonly undoOperation: ContextOperation;
readonly hooks = {
onWillSubmitPrompt: new OrderedHookSlot(),
@@ -34,6 +43,7 @@ export class AgentPromptService implements IAgentPromptService {
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentLoopService loopService: IAgentLoopService,
@@ -48,11 +58,23 @@ export class AgentPromptService implements IAgentPromptService {
}
await next();
});
+
+ this.undoOperation = context.defineOperation({
+ type: 'undo',
+ apply: (splice, removals) => {
+ for (const removal of removals) {
+ splice(removal.index, 1, []);
+ }
+ },
+ replay: (replay, removals) => {
+ replay.removeMessages(removalMessageIds(removals));
+ },
+ });
}
async prompt(message: ContextMessage): Promise {
const stamped = ensureMessageId(message);
- this.append(stamped);
+ this.contextOps.append(stamped);
if (await this.blockedByHook(stamped, false)) return undefined;
return this.launch();
}
@@ -88,6 +110,7 @@ export class AgentPromptService implements IAgentPromptService {
const history = this.context.get();
let removedCount = 0;
+ const removals: ContextRemovalTarget[] = [];
let stoppedAtCompaction = false;
for (let index = history.length - 1; index >= 0 && removedCount < count; index--) {
const message = history[index];
@@ -97,12 +120,16 @@ export class AgentPromptService implements IAgentPromptService {
break;
}
- this.context.splice(index, 1, []);
+ removals.push({ index, messageId: message.id });
if (isRealUserPrompt(message)) {
removedCount++;
}
}
+ if (removals.length > 0) {
+ this.undoOperation(removals);
+ }
+
if (removedCount < count && !this.record.restoring) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
@@ -122,14 +149,7 @@ export class AgentPromptService implements IAgentPromptService {
clear(): void {
this.discardQueuedSteers();
- const historyLength = this.context.get().length;
- if (historyLength > 0) {
- this.context.splice(0, historyLength, []);
- }
- }
-
- private append(...messages: ContextMessage[]): void {
- this.context.splice(this.context.get().length, 0, messages);
+ this.contextOps.clear();
}
private launch(): Turn {
@@ -168,7 +188,7 @@ export class AgentPromptService implements IAgentPromptService {
for (const entry of pending) {
entry.emitted = true;
}
- this.append(...pending.map((entry) => entry.message));
+ this.contextOps.append(...pending.map((entry) => entry.message));
return true;
}
diff --git a/packages/agent-core-v2/src/agent/record/record.ts b/packages/agent-core-v2/src/agent/record/record.ts
index f5acf1b9e..fe25f692c 100644
--- a/packages/agent-core-v2/src/agent/record/record.ts
+++ b/packages/agent-core-v2/src/agent/record/record.ts
@@ -6,15 +6,14 @@
* durable persistence (for resume), live broadcast (`AgentEvent` to the edge),
* and the replay read model. `signal(event)` emits a live-only event that is
* never recorded (deltas / progress). The replay read model (`buildReplay`,
- * `push`/`patchLast`/`removeLastMessages`) is owned here too — it is just one
- * more projection of the same record stream. Bound at Agent scope.
+ * `push`/`patchLast`/`removeMessages`/`cut`) is owned here too — it is just
+ * one more projection of the same record stream. Bound at Agent scope.
*/
import type { AgentEvent } from '@moonshot-ai/protocol';
import type { IDisposable } from '#/_base/di';
import { createDecorator } from '#/_base/di';
-import type { ContextMessage } from '#/agent/contextMemory';
import type {
IAgentWireRecordService,
WireRecordBlobSelector,
@@ -101,9 +100,9 @@ export interface IAgentRecordService {
/**
* Append a record to the replay read model directly. Used when the projected
* data is computed inside a domain handler rather than derived from a single
- * record via `toReplay` (e.g. `contextMemory` projecting spliced messages).
- * Gated by phase: captured while restoring/post-restoring, or always when
- * `captureLiveRecords` is set.
+ * record via `toReplay` (e.g. `contextMemory` forwarding a context
+ * operation's replay projection). Gated by phase: captured while
+ * restoring/post-restoring, or always when `captureLiveRecords` is set.
*/
push(record: AgentReplayRecordPayload): void;
/** Patch the most recent replay record of `type` (restore-time only). */
@@ -111,8 +110,16 @@ export interface IAgentRecordService {
type: T,
patch: Partial>,
): void;
- /** Drop replay `message` records whose message is in `removedMessages`. */
- removeLastMessages(removedMessages: ReadonlySet): void;
+ /** Drop replay `message` records whose message id is in `messageIds`. */
+ removeMessages(messageIds: ReadonlySet): void;
+ /**
+ * Mark a history-reset boundary (compaction / clear) in the replay stream.
+ * Drives partial-resume windowing: when a `range.start` window is
+ * configured, a cut either discards the segment before the window or
+ * freezes the read model (stopping the restore) once the window is full.
+ * A no-op without a windowed range.
+ */
+ cut(): void;
/** Replay read model built from restored (and optionally live) records. */
buildReplay(): readonly AgentReplayRecord[];
/** When true, live `append` calls also feed the replay read model. */
diff --git a/packages/agent-core-v2/src/agent/record/recordService.ts b/packages/agent-core-v2/src/agent/record/recordService.ts
index f007d43e6..5fb31e058 100644
--- a/packages/agent-core-v2/src/agent/record/recordService.ts
+++ b/packages/agent-core-v2/src/agent/record/recordService.ts
@@ -8,12 +8,15 @@
* Live emission is suppressed while restoring, so edge consumers never receive
* historical events.
*
- * The replay read model (`push` / `patchLast` / `removeLastMessages` /
+ * The replay read model (`push` / `patchLast` / `removeMessages` / `cut` /
* `buildReplay`) is owned here too — it is one more projection of the same
- * record stream, fed by `toReplay` facets (declarative) and by direct `push`
- * calls from domain handlers (imperative). The former `eventSink` and
- * `replayBuilder` services are folded into this class; `wireRecord` remains the
- * registered persistence backend that this service coordinates.
+ * record stream, fed by `toReplay` facets (declarative) and by direct calls
+ * from domain handlers (imperative — e.g. `contextMemory` forwarding a
+ * context operation's replay projection). `cut()` marks history-reset
+ * boundaries declared by context operations (compaction / clear) and drives
+ * the partial-resume windowing. The former `eventSink` and `replayBuilder`
+ * services are folded into this class; `wireRecord` remains the registered
+ * persistence backend that this service coordinates.
*/
import { Disposable, toDisposable } from '#/_base/di';
@@ -23,7 +26,6 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { AgentEvent } from '@moonshot-ai/protocol';
-import type { ContextMessage } from '#/agent/contextMemory';
import {
IAgentWireRecordService,
type WireRecord,
@@ -41,17 +43,6 @@ import {
type RecordServiceOptions,
} from './record';
-// An undo boundary is a `context.splice` that removes messages from the start of
-// the history. It is the canonical (post v1.5 migration) equivalent of the legacy
-// `context.clear` and `context.apply_compaction` records, both of which the v1.5
-// migration rewrites into a `context.splice` with `start === 0` and
-// `deleteCount > 0` (see wireRecord/migration/v1.5.ts). A splice that only
-// appends (`deleteCount === 0`) or removes messages from the middle/end of the
-// history (`start > 0`, e.g. a migrated `context.undo`) is not a boundary.
-function isUndoBoundaryRecord(record: WireRecord): boolean {
- return record.type === 'context.splice' && record.start === 0 && record.deleteCount > 0;
-}
-
export class AgentRecordService extends Disposable implements IAgentRecordService {
declare readonly _serviceBrand: undefined;
private readonly facets = new Map>();
@@ -78,7 +69,9 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
wireRecord.hooks.onRestoredRecord.register('record-replay', async (ctx, next) => {
await next();
this.runReplayFacet(ctx.record as unknown as AgentRecord);
- if (this.finishRestoringRecord(ctx.record)) {
+ // Once a cut() froze the windowed read model, the requested replay
+ // window is complete — stop restoring further records.
+ if (this.options.range !== undefined && this.frozen) {
ctx.stop = true;
}
}),
@@ -164,10 +157,32 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
}
}
- removeLastMessages(removedMessages: ReadonlySet): void {
+ removeMessages(messageIds: ReadonlySet): void {
if (this.frozen) return;
- if (removedMessages.size === 0) return;
- this.removeMessagesFrom(this.replayRecords, removedMessages);
+ if (messageIds.size === 0) return;
+ for (let i = this.replayRecords.length - 1; i >= 0; i--) {
+ const record = this.replayRecords[i]!;
+ if (
+ record.type === 'message' &&
+ record.message.id !== undefined &&
+ messageIds.has(record.message.id)
+ ) {
+ this.replayRecords.splice(i, 1);
+ }
+ }
+ }
+
+ cut(): void {
+ if (this.frozen) return;
+ const start = this.options.range?.start;
+ if (start === undefined) return;
+ const nextSegmentStart = this.segmentStart + this.replayRecords.length;
+ if (nextSegmentStart > start) {
+ this.frozen = true;
+ return;
+ }
+ this.segmentStart = nextSegmentStart;
+ this.replayRecords.splice(0);
}
buildReplay(): readonly AgentReplayRecord[] {
@@ -220,36 +235,6 @@ export class AgentRecordService extends Disposable implements IAgentRecordServic
}
}
- private finishRestoringRecord(record: WireRecord): boolean {
- const range = this.options.range;
- if (range === undefined) return false;
- if (this.frozen) return true;
- if (!isUndoBoundaryRecord(record)) return false;
- if (range.start === undefined) return false;
-
- const start = range.start;
- const nextSegmentStart = this.segmentStart + this.replayRecords.length;
- if (nextSegmentStart > start) {
- this.frozen = true;
- return true;
- }
-
- this.segmentStart = nextSegmentStart;
- this.replayRecords.splice(0);
- return false;
- }
-
- private removeMessagesFrom(
- records: AgentReplayRecord[],
- removedMessages: ReadonlySet,
- ): void {
- for (let i = records.length - 1; i >= 0; i--) {
- const record = records[i]!;
- if (record.type === 'message' && removedMessages.has(record.message)) {
- records.splice(i, 1);
- }
- }
- }
}
registerScopedService(
diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts
index 7399f60df..eb0f4e4fc 100644
--- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts
+++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts
@@ -1,17 +1,17 @@
/**
* `swarm` domain (L4) — `IAgentSwarmService` implementation.
*
- * Tracks swarm-mode enter/exit (mirroring it into `wireRecord` and
- * `systemReminder`) and auto-exits on turn end. Bound at Agent scope. The
- * `AgentSwarm` tool self-registers via `registerTool(...)` in
- * `tools/agent-swarm.ts`.
+ * Tracks swarm-mode enter/exit (mirroring it into `wireRecord` and context
+ * operations) and auto-exits on turn end. Bound at Agent scope. The `AgentSwarm`
+ * tool self-registers via `registerTool(...)` in `tools/agent-swarm.ts`.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentRecordService } from '#/agent/record';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService } from '#/agent/turn';
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
@@ -36,7 +36,8 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
constructor(
@IAgentRecordService private readonly record: IAgentRecordService,
- @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
+ @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IAgentTurnService turnService: IAgentTurnService,
) {
super();
@@ -93,7 +94,10 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
if (this._active !== null) return;
this._active = trigger;
if (injectReminder && trigger !== 'tool') {
- this.reminders.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode' });
+ this.contextOps.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, {
+ kind: 'injection',
+ variant: 'swarm_mode',
+ });
}
this.emitChanged();
}
@@ -102,15 +106,27 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
if (this._active === null) return;
const trigger = this._active;
this._active = null;
- const removedEnterReminder = trigger !== 'tool' && this.reminders.removeLastReminder(
- (m) => m.origin?.kind === 'injection' && m.origin.variant === 'swarm_mode',
- );
+ const removedEnterReminder = trigger !== 'tool' && this.removeLastSwarmEnterReminder();
if (injectExitReminder && trigger !== 'tool' && !removedEnterReminder) {
- this.reminders.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { kind: 'injection', variant: 'swarm_mode_exit' });
+ this.contextOps.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, {
+ kind: 'injection',
+ variant: 'swarm_mode_exit',
+ });
}
this.emitChanged();
}
+ private removeLastSwarmEnterReminder(): boolean {
+ const history = this.context.get();
+ const lastIndex = history.length - 1;
+ const last = history[lastIndex];
+ if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') {
+ return false;
+ }
+ this.contextOps.remove([{ index: lastIndex, messageId: last.id }]);
+ return true;
+ }
+
private emitChanged(): void {
this.record.signal({ type: 'agent.status.updated', swarmMode: this.isActive });
}
diff --git a/packages/agent-core-v2/src/agent/systemReminder/index.ts b/packages/agent-core-v2/src/agent/systemReminder/index.ts
deleted file mode 100644
index e7f22472d..000000000
--- a/packages/agent-core-v2/src/agent/systemReminder/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * `systemReminder` domain barrel — re-exports the reminder contract and its
- * scoped service. Importing this barrel registers the `IAgentSystemReminderService`
- * binding into the scope registry.
- */
-
-export * from './systemReminder';
-export * from './systemReminderService';
diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts
deleted file mode 100644
index 0699203c8..000000000
--- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { createDecorator } from "#/_base/di";
-
-import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
-
-export interface IAgentSystemReminderService {
- readonly _serviceBrand: undefined;
-
- /**
- * Append a `` message to the end of the context memory.
- * Returns the created message.
- */
- appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage;
- /**
- * Remove the last context message if it passes the supplied filter.
- * Returns `true` when a message was removed.
- */
- removeLastReminder(filter: (message: ContextMessage) => boolean): boolean;
-}
-
-export const IAgentSystemReminderService = createDecorator('agentSystemReminderService');
diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts
deleted file mode 100644
index 567578fd9..000000000
--- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import {
- Disposable,
-} from "#/_base/di";
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IAgentContextMemoryService } from '#/agent/contextMemory';
-import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
-
-import { IAgentSystemReminderService } from './systemReminder';
-
-export class AgentSystemReminderService extends Disposable implements IAgentSystemReminderService {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
- ) {
- super();
- }
-
- appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage {
- const message: ContextMessage = {
- role: 'user',
- content: [
- {
- type: 'text',
- text: `\n${content.trim()}\n`,
- },
- ],
- toolCalls: [],
- origin,
- };
- this.context.splice(this.context.get().length, 0, [message]);
- return message;
- }
-
- removeLastReminder(filter: (message: ContextMessage) => boolean): boolean {
- const history = this.context.get();
- const lastIndex = history.length - 1;
- const last = history[lastIndex];
- if (last === undefined || !filter(last)) {
- return false;
- }
- this.context.splice(lastIndex, 1, []);
- return true;
- }
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- IAgentSystemReminderService,
- AgentSystemReminderService,
- InstantiationType.Delayed,
- 'systemReminder',
-);
diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts
index bb94a5d86..156c3875d 100644
--- a/packages/agent-core-v2/src/agent/task/taskService.ts
+++ b/packages/agent-core-v2/src/agent/task/taskService.ts
@@ -27,6 +27,7 @@ import {
} from './types';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IConfigService } from '#/app/config';
import { IAgentPromptService } from '#/agent/prompt';
import { ISessionContext } from '#/session/sessionContext';
@@ -49,9 +50,6 @@ import {
} from './task';
import { LEGACY_BACKGROUND_SECTION, TASK_SECTION, type AgentTaskConfig } from './configSection';
import { AgentTaskPersistence } from './persist';
-import { TaskListTool } from '#/agent/task/tools/task-list';
-import { TaskOutputTool } from '#/agent/task/tools/task-output';
-import { TaskStopTool } from '#/agent/task/tools/task-stop';
import { OrderedHookSlot } from '#/hooks';
declare module '#/agent/wireRecord' {
@@ -148,6 +146,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentPromptService private readonly prompt: IAgentPromptService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentContextOpsService private readonly contextOps: IAgentContextOpsService,
@IConfigService private readonly config: IConfigService,
@IAtomicDocumentStore atomicDocs: IAtomicDocumentStore,
@IFileSystemStorageService byteStore: IFileSystemStorageService,
@@ -832,14 +831,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
private async restoreAgentTaskNotification(info: AgentTaskInfo): Promise {
const context = await this.buildAgentTaskNotificationContext(info);
if (context === undefined) return;
- this.context.splice(this.context.get().length, 0, [
- {
- role: 'user',
- content: [...context.content],
- toolCalls: [],
- origin: context.origin,
- },
- ]);
+ this.contextOps.append({
+ role: 'user',
+ content: [...context.content],
+ toolCalls: [],
+ origin: context.origin,
+ });
this.fireNotificationHook(context.notification);
}
diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts
index 57c00cc4a..239bf57d1 100644
--- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts
+++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts
@@ -293,6 +293,20 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu
}
}
+/** Test-driver surface that mirrors the old internal call shape. */
+export interface ToolDedupeTestDriver {
+ readonly currentStreak: number;
+ beginStep(turnId?: number, step?: number): Promise;
+ endStep(): Promise;
+ checkSameStep(toolCallId: string, toolName: string, args: unknown): Promise;
+ finalizeResult(
+ toolCallId: string,
+ toolName: string,
+ args: unknown,
+ result: ToolDedupResult,
+ ): Promise;
+}
+
export const __testing = {
REMINDER_TEXT_1,
REMINDER_TEXT_3,
@@ -301,6 +315,27 @@ export const __testing = {
REPEAT_REMINDER_2_START,
REPEAT_REMINDER_3_START,
REPEAT_FORCE_STOP_STREAK,
+ createDriver(service: AgentToolDedupeService): ToolDedupeTestDriver {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const s = service as any;
+ return {
+ get currentStreak() {
+ return service.currentStreak;
+ },
+ beginStep: async (turnId?: number, step?: number) => {
+ s.beginStep(turnId, step);
+ },
+ endStep: async () => {
+ s.endStep();
+ },
+ checkSameStep: async (toolCallId: string, toolName: string, args: unknown) => {
+ const checked = s.checkToolCall(toolCallId, toolName, args);
+ return checked.syntheticResult as ToolDedupResult | null;
+ },
+ finalizeResult: (toolCallId: string, toolName: string, args: unknown, result: ToolDedupResult) =>
+ s.finalizeResult(toolCallId, toolName, args, result),
+ };
+ },
};
registerScopedService(
diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.5.ts b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.5.ts
index f4aebc5c5..347721c89 100644
--- a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.5.ts
+++ b/packages/agent-core-v2/src/agent/wireRecord/migration/v1.5.ts
@@ -5,6 +5,10 @@ import {
} from '#/app/llmProtocol';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
+// Deep import (not the barrel): the barrel would runtime-cycle back into this
+// migration through contextMemoryService → record → wireRecord → migrations.
+import { ensureMessageId } from '#/agent/contextMemory/messageId';
+import type { ContextRemovalTarget } from '#/agent/contextOps';
import type { ExecutableToolResult } from '#/agent/tool';
import type { WireMigration, WireMigrationRecord } from './index';
@@ -21,14 +25,19 @@ export const migrateV1_4ToV1_5: WireMigration = {
targetVersion: '1.5',
migrateRecords(records: readonly WireMigrationRecord[]): readonly WireMigrationRecord[] {
const state = new V1_5MigrationState();
- return records.flatMap((record) => state.migrate(record));
+ const output: WireMigrationRecord[] = [];
+ for (const record of records) {
+ output.push(...state.migrate(record));
+ }
+ output.push(...state.finish());
+ return output;
},
};
class V1_5MigrationState {
private readonly history: ContextMessage[] = [];
private readonly openSteps = new Map();
- private readonly pendingToolResultIds = new Set();
+ private readonly pendingToolResultSteps = new Map();
private readonly launchedTurnIds = new Set();
private readonly promptAppendDedupKeys: string[] = [];
private deferredMessages: ContextMessage[] = [];
@@ -58,8 +67,6 @@ class V1_5MigrationState {
return [];
case 'context.undo':
return this.migrateUndo(record as V1_4UndoRecord);
- case 'context.splice':
- return this.preserveContextSplice(record as V1_5ContextSpliceRecord);
case 'forked':
return this.migrateForked(record);
default:
@@ -93,7 +100,7 @@ class V1_5MigrationState {
if (this.consumePromptAppendDedup(message)) {
return [];
}
- if (this.pendingToolResultIds.size > 0) {
+ if (this.pendingToolResultSteps.size > 0) {
this.deferredMessages.push(message);
return [];
}
@@ -108,7 +115,7 @@ class V1_5MigrationState {
output.push(...this.closePendingToolResults(record));
this.openSteps.set(event.uuid, {
message: createAssistantMessage(),
- inserted: false,
+ source: record,
});
return output;
case 'content.part':
@@ -129,28 +136,37 @@ class V1_5MigrationState {
}
private migrateClear(record: WireMigrationRecord): WireMigrationRecord[] {
+ const output = this.flushOpenSteps();
const deleteCount = this.history.length;
this.history.splice(0, deleteCount);
this.resetLoopState();
- if (deleteCount === 0) return [];
- return [this.createContextSpliceRecord(0, deleteCount, [], record)];
+ if (deleteCount > 0) {
+ output.push(withTime(record, { type: 'context.clear', args: [] }));
+ }
+ return output;
}
private migrateApplyCompaction(record: V1_4ApplyCompactionRecord): WireMigrationRecord[] {
- const message = createCompactionSummaryMessage(record.summary);
+ const output = this.flushOpenSteps();
+ const message = ensureMessageId(createCompactionSummaryMessage(record.summary));
const deleteCount = clampDeleteCount(record.compactedCount, this.history.length);
this.history.splice(0, deleteCount, message);
this.resetLoopState();
- return [
- this.createContextSpliceRecord(0, deleteCount, [message], record),
+ output.push(
+ withTime(record, {
+ type: 'context.compact',
+ args: [deleteCount, cloneContextMessage(message), record.tokensAfter],
+ }),
this.createFullCompactionCompleteRecord(record),
- ];
+ );
+ return output;
}
private migrateUndo(record: V1_4UndoRecord): WireMigrationRecord[] {
if (record.count <= 0) return [];
- const output: WireMigrationRecord[] = [];
+ const output = this.flushOpenSteps();
+ const removals: ContextRemovalTarget[] = [];
let removedUserCount = 0;
for (let index = this.history.length - 1; index >= 0; index--) {
const message = this.history[index];
@@ -159,24 +175,19 @@ class V1_5MigrationState {
if (message.origin?.kind === 'compaction_summary') break;
this.history.splice(index, 1);
- output.push(this.createContextSpliceRecord(index, 1, [], record));
+ removals.push({ index, messageId: message.id });
if (isRealUserPrompt(message)) {
removedUserCount++;
if (removedUserCount >= record.count) break;
}
}
this.resetLoopState();
+ if (removals.length > 0) {
+ output.push(withTime(record, { type: 'context.undo', args: [removals] }));
+ }
return output;
}
- private preserveContextSplice(record: V1_5ContextSpliceRecord): WireMigrationRecord[] {
- const start = normalizedSpliceStart(record.start, this.history.length);
- const deleteCount = clampDeleteCount(record.deleteCount, this.history.length - start);
- const messages = record.messages.map(cloneContextMessage);
- this.history.splice(start, deleteCount, ...messages);
- return [record];
- }
-
private migrateForked(record: WireMigrationRecord): WireMigrationRecord[] {
if (!this.hasGoal) return [];
this.hasGoal = false;
@@ -187,17 +198,18 @@ class V1_5MigrationState {
event: LoopContentPartEvent,
record: WireMigrationRecord,
): WireMigrationRecord[] {
- return this.replaceOpenStep(event.stepUuid, record, (message) => ({
+ this.updateOpenStep(event.stepUuid, record, (message) => ({
...message,
content: [...message.content, cloneContentPart(event.part)],
}));
+ return [];
}
private applyToolCall(
event: LoopToolCallEvent,
record: WireMigrationRecord,
): WireMigrationRecord[] {
- const output = this.replaceOpenStep(event.stepUuid, record, (message) => ({
+ this.updateOpenStep(event.stepUuid, record, (message) => ({
...message,
toolCalls: [
...message.toolCalls,
@@ -209,17 +221,19 @@ class V1_5MigrationState {
},
],
}));
- this.pendingToolResultIds.add(event.toolCallId);
- return output;
+ this.pendingToolResultSteps.set(event.toolCallId, event.stepUuid);
+ return [];
}
private applyToolResult(
event: LoopToolResultEvent,
record: WireMigrationRecord,
): WireMigrationRecord[] {
- if (!this.pendingToolResultIds.has(event.toolCallId)) return [];
- const output = this.appendToolResult(event.toolCallId, event.result, record);
- this.pendingToolResultIds.delete(event.toolCallId);
+ const stepUuid = this.pendingToolResultSteps.get(event.toolCallId);
+ if (stepUuid === undefined) return [];
+ const output = this.flushOpenStep(stepUuid, record);
+ output.push(...this.appendToolResult(event.toolCallId, event.result, record));
+ this.pendingToolResultSteps.delete(event.toolCallId);
output.push(...this.flushDeferredMessages(record));
return output;
}
@@ -228,37 +242,48 @@ class V1_5MigrationState {
event: LoopStepEndEvent,
record: WireMigrationRecord,
): WireMigrationRecord[] {
+ const output = this.flushOpenStep(event.uuid, record);
this.openSteps.delete(event.uuid);
- return this.flushDeferredMessages(record);
+ output.push(...this.flushDeferredMessages(record));
+ return output;
}
- private replaceOpenStep(
+ private updateOpenStep(
stepUuid: string,
record: WireMigrationRecord,
update: (message: ContextMessage) => ContextMessage,
- ): WireMigrationRecord[] {
+ ): void {
const openStep = this.openSteps.get(stepUuid) ?? {
message: createAssistantMessage(),
- inserted: false,
+ source: record,
};
- const next = update(openStep.message);
- if (!openStep.inserted) {
- const inserted = cloneContextMessage(next);
- const start = this.history.length;
- this.history.push(inserted);
- this.openSteps.set(stepUuid, { message: inserted, inserted: true });
- return [this.createContextSpliceRecord(start, 0, [inserted], record)];
- }
+ this.openSteps.set(stepUuid, {
+ message: update(openStep.message),
+ source: record,
+ });
+ }
- const index = this.history.indexOf(openStep.message);
- if (index < 0) {
- this.openSteps.set(stepUuid, { message: next, inserted: false });
- return this.appendNow(next, record);
+ private flushOpenStep(
+ stepUuid: string,
+ source: WireMigrationRecord,
+ ): WireMigrationRecord[] {
+ const openStep = this.openSteps.get(stepUuid);
+ if (openStep === undefined) return [];
+ this.openSteps.delete(stepUuid);
+ if (isEmptyAssistantMessage(openStep.message)) return [];
+ return this.appendNow(openStep.message, source);
+ }
+
+ private flushOpenSteps(): WireMigrationRecord[] {
+ const output: WireMigrationRecord[] = [];
+ for (const [stepUuid, openStep] of [...this.openSteps]) {
+ output.push(...this.flushOpenStep(stepUuid, openStep.source));
}
+ return output;
+ }
- this.history.splice(index, 1, next);
- this.openSteps.set(stepUuid, { message: next, inserted: true });
- return [this.createContextSpliceRecord(index, 1, [next], record)];
+ finish(): WireMigrationRecord[] {
+ return this.flushOpenSteps();
}
private appendToolResult(
@@ -278,10 +303,14 @@ class V1_5MigrationState {
}
private closePendingToolResults(record: WireMigrationRecord): WireMigrationRecord[] {
- if (this.pendingToolResultIds.size === 0) return [];
+ if (this.pendingToolResultSteps.size === 0) return [];
const output: WireMigrationRecord[] = [];
- const toolCallIds = [...this.pendingToolResultIds];
+ const toolCallIds = [...this.pendingToolResultSteps.keys()];
for (const toolCallId of toolCallIds) {
+ const stepUuid = this.pendingToolResultSteps.get(toolCallId);
+ if (stepUuid !== undefined) {
+ output.push(...this.flushOpenStep(stepUuid, record));
+ }
output.push(
...this.appendToolResult(
toolCallId,
@@ -292,14 +321,14 @@ class V1_5MigrationState {
record,
),
);
- this.pendingToolResultIds.delete(toolCallId);
+ this.pendingToolResultSteps.delete(toolCallId);
}
output.push(...this.flushDeferredMessages(record));
return output;
}
private flushDeferredMessages(record: WireMigrationRecord): WireMigrationRecord[] {
- if (this.pendingToolResultIds.size > 0 || this.deferredMessages.length === 0) {
+ if (this.pendingToolResultSteps.size > 0 || this.deferredMessages.length === 0) {
return [];
}
const messages = this.deferredMessages;
@@ -315,23 +344,18 @@ class V1_5MigrationState {
message: ContextMessage,
record: WireMigrationRecord,
): WireMigrationRecord[] {
- const next = cloneContextMessage(message);
- const start = this.history.length;
+ const next = ensureMessageId(cloneContextMessage(message));
this.history.push(next);
- return [this.createContextSpliceRecord(start, 0, [next], record)];
+ return [this.createAppendRecord(next, record)];
}
- private createContextSpliceRecord(
- start: number,
- deleteCount: number,
- messages: readonly ContextMessage[],
+ private createAppendRecord(
+ message: ContextMessage,
source: WireMigrationRecord,
): WireMigrationRecord {
return withTime(source, {
- type: 'context.splice',
- start,
- deleteCount,
- messages: messages.map(cloneContextMessage),
+ type: 'context.append',
+ args: [cloneContextMessage(message)],
});
}
@@ -399,14 +423,14 @@ class V1_5MigrationState {
private resetLoopState(): void {
this.openSteps.clear();
- this.pendingToolResultIds.clear();
+ this.pendingToolResultSteps.clear();
this.deferredMessages = [];
}
}
interface OpenStep {
readonly message: ContextMessage;
- readonly inserted: boolean;
+ readonly source: WireMigrationRecord;
}
interface V1_4TurnPromptRecord extends WireMigrationRecord {
@@ -489,13 +513,6 @@ interface V1_4UndoRecord extends WireMigrationRecord {
readonly count: number;
}
-interface V1_5ContextSpliceRecord extends WireMigrationRecord {
- readonly type: 'context.splice';
- readonly start: number;
- readonly deleteCount: number;
- readonly messages: readonly ContextMessage[];
-}
-
function createAssistantMessage(): ContextMessage {
return {
role: 'assistant',
@@ -504,6 +521,14 @@ function createAssistantMessage(): ContextMessage {
};
}
+function isEmptyAssistantMessage(message: ContextMessage): boolean {
+ return (
+ message.role === 'assistant' &&
+ message.content.length === 0 &&
+ message.toolCalls.length === 0
+ );
+}
+
function createCompactionSummaryMessage(summary: string): ContextMessage {
return {
role: 'assistant',
@@ -528,12 +553,6 @@ function turnIdOf(event: V1_4LoopRecordedEvent): number | undefined {
return Number.isInteger(turnId) && turnId >= 0 ? turnId : undefined;
}
-function normalizedSpliceStart(start: number, length: number): number {
- if (!Number.isFinite(start)) return length;
- if (start < 0) return Math.max(0, length + start);
- return Math.min(start, length);
-}
-
function clampDeleteCount(deleteCount: number, max: number): number {
if (!Number.isFinite(deleteCount) || deleteCount <= 0) return 0;
return Math.min(deleteCount, Math.max(0, max));
diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
index ed2467cfa..15c794569 100644
--- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
+++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
@@ -23,10 +23,14 @@ import {
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
-import { IAgentLifecycleService, ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle';
+import {
+ IAgentLifecycleService,
+ ensureMainAgent,
+ resolveContextOperationOwners,
+ MAIN_AGENT_ID,
+} from '#/session/agentLifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IEventService } from '#/app/event';
-import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { ErrorCodes, KimiError } from '#/errors';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { createExecContext, execContextSeed } from '#/session/execContext';
@@ -155,10 +159,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const agents = handle.accessor.get(IAgentLifecycleService);
if (agents.getHandle(MAIN_AGENT_ID) === undefined) {
const main = await ensureMainAgent(handle);
- // Resolve context memory BEFORE restoring so its `context.splice` resumer
- // is registered; otherwise the wire replay applies splices into a void and
- // the restored transcript never lands in context memory.
- main.accessor.get(IAgentContextMemoryService);
+ // Resolve every context-operation owner BEFORE restoring so all
+ // `context.` resumers are registered; an unclaimed record fails
+ // the restore loudly.
+ resolveContextOperationOwners(main);
await main.accessor.get(IAgentWireRecordService).restore();
}
return handle;
@@ -283,6 +287,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
sourceAgent.labels ??
(sourceAgent.swarmItem !== undefined ? { swarmItem: sourceAgent.swarmItem } : undefined),
});
+ resolveContextOperationOwners(agentHandle);
await agentHandle.accessor.get(IAgentWireRecordService).restore();
}
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index a53c5e344..64fed6a93 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -80,7 +80,7 @@ export * from '#/app/web';
// Ported agent services. These keep the current service boundaries during the migration.
export * from '#/agent/blob';
export * from '#/agent/contextMemory';
-export * from '#/agent/systemReminder';
+export * from '#/agent/contextOps';
export * from '#/agent/contextProjector';
export * from '#/agent/contextSize';
export * from '#/agent/contextInjector';
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
index 5e37dd13d..f73c9b6b8 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
@@ -38,6 +38,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry';
import { IAgentWireRecordService, AgentWireRecordService } from '#/agent/wireRecord';
import { IAgentBlobService, AgentBlobServiceImpl } from '#/agent/blob';
@@ -195,7 +196,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get();
if (sourceMessages !== undefined && sourceMessages.length > 0) {
- child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, sourceMessages);
+ child.accessor.get(IAgentContextOpsService)?.append(...sourceMessages);
}
return child;
}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/contextOperationOwners.ts b/packages/agent-core-v2/src/session/agentLifecycle/contextOperationOwners.ts
new file mode 100644
index 000000000..6e9b04ea7
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/contextOperationOwners.ts
@@ -0,0 +1,31 @@
+/**
+ * `agentLifecycle` domain (L6) — restore preamble for context operations.
+ *
+ * Wire-log restore replays `context.` records through the operations
+ * registered on `contextMemory`, and most owners are Delayed services whose
+ * registration only happens on first resolution. Every restore path must
+ * resolve the owning services first, otherwise the restore fails loudly on
+ * the first unclaimed `context.*` record. This helper concentrates the owner
+ * list so restore call sites don't each maintain their own.
+ *
+ * Not a Service: a pure composition helper over the agent handle.
+ */
+
+import type { IAgentScopeHandle } from '#/_base/di/scope';
+import { IAgentContextOpsService } from '#/agent/contextOps';
+import { IAgentFullCompactionService } from '#/agent/fullCompaction';
+import { IAgentPromptService } from '#/agent/prompt';
+
+/**
+ * Resolve every service that defines a context operation, so all
+ * `context.` resumers are registered before `restore()` replays the
+ * wire log.
+ */
+export function resolveContextOperationOwners(agent: IAgentScopeHandle): void {
+ // `context.append` / `context.replace` / `context.remove` / `context.clear`.
+ agent.accessor.get(IAgentContextOpsService);
+ // `context.undo`.
+ agent.accessor.get(IAgentPromptService);
+ // `context.compact` (Eager — resolved here for completeness).
+ agent.accessor.get(IAgentFullCompactionService);
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/index.ts b/packages/agent-core-v2/src/session/agentLifecycle/index.ts
index 747d6c7f1..a937da7f1 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/index.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/index.ts
@@ -15,6 +15,7 @@ export * from './agentLifecycle';
export * from './agentLifecycleService';
export * from './tools/subagent-task';
export { AGENT_RUN_PROMPT_ORIGIN } from './runAgentTurn';
+export * from './contextOperationOwners';
export * from './mainAgent';
export * from './mirrorAgentRun';
// Deliberately last: `tools/agent` reaches `sessionSwarmService` through
diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts
index df991d473..7ce5b0486 100644
--- a/packages/agent-core-v2/src/session/btw/btwService.ts
+++ b/packages/agent-core-v2/src/session/btw/btwService.ts
@@ -16,7 +16,7 @@ import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER, TOOL_CALL_DISABLED_MESSAGE } from './btw';
@@ -31,7 +31,7 @@ export class SessionBtwService implements ISessionBtwService {
async start(): Promise {
const child = await this.lifecycle.fork('main');
child.accessor
- .get(IAgentSystemReminderService)
+ .get(IAgentContextOpsService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, {
kind: 'system_trigger',
name: 'btw',
diff --git a/packages/agent-core-v2/test/btw/btw.test.ts b/packages/agent-core-v2/test/btw/btw.test.ts
index d92456065..e5665ceff 100644
--- a/packages/agent-core-v2/test/btw/btw.test.ts
+++ b/packages/agent-core-v2/test/btw/btw.test.ts
@@ -7,7 +7,7 @@ import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
+import { IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER } from '#/session/btw/btw';
import { SessionBtwService } from '#/session/btw/btwService';
@@ -29,7 +29,7 @@ describe('SessionBtwService', () => {
id: 'agent-btw-1',
accessor: {
get: (id: unknown) => {
- if (id === IAgentSystemReminderService) return { appendSystemReminder };
+ if (id === IAgentContextOpsService) return { appendSystemReminder };
if (id === IAgentPermissionPolicyService) return { registerPolicy };
return undefined;
},
diff --git a/packages/agent-core-v2/test/contextInjector/manager.test.ts b/packages/agent-core-v2/test/contextInjector/manager.test.ts
index a6f9cd535..3815068ee 100644
--- a/packages/agent-core-v2/test/contextInjector/manager.test.ts
+++ b/packages/agent-core-v2/test/contextInjector/manager.test.ts
@@ -8,12 +8,10 @@ import {
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { IAgentContextOpsService, AgentContextOpsService } from '#/agent/contextOps';
import { IAgentLoopService } from '#/agent/loop';
-import { IAgentProfileService } from '#/agent/profile';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
-import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import { IAgentTurnService } from '#/agent/turn';
-import { registerContextMemoryServices } from '../contextMemory/stubs';
+import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubTurnWithHooks } from '../turn/stubs';
type InjectableContextInjector = IAgentContextInjectorService & {
@@ -51,7 +49,7 @@ function lastText(context: IAgentContextMemoryService): string | undefined {
describe('AgentContextInjectorService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
- let context: IAgentContextMemoryService;
+ let context: StubContextMemory;
beforeEach(() => {
disposables = new DisposableStore();
@@ -61,11 +59,11 @@ describe('AgentContextInjectorService', () => {
additionalServices: (reg) => {
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
- reg.define(IAgentSystemReminderService, AgentSystemReminderService);
+ reg.define(IAgentContextOpsService, AgentContextOpsService);
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
},
});
- context = ix.get(IAgentContextMemoryService);
+ context = ix.get(IAgentContextMemoryService) as StubContextMemory;
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
index 65ef60d84..97f1af856 100644
--- a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
+++ b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
@@ -1,8 +1,9 @@
/**
- * `AgentContextMemoryService.applySplice` replay contract, exercised without
- * the full agent harness: a boundary splice (`start === 0 && deleteCount > 0`,
- * i.e. compaction/clear) never touches the replay; every other splice mirrors
- * itself (removes deleted messages, pushes inserted ones).
+ * `AgentContextMemoryService` operation replay contract, exercised without
+ * the full agent harness. Each operation's `apply` mutates the in-memory
+ * history and its `replay` projection feeds the replay read model. Boundary
+ * resets (`start === 0 && deleteCount > 0`, e.g. compaction/clear) cut the
+ * replay segment instead of pushing/removing individual messages.
*/
import { describe, expect, it } from 'vitest';
@@ -10,35 +11,29 @@ import { describe, expect, it } from 'vitest';
import {
AgentContextMemoryService,
type ContextMessage,
+ type ContextReplayWriter,
+ type ContextSplice,
} from '#/agent/contextMemory';
-import type { AgentRecord, IAgentRecordService } from '#/agent/record';
+import type { IAgentRecordService } from '#/agent/record';
import type { AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
import { stubRecord } from './stubs';
interface RecordingRecordStub {
readonly record: IAgentRecordService;
readonly pushed: AgentReplayRecordPayload[];
- readonly removed: ContextMessage[][];
- readonly resume: (record: AgentRecord<'context.splice'>) => void;
+ readonly removed: string[][];
}
function recordingRecord(): RecordingRecordStub {
const pushed: AgentReplayRecordPayload[] = [];
- const removed: ContextMessage[][] = [];
- let resume: ((record: AgentRecord<'context.splice'>) => void) | undefined;
+ const removed: string[][] = [];
const base = stubRecord();
const record: IAgentRecordService = {
...base,
- define: (type, facets) => {
- if (type === 'context.splice' && facets.resume !== undefined) {
- resume = facets.resume as unknown as (record: AgentRecord<'context.splice'>) => void;
- }
- return base.define(type, facets);
- },
push: (payload) => {
pushed.push(payload);
},
- removeLastMessages: (messages) => {
+ removeMessages: (messages) => {
if (messages.size > 0) removed.push([...messages]);
},
};
@@ -46,10 +41,6 @@ function recordingRecord(): RecordingRecordStub {
record,
pushed,
removed,
- resume: (spliceRecord) => {
- if (resume === undefined) throw new Error('context.splice resumer not registered');
- resume(spliceRecord);
- },
};
}
@@ -66,13 +57,85 @@ function summaryMessage(text: string): ContextMessage {
};
}
-describe('AgentContextMemoryService splice replay contract', () => {
+function defineAppend(
+ context: AgentContextMemoryService,
+): (messages: readonly ContextMessage[]) => void {
+ return context.defineOperation({
+ type: 'append',
+ apply: (splice: ContextSplice, messages) => {
+ splice(Number.POSITIVE_INFINITY, 0, messages);
+ },
+ replay: (replay: ContextReplayWriter, messages) => {
+ for (const message of messages) {
+ replay.push({ type: 'message', message });
+ }
+ },
+ });
+}
+
+function defineRemove(
+ context: AgentContextMemoryService,
+): (index: number, messageId: string) => void {
+ return context.defineOperation({
+ type: 'remove',
+ apply: (splice: ContextSplice, index) => {
+ splice(index, 1, []);
+ },
+ replay: (replay: ContextReplayWriter, _index, messageId) => {
+ replay.removeMessages(new Set([messageId]));
+ },
+ });
+}
+
+function defineReplace(
+ context: AgentContextMemoryService,
+): (index: number, message: ContextMessage, oldMessageId: string) => void {
+ return context.defineOperation({
+ type: 'replace',
+ apply: (splice: ContextSplice, index, message) => {
+ splice(index, 1, [message]);
+ },
+ replay: (replay: ContextReplayWriter, _index, message, oldMessageId) => {
+ replay.removeMessages(new Set([oldMessageId]));
+ replay.push({ type: 'message', message });
+ },
+ });
+}
+
+function defineCompact(
+ context: AgentContextMemoryService,
+): (deleteCount: number, summary: ContextMessage) => void {
+ return context.defineOperation({
+ type: 'compact',
+ apply: (splice: ContextSplice, deleteCount, summary) => {
+ splice(0, deleteCount, [summary]);
+ },
+ replay: (replay: ContextReplayWriter) => {
+ replay.cut();
+ },
+ });
+}
+
+function defineClear(context: AgentContextMemoryService): () => void {
+ return context.defineOperation({
+ type: 'clear',
+ apply: (splice: ContextSplice) => {
+ splice(0, Number.POSITIVE_INFINITY, []);
+ },
+ replay: (replay: ContextReplayWriter) => {
+ replay.cut();
+ },
+ });
+}
+
+describe('AgentContextMemoryService operation replay contract', () => {
it('pushes appended messages into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
+ const append = defineAppend(context);
- context.splice(0, 0, [userMessage('hello')]);
- context.splice(context.get().length, 0, [userMessage('world')]);
+ append([userMessage('hello')]);
+ append([userMessage('world')]);
expect(stub.pushed).toHaveLength(2);
expect(stub.pushed[1]).toMatchObject({
@@ -85,25 +148,32 @@ describe('AgentContextMemoryService splice replay contract', () => {
it('mirrors mid-history removals (undo-shaped splices) into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
- context.splice(0, 0, [userMessage('keep'), userMessage('drop')]);
+ const append = defineAppend(context);
+ const remove = defineRemove(context);
- context.splice(1, 1, []);
+ append([userMessage('keep'), userMessage('drop')]);
+ const droppedId = context.get()[1]!.id!;
+ remove(1, droppedId);
expect(context.get().map((m) => m.content)).toEqual([[{ type: 'text', text: 'keep' }]]);
expect(stub.removed).toHaveLength(1);
- expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'drop' }] });
+ expect(stub.removed[0]).toEqual([droppedId]);
});
it('mirrors in-place replacements (migrated step updates) into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
- context.splice(0, 0, [userMessage('prompt'), userMessage('partial')]);
+ const append = defineAppend(context);
+ const replace = defineReplace(context);
+
+ append([userMessage('prompt'), userMessage('partial')]);
stub.pushed.length = 0;
+ const partialId = context.get()[1]!.id!;
- context.splice(1, 1, [userMessage('final')]);
+ replace(1, userMessage('final'), partialId);
expect(stub.removed).toHaveLength(1);
- expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'partial' }] });
+ expect(stub.removed[0]).toEqual([partialId]);
expect(stub.pushed).toHaveLength(1);
expect(stub.pushed[0]).toMatchObject({
type: 'message',
@@ -114,10 +184,13 @@ describe('AgentContextMemoryService splice replay contract', () => {
it('leaves the replay untouched for boundary splices (compaction)', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
- context.splice(0, 0, [userMessage('old 1'), userMessage('old 2')]);
+ const append = defineAppend(context);
+ const compact = defineCompact(context);
+
+ append([userMessage('old 1'), userMessage('old 2')]);
stub.pushed.length = 0;
- context.splice(0, 2, [summaryMessage('summary')]);
+ compact(2, summaryMessage('summary'));
expect(context.get()).toHaveLength(1);
expect(stub.removed).toHaveLength(0);
@@ -127,10 +200,13 @@ describe('AgentContextMemoryService splice replay contract', () => {
it('leaves the replay untouched for boundary splices (clear)', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
- context.splice(0, 0, [userMessage('one'), userMessage('two')]);
+ const append = defineAppend(context);
+ const clear = defineClear(context);
+
+ append([userMessage('one'), userMessage('two')]);
stub.pushed.length = 0;
- context.splice(0, context.get().length, []);
+ clear();
expect(context.get()).toHaveLength(0);
expect(stub.removed).toHaveLength(0);
@@ -140,24 +216,13 @@ describe('AgentContextMemoryService splice replay contract', () => {
it('applies the same contract on the resume path', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
+ const append = defineAppend(context);
+ const compact = defineCompact(context);
- stub.resume({
- type: 'context.splice',
- start: 0,
- deleteCount: 0,
- messages: [userMessage('restored 1'), userMessage('restored 2')],
- });
- stub.resume({
- type: 'context.splice',
- start: 0,
- deleteCount: 2,
- messages: [summaryMessage('restored summary')],
- });
+ append([userMessage('restored 1'), userMessage('restored 2')]);
+ compact(2, summaryMessage('restored summary'));
expect(context.get()).toHaveLength(1);
- // Appends were pushed; the boundary splice neither removed nor pushed, so
- // the restored transcript keeps the pre-compaction messages and the
- // summary never appears as a plain message.
expect(stub.pushed).toHaveLength(2);
expect(stub.removed).toHaveLength(0);
});
diff --git a/packages/agent-core-v2/test/contextMemory/stubs.ts b/packages/agent-core-v2/test/contextMemory/stubs.ts
index d85ae0554..c5c4ec0ec 100644
--- a/packages/agent-core-v2/test/contextMemory/stubs.ts
+++ b/packages/agent-core-v2/test/contextMemory/stubs.ts
@@ -11,7 +11,7 @@ import { toDisposable } from '#/_base/di';
import type { ServiceRegistration } from '#/_base/di/test';
import { createHooks } from '#/hooks';
import type { Hooks } from '#/hooks';
-import { ensureMessageId, IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { ensureMessageId, IAgentContextMemoryService, type ContextMessage, type ContextOperation, type ContextOperationDefinition } from '#/agent/contextMemory';
import { IAgentRecordService } from '#/agent/record';
import { IAgentWireRecordService } from '#/agent/wireRecord';
@@ -51,7 +51,8 @@ export function stubRecord(): IAgentRecordService {
define: () => toDisposable(() => {}),
push: () => {},
patchLast: () => {},
- removeLastMessages: () => {},
+ removeMessages: () => {},
+ cut: () => {},
buildReplay: () => [],
};
}
@@ -59,6 +60,13 @@ export function stubRecord(): IAgentRecordService {
export interface StubContextMemory extends IAgentContextMemoryService {
/** The live backing history, exposed so tests can inspect splices. */
readonly messages: readonly ContextMessage[];
+ /** Direct splice helper for tests that pre-date the operation registry. */
+ splice(
+ start: number,
+ deleteCount: number,
+ inserted: readonly ContextMessage[],
+ tokens?: number,
+ ): void;
}
/**
@@ -68,6 +76,7 @@ export interface StubContextMemory extends IAgentContextMemoryService {
*/
export function stubContextMemory(): StubContextMemory {
const messages: ContextMessage[] = [];
+ const operationTypes = new Set();
const hooks = {
onSpliced: createHooks(['onSpliced'])['onSpliced'],
} as unknown as Hooks<{
@@ -78,6 +87,20 @@ export function stubContextMemory(): StubContextMemory {
tokens?: number;
};
}>;
+
+ const splice: (start: number, deleteCount: number, inserted: readonly ContextMessage[], tokens?: number) => void = (start, deleteCount, inserted, tokens) => {
+ const boundedStart = normalizeSpliceStart(start, messages.length);
+ const boundedDeleteCount = clampDeleteCount(deleteCount, messages.length - boundedStart);
+ const stamped = inserted.map(ensureMessageId);
+ messages.splice(boundedStart, boundedDeleteCount, ...stamped);
+ void hooks.onSpliced.run({
+ start: boundedStart,
+ deleteCount: boundedDeleteCount,
+ messages: [...stamped],
+ tokens,
+ });
+ };
+
return {
_serviceBrand: undefined,
hooks,
@@ -85,16 +108,14 @@ export function stubContextMemory(): StubContextMemory {
return messages;
},
get: () => [...messages],
- splice: (start, deleteCount, inserted, tokens) => {
- const stamped = inserted.map(ensureMessageId);
- messages.splice(start, deleteCount, ...stamped);
- void hooks.onSpliced.run({
- start,
- deleteCount,
- messages: [...stamped],
- tokens,
- });
+ defineOperation: (definition: ContextOperationDefinition): ContextOperation => {
+ const recordType = `context.${definition.type}`;
+ operationTypes.add(recordType);
+ return (...args: T) => {
+ definition.apply(splice, ...args);
+ };
},
+ splice,
};
}
@@ -109,3 +130,14 @@ export function registerContextMemoryServices(reg: ServiceRegistration): void {
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
}
+
+function normalizeSpliceStart(start: number, length: number): number {
+ if (Number.isNaN(start)) return length;
+ if (start < 0) return Math.max(0, length + Math.floor(start));
+ return Math.min(Math.floor(start), length);
+}
+
+function clampDeleteCount(deleteCount: number, max: number): number {
+ if (Number.isNaN(deleteCount) || deleteCount <= 0) return 0;
+ return Math.min(Math.floor(deleteCount), Math.max(0, max));
+}
diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts
index 044d657f1..9eb8d6a44 100644
--- a/packages/agent-core-v2/test/externalHooks/integration.test.ts
+++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts
@@ -8,7 +8,8 @@ import {
} from '#/_base/di/test';
import { Event } from '#/_base/event';
import { emptyUsage } from '#/app/llmProtocol/kosong';
-import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { AgentContextOpsService, IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentTaskService } from '#/agent/task';
import {
AgentExternalHooksService,
@@ -33,7 +34,7 @@ import { IPluginService } from '#/app/plugin';
import { createHooks } from '#/hooks';
import { stubBootstrap } from '../bootstrap/stubs';
-import { stubRecord } from '../contextMemory/stubs';
+import { stubContextMemory, stubRecord } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubToolExecutor, stubTurnWithHooks } from '../turn/stubs';
function nodeCommand(source: string): string {
@@ -71,21 +72,6 @@ function makeAfterStep(signal: AbortSignal): TurnAfterStepContext {
};
}
-function stubContextMemory(): IAgentContextMemoryService & {
- readonly messages: readonly ContextMessage[];
-} {
- const messages: ContextMessage[] = [];
- return {
- _serviceBrand: undefined,
- get: () => [...messages],
- splice: (start, deleteCount, inserted) => {
- messages.splice(start, deleteCount, ...inserted);
- },
- hooks: createHooks(['onSpliced']) as IAgentContextMemoryService['hooks'],
- messages,
- };
-}
-
describe('HookEngine integration', () => {
it('blocks a dangerous Bash command and allows a safe one via a PreToolUse script hook', async () => {
const engine = new HookEngine([
@@ -159,6 +145,7 @@ describe('HookEngine integration', () => {
reg.definePartialInstance(IConfigService, {});
reg.definePartialInstance(IPluginService, {});
reg.defineInstance(IAgentContextMemoryService, context);
+ reg.define(IAgentContextOpsService, AgentContextOpsService);
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentLoopService, loop);
reg.definePartialInstance(IAgentPromptService, {
diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts
index dfb140d66..ecbdc802f 100644
--- a/packages/agent-core-v2/test/harness/agent.ts
+++ b/packages/agent-core-v2/test/harness/agent.ts
@@ -5,7 +5,7 @@ import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import { expect, vi } from 'vitest';
-import { toDisposable } from '#/_base/di';
+import { createDecorator, toDisposable } from '#/_base/di';
import { Event } from '#/_base/event';
import type { PromisifyMethods } from '#/_base/utils/types';
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
@@ -46,7 +46,6 @@ import type {
WireRecordRestoreResult,
} from '#/agent/wireRecord';
import { IOAuthService } from '#/app/auth/auth';
-import { IChatProviderFactory } from '#/app/chatProvider';
import type { SkillCatalog } from '#/app/globalSkillCatalog/types';
import {
isToolCall,
@@ -65,6 +64,14 @@ import {
type generate as kosongGenerate,
} from '#/app/llmProtocol/kosong';
import type { ILogger, LogContext, LogLevel } from '#/app/log';
+
+interface IChatProviderFactory {
+ readonly _serviceBrand: undefined;
+ create(config: ProviderConfig): ChatProvider;
+ register(provider: ChatProvider): void;
+}
+
+const IChatProviderFactory = createDecorator('chatProviderFactory');
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
import {
AGENT_WIRE_PROTOCOL_VERSION,
@@ -84,6 +91,7 @@ import {
IBootstrapService,
IConfigService,
IAgentContextMemoryService,
+ IAgentContextOpsService,
IAgentContextProjectorService,
IAgentContextSizeService,
IAgentEventSinkService,
@@ -1269,12 +1277,10 @@ export class AgentTestContext {
content: string,
origin: ContextMessage['origin'] = { kind: 'injection', variant: 'system-reminder' },
): void {
- this.appendMessage({
- role: 'user',
- content: [{ type: 'text', text: `\n${content.trim()}\n` }],
- toolCalls: [],
- origin,
- });
+ this.get(IAgentContextOpsService).appendSystemReminder(
+ content,
+ origin ?? { kind: 'injection', variant: 'system-reminder' },
+ );
}
appendLocalCommandStdout(content: string): void {
@@ -1759,8 +1765,7 @@ export class AgentTestContext {
private appendMessage(...messages: ContextMessage[]): void {
if (messages.length === 0) return;
- const context = this.get(IAgentContextMemoryService);
- context.splice(context.get().length, 0, messages);
+ this.get(IAgentContextOpsService).append(...messages);
}
private coverUsage(tokenTotal: number | undefined): void {
diff --git a/packages/agent-core-v2/test/message/message.test.ts b/packages/agent-core-v2/test/message/message.test.ts
index 0a139d385..bd8f46a53 100644
--- a/packages/agent-core-v2/test/message/message.test.ts
+++ b/packages/agent-core-v2/test/message/message.test.ts
@@ -1,13 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
-import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
import { IAgentRecordService } from '#/agent/record';
import { IAgentWireRecordService } from '#/agent/wireRecord';
-import { stubRecord, stubWireRecord } from '../contextMemory/stubs';
+import { stubContextMemory, stubRecord, stubWireRecord } from '../contextMemory/stubs';
function textMessage(role: ContextMessage['role'], text: string): ContextMessage {
return {
@@ -38,7 +36,7 @@ describe('message history (IAgentContextMemoryService)', () => {
ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentRecordService, stubRecord());
- ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
+ ix.stub(IAgentContextMemoryService, stubContextMemory());
});
afterEach(() => disposables.dispose());
diff --git a/packages/agent-core-v2/test/prompt/promptService.test.ts b/packages/agent-core-v2/test/prompt/promptService.test.ts
index cc7831eab..bcde4c18a 100644
--- a/packages/agent-core-v2/test/prompt/promptService.test.ts
+++ b/packages/agent-core-v2/test/prompt/promptService.test.ts
@@ -6,6 +6,7 @@ import { IAgentLoopService } from '#/agent/loop';
import { AgentPromptService, IAgentPromptService } from '#/agent/prompt';
import type { PromptSubmitContext } from '#/agent/prompt';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { AgentContextOpsService, IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService, type Turn } from '#/agent/turn';
@@ -32,6 +33,7 @@ function createHarness(options: { readonly hasActiveTurn?: boolean } = {}) {
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentContextMemoryService, context);
+ reg.define(IAgentContextOpsService, AgentContextOpsService);
reg.defineInstance(IAgentTurnService, turn);
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentLoopService, loop);
diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts
index 6c6277993..ff2ef57e1 100644
--- a/packages/agent-core-v2/test/swarm/swarm.test.ts
+++ b/packages/agent-core-v2/test/swarm/swarm.test.ts
@@ -1,33 +1,30 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { makeAgentScopeContext } from '#/agent/scopeContext';
-
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { AgentContextOpsService, IAgentContextOpsService } from '#/agent/contextOps';
import { IAgentRecordService } from '#/agent/record';
-const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
-import { IAgentLifecycleService } from '#/session/agentLifecycle';
-import { ISessionSwarmService } from '#/session/swarm';
-import type {
- SessionSwarmRunResult,
- SessionSwarmTask,
-} from '#/session/swarm';
-import { IAgentScopeContext } from '#/agent/scopeContext';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
-import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
+import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext';
import { IAgentSwarmService } from '#/agent/swarm';
import { AgentSwarmService } from '#/agent/swarm/swarmService';
import { AgentSwarmTool, AgentSwarmToolInputSchema } from '#/agent/swarm/tools/agent-swarm';
import type { ExecutableToolContext } from '#/agent/tool';
-import { IAgentToolRegistryService, AgentToolRegistryService } from '#/agent/toolRegistry';
+import { AgentToolRegistryService, IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentTurnService } from '#/agent/turn';
import { IAgentWireRecordService } from '#/agent/wireRecord';
-
-import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
+import { IAgentLifecycleService } from '#/session/agentLifecycle';
+import type {
+ SessionSwarmRunResult,
+ SessionSwarmTask,
+} from '#/session/swarm';
+import { ISessionSwarmService } from '#/session/swarm';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { stubContextMemory, stubWireRecord, type StubContextMemory } from '../contextMemory/stubs';
import { executeTool } from '../tools/fixtures/execute-tool';
import { stubTurnWithHooks } from '../turn/stubs';
+const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
+
const signal = new AbortController().signal;
function context(
@@ -73,7 +70,7 @@ describe('AgentSwarmService', () => {
ix.stub(IAgentLifecycleService, {});
ix.stub(ISessionSwarmService, { run: async () => [], cancel: () => {} });
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
- ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
+ ix.set(IAgentContextOpsService, new SyncDescriptor(AgentContextOpsService));
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
});
afterEach(() => disposables.dispose());
@@ -86,6 +83,43 @@ describe('AgentSwarmService', () => {
swarm.exit();
expect(swarm.isActive).toBe(false);
});
+
+ it('removes the swarm enter reminder on exit when it is the last message', () => {
+ const context = ix.get(IAgentContextMemoryService) as StubContextMemory;
+ const swarm = ix.get(IAgentSwarmService);
+
+ swarm.enter('manual');
+ expect(context.messages).toHaveLength(1);
+ expect(context.messages[0]?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' });
+
+ swarm.exit();
+
+ expect(context.messages).toHaveLength(0);
+ });
+
+ it('keeps the swarm enter reminder on exit when it is not the last message', () => {
+ const context = ix.get(IAgentContextMemoryService) as StubContextMemory;
+ const swarm = ix.get(IAgentSwarmService);
+
+ swarm.enter('manual');
+ context.splice(Number.POSITIVE_INFINITY, 0, [
+ {
+ role: 'user',
+ content: [{ type: 'text', text: 'later message' }],
+ toolCalls: [],
+ origin: { kind: 'user' },
+ },
+ ]);
+ swarm.exit();
+
+ expect(context.messages).toHaveLength(3);
+ expect(context.messages[0]?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' });
+ expect(context.messages[1]?.origin).toEqual({ kind: 'user' });
+ expect(context.messages[2]?.origin).toEqual({
+ kind: 'injection',
+ variant: 'swarm_mode_exit',
+ });
+ });
});
describe('AgentSwarmTool', () => {
diff --git a/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts b/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts
index e9184c81a..3376e2c61 100644
--- a/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts
+++ b/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts
@@ -44,7 +44,8 @@ function createDeduper(telemetry = recordingTelemetry(telemetryEvents)): ToolDed
},
strict: true,
});
- return ix.get(IAgentToolDedupeService) as unknown as ToolDedupeInternals;
+ const service = ix.get(IAgentToolDedupeService);
+ return toolDedupTesting.createDriver(service as AgentToolDedupeService) as unknown as ToolDedupeInternals;
}
function okResult(text: string): ToolDedupResult {
diff --git a/packages/agent-core-v2/test/turn/turn-ready.test.ts b/packages/agent-core-v2/test/turn/turn-ready.test.ts
index 91801abe7..548c01d1d 100644
--- a/packages/agent-core-v2/test/turn/turn-ready.test.ts
+++ b/packages/agent-core-v2/test/turn/turn-ready.test.ts
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentContextOpsService, AgentContextOpsService } from '#/agent/contextOps';
import { AgentLoopService, IAgentLoopService } from '#/agent/loop';
import { IAgentLLMRequesterService } from '#/agent/llmRequester';
import { IAgentRecordService } from '#/agent/record';
@@ -33,6 +34,7 @@ describe('AgentTurnService ready', () => {
reg.defineInstance(IAgentLoopService, loop);
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
+ reg.define(IAgentContextOpsService, AgentContextOpsService);
reg.defineInstance(ITelemetryService, recordingTelemetry([]));
reg.defineInstance(IAgentTelemetryContextService, {
_serviceBrand: undefined,
@@ -149,6 +151,7 @@ describe('AgentLoopService onStarted', () => {
additionalServices: (reg) => {
reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
reg.defineInstance(IAgentRecordService, stubRecord());
+ reg.define(IAgentContextOpsService, AgentContextOpsService);
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
reg.definePartialInstance(IConfigService, {
get: () => undefined as T,
diff --git a/packages/agent-core-v2/test/wireRecord/migration/v1.5.test.ts b/packages/agent-core-v2/test/wireRecord/migration/v1.5.test.ts
index 5290daa3a..3601b09c4 100644
--- a/packages/agent-core-v2/test/wireRecord/migration/v1.5.test.ts
+++ b/packages/agent-core-v2/test/wireRecord/migration/v1.5.test.ts
@@ -4,7 +4,7 @@ import { migrateV1_4ToV1_5 } from '#/agent/wireRecord/migration';
import { runMigrationRecords } from './utils';
describe('1.4 to 1.5', () => {
- it('rewrites prompt and loop transcript records to launch and splice records', () => {
+ it('rewrites prompt and loop transcript records to launch and context records', () => {
expect(
runMigrationRecords(migrateV1_4ToV1_5, [
{
@@ -99,12 +99,11 @@ describe('1.4 to 1.5', () => {
]),
).toMatchInlineSnapshot(`
[wire] metadata { "protocol_version": "", "created_at": "