refactor(agent-core-v2): make todo session-shared and wire-only

- move todo from agent scope (`IAgentTodoListService`) to session scope
  (`ISessionTodoService`); the list is now shared across every agent in a
  session instead of per-agent.
- persist every change as a `todo.set` record on the main agent's wire, which
  is the single source of truth and a replayable timeline; drop the agent
  tool-store `'todo'` key and the atomic-doc persistence store.
- bind `TodoListTool` and the stale reminder into every agent via
  `IAgentLifecycleService`, and register the `todo.set` resume resumer on the
  main agent.
- migrate `fullCompaction` to read `ISessionTodoService`; register the `todo`
  domain at L4 with a `todo>agentLifecycle` layer exception; refresh the DI
  scope/domain diagram.
This commit is contained in:
haozhe.yang 2026-07-03 23:30:55 +08:00
parent bcc9bc095f
commit b3ac27e79a
21 changed files with 665 additions and 331 deletions

View file

@ -62,6 +62,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>terminal</b>\n<size:9><i>App</i></size>\n IHostTerminalService" as terminal_app #D6EAF8
rectangle "<b>sessionTerminal</b>\n<size:9><i>Session</i></size>\n ISessionTerminalService" as terminal_session #D5F5E3
rectangle "<b>modelProvider</b>\n<size:9><i>Session</i></size>\n IModelProvider (seed)" as modelProvider #D5F5E3
rectangle "<b>todo</b>\n<size:9><i>Session</i></size>\n ISessionTodoService" as todo #D5F5E3
}
package "Agent scope (per agent)" #FDF5E6 {
@ -98,7 +99,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>fullCompaction</b>\n<size:9><i>Agent</i></size>\n IAgentFullCompactionService" as fullCompaction #FDEBD0
rectangle "<b>microCompaction</b>\n<size:9><i>Agent</i></size>\n IAgentMicroCompactionService" as microCompaction #FDEBD0
rectangle "<b>externalHooks</b>\n<size:9><i>Agent</i></size>\n IAgentExternalHooksService" as externalHooks #FDEBD0
rectangle "<b>todoList</b>\n<size:9><i>Agent</i></size>\n IAgentTodoListService" as todoList #FDEBD0
rectangle "<b>usage</b>\n<size:9><i>Agent</i></size>\n IAgentUsageService" as usage #FDEBD0
rectangle "<b>rpc</b>\n<size:9><i>Agent</i></size>\n IAgentRPCService" as rpc #FDEBD0
rectangle "<b>fileTools</b>\n<size:9><i>Agent</i></size>\n Read/Write/Grep/Glob tools" as fileTools #FDEBD0
@ -301,11 +301,7 @@ microCompaction --> loop #34495E
externalHooks --> config #34495E
externalHooks --> bootstrap #34495E
externalHooks --> plugin #34495E
todoList --> contextMemory #34495E
todoList --> profile #34495E
todoList --> toolStore #34495E
todoList --> toolRegistry #34495E
todoList --> contextInjector #34495E
todo --> agent_lifecycle #34495E
usage --> wireRecord #34495E
usage --> record #34495E
rpc --> prompt #34495E
@ -354,6 +350,7 @@ userTool ..> wireRecord #16A085 : tools.register_/unregister_user_tool
profile ..> wireRecord #16A085 : config.update / tools.set_active_tools
contextMemory ..> wireRecord #16A085 : context.splice
cron ..> wireRecord #16A085 : cron.add / delete / cursor
todo ..> wireRecord #16A085 : todo.set
fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
fullCompaction ..> loop #16A085 : hooks.onContextOverflow
permissionRules ..> wireRecord #16A085 : permission.rules.add / record_approval_result

View file

@ -156,7 +156,7 @@ const DOMAIN_LAYER = new Map([
['profile', 4],
['prompt', 4],
['replayBuilder', 4],
['todoList', 4],
['todo', 4],
['web', 4],
// L5 — agent task management
['agentTask', 5],
@ -248,6 +248,8 @@ function domainFromRel(rel, { exemptRootFile }) {
* - `swarm>agentLifecycle`: swarm spawns/manages sub-agents.
* - `cron>agentLifecycle` : cron coordinator steers the main agent.
* - `cron>sessionContext`: cron scheduler reads session identity for store filtering.
* - `todo>agentLifecycle` : todo binds its tool/reminder into agents and its
* resume resumer into the main agent via lifecycle handle.
*
* Post-rebase-v2 restructuring introduced cross-domain type sharing between
* L3 (registries/capabilities) and L4 (agent behaviour). The tool contract
@ -281,6 +283,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'swarm>agentLifecycle',
'cron>agentLifecycle',
'cron>sessionContext',
'todo>agentLifecycle',
'wireRecord>hooks',
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
// `tool`; the remaining upward import is a `loop` error/event helper.

View file

@ -19,13 +19,8 @@ import { IAgentLoopService, type TurnContextOverflowContext } from '#/agent/loop
import { isAbortError } from '#/agent/loop/errors';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
import {
TODO_STORE_KEY,
renderTodoList,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
import { IAgentToolState } from '#/agent/toolState';
import { IAgentTurnService } from '#/agent/turn';
import { ISessionTodoService, renderTodoList, type TodoItem } from '#/session/todo';
import {
APIContextOverflowError,
APIEmptyResponseError,
@ -109,7 +104,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolState private readonly toolStore: IAgentToolState,
@ISessionTodoService private readonly todo: ISessionTodoService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentTurnService turnService: IAgentTurnService,
@ -486,12 +481,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}
private currentTodos(): readonly TodoItem[] {
const raw = this.toolStore.data()[TODO_STORE_KEY];
if (!Array.isArray(raw)) return [];
return raw.filter(isTodoItem).map((todo) => ({
title: todo.title,
status: todo.status,
}));
return this.todo.getTodos();
}
private tokenCountWithPending(): number {

View file

@ -1,7 +0,0 @@
/**
* `todoList` domain barrel - re-exports the todoList service contract and implementation.
*/
export * from './todoList';
export * from './todoListReminder';
export * from './todoListService';

View file

@ -1,7 +0,0 @@
import { createDecorator } from "#/_base/di";
export interface IAgentTodoListService {
readonly _serviceBrand: undefined;
}
export const IAgentTodoListService = createDecorator<IAgentTodoListService>('agentTodoListService');

View file

@ -1,62 +0,0 @@
import {
Disposable,
} from "#/_base/di";
import { IInstantiationService } from "#/_base/di/instantiation";
import {
TODO_LIST_TOOL_NAME,
TODO_STORE_KEY,
TodoListTool,
readTodoItems,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
import {
TODO_LIST_REMINDER_VARIANT,
todoListStaleReminder,
} from './todoListReminder';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentToolState } from '#/agent/toolState';
import { IAgentTodoListService } from './todoList';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
export class AgentTodoListService extends Disposable implements IAgentTodoListService {
declare readonly _serviceBrand: undefined;
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolState private readonly toolStore: IAgentToolState,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(TodoListTool)));
this._register(
dynamicInjector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder()),
);
}
private getTodos(): readonly TodoItem[] {
return readTodoItems(this.toolStore.data()[TODO_STORE_KEY]);
}
private staleReminder(): string | undefined {
return todoListStaleReminder({
active: this.profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'),
history: this.context.get(),
todos: this.getTodos(),
});
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentTodoListService,
AgentTodoListService,
InstantiationType.Eager,
'todoList',
);

View file

@ -1,156 +0,0 @@
/**
* TodoListTool structured TODO list management tool.
*
* The LLM uses this tool to maintain a visible plan of sub-tasks during
* plan-mode workflows and multi-step operations. A single tool serves
* both reads and writes:
*
* - `resolveExecution({ todos: [...] })` replace the full list
* - `resolveExecution({ todos: [] })` clear the list
* - `resolveExecution({})` query current list (no mutation)
*
* Storage: todos live in the agent-level tool store. Writes go through
* `tools.update_store`, so the store update is visible on wire replay.
*/
import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { IAgentToolState } from '#/agent/toolState';
import DESCRIPTION from './todo-list.md?raw';
import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
// ── TODO state shape ─────────────────────────────────────────────────
export const TODO_LIST_TOOL_NAME = 'TodoList' as const;
export const TODO_STORE_KEY = 'todo';
export type TodoStatus = 'pending' | 'in_progress' | 'done';
export interface TodoItem {
readonly title: string;
readonly status: TodoStatus;
}
export function readTodoItems(raw: unknown): readonly TodoItem[] {
if (!Array.isArray(raw)) return [];
return raw.filter(isTodoItem).map((todo) => ({
title: todo.title,
status: todo.status,
}));
}
declare module '#/agent/toolState' {
interface ToolStoreData {
todo: readonly TodoItem[];
}
}
// ── Schema ───────────────────────────────────────────────────────────
const TodoItemSchema = z.object({
title: z.string().min(1).describe('Short, actionable title for the todo.'),
status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'),
});
export interface TodoListInput {
todos?: Array<{ title: string; status: TodoStatus }>;
}
export const TodoListInputSchema: z.ZodType<TodoListInput> = z.object({
todos: z
.array(TodoItemSchema)
.optional()
.describe(
'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.',
),
});
// ── Implementation ───────────────────────────────────────────────────
export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string {
if (todos.length === 0) {
return 'Todo list is empty.';
}
const lines = todos.map((t) => {
const marker = statusMarker(t.status);
return ` ${marker} ${t.title}`;
});
return [title, ...lines].join('\n');
}
function statusMarker(status: TodoStatus): string {
switch (status) {
case 'pending':
return '[pending]';
case 'in_progress':
return '[in_progress]';
case 'done':
return '[done]';
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}
function isTodoItem(value: unknown): value is TodoItem {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return typeof record['title'] === 'string' && isTodoStatus(record['status']);
}
function isTodoStatus(value: unknown): value is TodoStatus {
return value === 'pending' || value === 'in_progress' || value === 'done';
}
export class TodoListTool implements BuiltinTool<TodoListInput> {
readonly name = TODO_LIST_TOOL_NAME;
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
constructor(@IAgentToolState private readonly store: IAgentToolState) {}
resolveExecution(args: TodoListInput): ToolExecution {
const description =
args.todos === undefined
? 'Reading todo list'
: args.todos.length === 0
? 'Clearing todo list'
: 'Updating todo list';
return {
description,
approvalRule: this.name,
execute: async () => {
// Query mode — return the current list without mutation.
if (args.todos === undefined) {
const current = this.getTodos();
return { isError: false, output: renderTodoList(current) };
}
// Write mode — replace the full list and return the new state.
this.setTodos(args.todos);
const stored = this.getTodos();
const output =
stored.length === 0
? 'Todo list cleared.'
: `Todo list updated.\n${renderTodoList(stored)}\n\n${TODO_LIST_WRITE_REMINDER.trim()}`;
return { isError: false, output };
},
};
}
private getTodos(): readonly TodoItem[] {
const todos = this.store.get(TODO_STORE_KEY);
return todos ?? [];
}
private setTodos(todos: readonly TodoItem[]): void {
this.store.set(
TODO_STORE_KEY,
todos.map((todo) => ({ title: todo.title, status: todo.status })),
);
}
}

View file

@ -103,7 +103,7 @@ export * from '#/agent/rpc';
export * from '#/agent/scopeContext';
export * from '#/session/btw';
export * from '#/session/swarm';
export * from '#/agent/todoList';
export * from '#/session/todo';
export * from '#/agent/tool';
export * from '#/agent/toolExecutor';
import '#/agent/toolRegistry';

View file

@ -0,0 +1,12 @@
/**
* `todo` domain barrel re-exports the todo data shape, the session service
* contract and implementation, the stale reminder, and the `TodoListTool`.
* Importing this barrel registers the `ISessionTodoService` binding into the
* scope registry.
*/
export * from './todoItem';
export * from './todoListReminder';
export * from './sessionTodo';
export * from './sessionTodoService';
export * from './tools/todo-list';

View file

@ -0,0 +1,28 @@
/**
* `todo` domain (L4) `ISessionTodoService` contract.
*
* The session-shared todo list: an in-memory list materialized from the main
* agent's `todo.set` wire records, mutated through `setTodos` (which appends a
* fresh `todo.set` to the main agent's wire), and readable by every agent in
* the session. Bound at Session scope.
*/
import { createDecorator } from '#/_base/di';
import type { Event } from '#/_base/event';
import type { TodoItem } from './todoItem';
export interface ISessionTodoService {
readonly _serviceBrand: undefined;
/** Current in-memory todo list (the materialized main-agent wire state). */
getTodos(): readonly TodoItem[];
/** Replace the whole list: appends a `todo.set` to the main agent's wire. */
setTodos(todos: readonly TodoItem[]): void;
/** Clear the list (equivalent to `setTodos([])`). */
clear(): void;
/** Fires after every `setTodos` with the new list. */
readonly onDidChange: Event<readonly TodoItem[]>;
}
export const ISessionTodoService = createDecorator<ISessionTodoService>('sessionTodoService');

View file

@ -0,0 +1,164 @@
/**
* `todo` domain (L4) `ISessionTodoService` implementation.
*
* Holds the session's shared in-memory todo list. Every mutation appends a
* `todo.set` record to the main agent's wire (the single source of truth and
* replayable timeline); on resume the main agent's wire replay runs the
* `todo.set` resumer and rebuilds the in-memory list. Binds the `TodoListTool`
* and the stale-todo reminder into every agent (`onDidCreate`), and the resume
* resumer into the main agent (`onDidCreateMain`), borrowing each agent's
* services through its `IAgentScopeHandle.accessor`. Per-agent bindings are
* disposed when the agent is disposed. Bound at Session scope.
*/
import { Disposable, toDisposable, type IDisposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { Emitter } from '#/_base/event';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { ISessionTodoService } from './sessionTodo';
import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem';
import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListReminder';
import { TodoListTool } from './tools/todo-list';
declare module '#/agent/wireRecord' {
interface WireRecordMap {
'todo.set': {
todos: readonly TodoItem[];
};
}
}
const MAIN_AGENT_ID = 'main';
export class SessionTodoService extends Disposable implements ISessionTodoService {
declare readonly _serviceBrand: undefined;
private todos: readonly TodoItem[] = [];
private readonly onDidChangeEmitter = this._register(new Emitter<readonly TodoItem[]>());
readonly onDidChange = this.onDidChangeEmitter.event;
/** Per-agent bindings (tool + reminder, plus the resume resumer for main). */
private readonly agentBindings = new Map<string, IDisposable[]>();
constructor(
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
) {
super();
this._register(this.agentLifecycle.onDidCreate((handle) => this.bindAgent(handle)));
this._register(this.agentLifecycle.onDidCreateMain((handle) => this.bindMainWire(handle)));
this._register(
this.agentLifecycle.onDidDispose((agentId) => this.disposeAgentBindings(agentId)),
);
for (const handle of this.agentLifecycle.list()) {
this.bindAgent(handle);
}
const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID);
if (main !== undefined) {
this.bindMainWire(main);
}
this._register(
toDisposable(() => {
for (const agentId of [...this.agentBindings.keys()]) {
this.disposeAgentBindings(agentId);
}
this.todos = [];
}),
);
}
getTodos(): readonly TodoItem[] {
return this.todos;
}
setTodos(todos: readonly TodoItem[]): void {
const next: readonly TodoItem[] = todos.map((todo) => ({
title: todo.title,
status: todo.status,
}));
this.todos = next;
this.appendTodoSet(next);
this.onDidChangeEmitter.fire(next);
}
clear(): void {
this.setTodos([]);
}
private appendTodoSet(todos: readonly TodoItem[]): void {
const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID);
if (main === undefined) return;
const record = main.accessor.get(IAgentRecordService);
record.append({ type: 'todo.set', todos } as never);
}
private bindMainWire(handle: IAgentScopeHandle): void {
const record = handle.accessor.get(IAgentRecordService);
const disposable = record.define('todo.set', {
resume: (r) => {
this.todos = (r as unknown as { todos: readonly TodoItem[] }).todos;
},
});
this.trackAgentBinding(handle.id, disposable);
}
private bindAgent(handle: IAgentScopeHandle): void {
const instantiation = handle.accessor.get(IInstantiationService);
const registry = handle.accessor.get(IAgentToolRegistryService);
const tool = instantiation.createInstance(TodoListTool);
this.trackAgentBinding(handle.id, registry.register(tool, { source: 'builtin' }));
const injector = handle.accessor.get(IAgentContextInjectorService);
this.trackAgentBinding(
handle.id,
injector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder(handle)),
);
}
private staleReminder(handle: IAgentScopeHandle): string | undefined {
const memory = handle.accessor.get(IAgentContextMemoryService);
const profile = handle.accessor.get(IAgentProfileService);
return todoListStaleReminder({
active: profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'),
history: memory.get(),
todos: this.todos,
});
}
private trackAgentBinding(agentId: string, disposable: IDisposable): void {
const list = this.agentBindings.get(agentId);
if (list === undefined) {
this.agentBindings.set(agentId, [disposable]);
} else {
list.push(disposable);
}
}
private disposeAgentBindings(agentId: string): void {
const bindings = this.agentBindings.get(agentId);
if (bindings === undefined) return;
for (const disposable of bindings) {
disposable.dispose();
}
this.agentBindings.delete(agentId);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionTodoService,
SessionTodoService,
InstantiationType.Eager,
'todo',
);

View file

@ -0,0 +1,61 @@
/**
* `todo` domain (L4) todo item data shape and pure render helpers.
*
* `TodoItem` / `TodoStatus` are the persistent shape carried by the `todo.set`
* wire record and rendered by the `TodoListTool` and the stale reminder. Pure
* and scope-less no scoped state lives here. The session todo list itself is
* owned by `ISessionTodoService`.
*/
export const TODO_LIST_TOOL_NAME = 'TodoList' as const;
export type TodoStatus = 'pending' | 'in_progress' | 'done';
export interface TodoItem {
readonly title: string;
readonly status: TodoStatus;
}
export function readTodoItems(raw: unknown): readonly TodoItem[] {
if (!Array.isArray(raw)) return [];
return raw.filter(isTodoItem).map((todo) => ({
title: todo.title,
status: todo.status,
}));
}
export function isTodoItem(value: unknown): value is TodoItem {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return typeof record['title'] === 'string' && isTodoStatus(record['status']);
}
function isTodoStatus(value: unknown): value is TodoStatus {
return value === 'pending' || value === 'in_progress' || value === 'done';
}
export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string {
if (todos.length === 0) {
return 'Todo list is empty.';
}
const lines = todos.map((t) => {
const marker = statusMarker(t.status);
return ` ${marker} ${t.title}`;
});
return [title, ...lines].join('\n');
}
function statusMarker(status: TodoStatus): string {
switch (status) {
case 'pending':
return '[pending]';
case 'in_progress':
return '[in_progress]';
case 'done':
return '[done]';
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}

View file

@ -1,9 +1,16 @@
import {
TODO_LIST_TOOL_NAME,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
/**
* `todo` domain (L4) pure stale-todo reminder logic.
*
* Computes the `todo_list_reminder` context injection from the agent's context
* history (turns since the last `TodoList` write / last reminder) and the
* current session todo list. No scoped state `SessionTodoService` supplies
* the inputs and registers the provider into each agent's context injector.
*/
import type { ContextMessage } from '#/agent/contextMemory';
import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem';
export const TODO_LIST_REMINDER_VARIANT = 'todo_list_reminder';
const TODO_LIST_REMINDER_TURNS_SINCE_WRITE = 10;

View file

@ -0,0 +1,86 @@
/**
* `todo` domain (L4) `TodoListTool`, the structured TODO list tool.
*
* A single tool serves both reads and writes:
*
* - `resolveExecution({ todos: [...] })` replace the full list
* - `resolveExecution({ todos: [] })` clear the list
* - `resolveExecution({})` query the current list
*
* The list is session-shared: the tool reads/writes `ISessionTodoService`,
* which persists every change as a `todo.set` wire record on the main agent.
* Instantiated per agent by `SessionTodoService` and registered into each
* agent's tool registry.
*/
import { z } from 'zod';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { ISessionTodoService } from '#/session/todo/sessionTodo';
import {
TODO_LIST_TOOL_NAME,
renderTodoList,
type TodoItem,
type TodoStatus,
} from '#/session/todo/todoItem';
import DESCRIPTION from './todo-list.md?raw';
import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
const TodoItemSchema = z.object({
title: z.string().min(1).describe('Short, actionable title for the todo.'),
status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'),
});
export interface TodoListInput {
todos?: Array<{ title: string; status: TodoStatus }>;
}
export const TodoListInputSchema: z.ZodType<TodoListInput> = z.object({
todos: z
.array(TodoItemSchema)
.optional()
.describe(
'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.',
),
});
export class TodoListTool implements BuiltinTool<TodoListInput> {
readonly name = TODO_LIST_TOOL_NAME;
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
constructor(@ISessionTodoService private readonly todo: ISessionTodoService) {}
resolveExecution(args: TodoListInput): ToolExecution {
const description =
args.todos === undefined
? 'Reading todo list'
: args.todos.length === 0
? 'Clearing todo list'
: 'Updating todo list';
return {
description,
approvalRule: this.name,
execute: async () => {
if (args.todos === undefined) {
return { isError: false, output: renderTodoList(this.todo.getTodos()) };
}
const next: readonly TodoItem[] = args.todos.map((todo) => ({
title: todo.title,
status: todo.status,
}));
this.todo.setTodos(next);
const stored = this.todo.getTodos();
const output =
stored.length === 0
? 'Todo list cleared.'
: `Todo list updated.\n${renderTodoList(stored)}\n\n${TODO_LIST_WRITE_REMINDER.trim()}`;
return { isError: false, output };
},
};
}
}

View file

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { DisposableStore } from '#/_base/di/lifecycle';
import {
createServices,
type TestInstantiationService,
@ -12,10 +12,6 @@ import { IAgentLoopService } from '#/agent/loop';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import { IAgentTodoListService, TODO_LIST_REMINDER_VARIANT } from '#/agent/todoList';
import { AgentTodoListService } from '#/agent/todoList/todoListService';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentToolState } from '#/agent/toolState';
import { IAgentTurnService } from '#/agent/turn';
import { registerContextMemoryServices } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubTurnWithHooks } from '../turn/stubs';
@ -24,10 +20,6 @@ type InjectableContextInjector = IAgentContextInjectorService & {
inject(): Promise<void>;
};
type ContextInjectorInternals = {
entries: Set<{ variant: string }>;
};
function injector(ix: TestInstantiationService): InjectableContextInjector {
return ix.get(IAgentContextInjectorService) as InjectableContextInjector;
}
@ -249,44 +241,3 @@ describe('AgentContextInjectorService', () => {
]);
});
});
describe('AgentContextInjectorService registration', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
base: [registerContextMemoryServices],
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
reg.definePartialInstance(IAgentProfileService, {
isToolActive: () => false,
});
reg.definePartialInstance(IAgentToolState, {
data: () => ({}),
});
reg.definePartialInstance(IAgentToolRegistryService, {
register: () => toDisposable(() => {}),
});
reg.define(IAgentTodoListService, AgentTodoListService);
},
});
});
afterEach(() => disposables.dispose());
it('registers the todo-list reminder when the todo-list service is resolved', () => {
ix.get(IAgentTodoListService);
const entries = [
...(injector(ix) as unknown as ContextInjectorInternals).entries,
];
expect(entries.some((entry) => entry.variant === TODO_LIST_REMINDER_VARIANT)).toBe(true);
});
});

View file

@ -33,9 +33,8 @@ import {
IAgentMicroCompactionService,
IOAuthService,
IAgentProfileService,
IAgentToolState,
ISessionTodoService,
} from '#/index';
import { TODO_STORE_KEY } from '#/agent/todoList/tools/todo-list';
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
@ -2080,7 +2079,7 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
ctx.get(IAgentToolState).set(TODO_STORE_KEY, [
ctx.get(ISessionTodoService).setTodos([
{ title: 'Fix the auth bug', status: 'in_progress' },
{ title: 'Add tests', status: 'pending' },
]);

View file

@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest';
import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation';
import { IInstantiationService } from '#/_base/di/instantiation';
import { toDisposable, type IDisposable } from '#/_base/di/lifecycle';
import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { Emitter } from '#/_base/event';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import {
ISessionTodoService,
SessionTodoService,
TODO_LIST_REMINDER_VARIANT,
type TodoItem,
} from '#/session/todo';
interface RecordedTodoSet {
readonly todos: readonly TodoItem[];
}
interface FakeAgent {
readonly handle: IAgentScopeHandle;
readonly registeredTools: string[];
readonly registeredVariants: string[];
readonly appended: RecordedTodoSet[];
readonly resumers: Array<(record: RecordedTodoSet) => void>;
}
function makeFakeAgent(agentId: string): FakeAgent {
const registeredTools: string[] = [];
const registeredVariants: string[] = [];
const appended: RecordedTodoSet[] = [];
const resumers: Array<(record: RecordedTodoSet) => void> = [];
const recordStub = {
_serviceBrand: undefined,
append: (record: RecordedTodoSet) => {
appended.push(record);
},
define: (_type: string, facets: { resume?: (r: RecordedTodoSet) => void }) => {
if (facets.resume !== undefined) resumers.push(facets.resume);
return toDisposable(() => {});
},
signal: () => {},
on: () => toDisposable(() => {}),
hooks: {},
};
const registryStub = {
_serviceBrand: undefined,
register: (tool: { name: string }) => {
registeredTools.push(tool.name);
return toDisposable(() => {});
},
list: () => [],
resolve: () => undefined,
hooks: {},
};
const injectorStub = {
_serviceBrand: undefined,
register: (variant: string) => {
registeredVariants.push(variant);
return toDisposable(() => {});
},
};
const instantiationStub = {
createInstance: (ctor: { name: string }) => ({ name: ctor.name }),
};
const memoryStub = {
_serviceBrand: undefined,
get: () => [],
};
const profileStub = {
_serviceBrand: undefined,
isToolActive: () => false,
};
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>): T => {
if (id === IAgentRecordService) return recordStub as unknown as T;
if (id === IAgentToolRegistryService) return registryStub as unknown as T;
if (id === IAgentContextInjectorService) return injectorStub as unknown as T;
if (id === IInstantiationService) return instantiationStub as unknown as T;
if (id === IAgentContextMemoryService) return memoryStub as unknown as T;
if (id === IAgentProfileService) return profileStub as unknown as T;
throw new Error(`unexpected service request in fake agent: ${String(id)}`);
},
};
const handle: IAgentScopeHandle = {
id: agentId,
kind: LifecycleScope.Agent,
accessor,
dispose: () => {},
};
return { handle, registeredTools, registeredVariants, appended, resumers };
}
interface LifecycleStub {
readonly service: IAgentLifecycleService;
readonly fireCreate: (handle: IAgentScopeHandle) => void;
readonly fireCreateMain: (handle: IAgentScopeHandle) => void;
readonly fireDispose: (agentId: string) => void;
}
function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): LifecycleStub {
const onDidCreate = new Emitter<IAgentScopeHandle>();
const onDidCreateMain = new Emitter<IAgentScopeHandle>();
const onDidDispose = new Emitter<string>();
const byId = new Map(handles.map((h) => [h.id, h]));
const service: IAgentLifecycleService = {
_serviceBrand: undefined,
onDidCreate: onDidCreate.event,
onDidCreateMain: onDidCreateMain.event,
onDidDispose: onDidDispose.event,
getHandle: (id: string) => byId.get(id),
list: () => [...byId.values()],
create: async () => {
throw new Error('not implemented');
},
notifyMainCreated: () => {},
fork: async () => {
throw new Error('not implemented');
},
run: () => {
throw new Error('not implemented');
},
remove: async () => {},
};
return {
service,
fireCreate: (h) => {
byId.set(h.id, h);
onDidCreate.fire(h);
},
fireCreateMain: (h) => {
byId.set(h.id, h);
onDidCreateMain.fire(h);
},
fireDispose: (id) => {
byId.delete(id);
onDidDispose.fire(id);
},
};
}
describe('SessionTodoService', () => {
it('starts empty and updates in-memory list on setTodos', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
expect(service.getTodos()).toEqual([]);
const next: TodoItem[] = [
{ title: 'a', status: 'pending' },
{ title: 'b', status: 'in_progress' },
];
service.setTodos(next);
expect(service.getTodos()).toEqual(next);
service.clear();
expect(service.getTodos()).toEqual([]);
});
it('fires onDidChange after each setTodos', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
const seen: Array<readonly TodoItem[]> = [];
const d = service.onDidChange((todos) => seen.push(todos));
service.setTodos([{ title: 'x', status: 'pending' }]);
service.setTodos([{ title: 'y', status: 'done' }]);
d.dispose();
expect(seen).toEqual([
[{ title: 'x', status: 'pending' }],
[{ title: 'y', status: 'done' }],
]);
});
it('appends a todo.set record to the main agent wire on setTodos', () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
service.setTodos([{ title: 'persist me', status: 'in_progress' }]);
expect(main.appended).toEqual([
{ type: 'todo.set', todos: [{ title: 'persist me', status: 'in_progress' }] },
]);
});
it('does not append to the wire when the main agent is absent', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
// Should not throw even without a main agent.
expect(() => service.setTodos([{ title: 'x', status: 'pending' }])).not.toThrow();
expect(service.getTodos()).toEqual([{ title: 'x', status: 'pending' }]);
});
it('binds the TodoList tool and reminder into every created agent', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
void service;
const main = makeFakeAgent('main');
const sub = makeFakeAgent('agent-1');
lifecycle.fireCreate(main.handle);
lifecycle.fireCreate(sub.handle);
expect(main.registeredTools).toContain('TodoListTool');
expect(main.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT);
expect(sub.registeredTools).toContain('TodoListTool');
expect(sub.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT);
});
it('registers the todo.set resume resumer only on the main agent', () => {
const main = makeFakeAgent('main');
const sub = makeFakeAgent('agent-1');
const lifecycle = makeLifecycleStub([main.handle, sub.handle]);
const service = new SessionTodoService(lifecycle.service);
void service;
expect(main.resumers).toHaveLength(1);
expect(sub.resumers).toHaveLength(0);
});
it('rebuilds the in-memory list when a todo.set record is resumed', () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
const resumer = main.resumers[0];
expect(resumer).toBeDefined();
resumer!({ todos: [{ title: 'restored', status: 'done' }] });
expect(service.getTodos()).toEqual([{ title: 'restored', status: 'done' }]);
});
it('disposes per-agent bindings when the agent is disposed', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
const main = makeFakeAgent('main');
lifecycle.fireCreate(main.handle);
expect(main.registeredTools).toHaveLength(1);
// Disposal should not throw and should leave the service usable.
expect(() => lifecycle.fireDispose('main')).not.toThrow();
expect(service.getTodos()).toEqual([]);
});
it('satisfies the ISessionTodoService contract', () => {
const lifecycle = makeLifecycleStub();
const service: ISessionTodoService = new SessionTodoService(lifecycle.service);
expect(typeof service.getTodos).toBe('function');
expect(typeof service.setTodos).toBe('function');
expect(typeof service.clear).toBe('function');
expect(typeof service.onDidChange).toBe('function');
});
});

View file

@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ContextMessage } from '#/agent/contextMemory';
import { todoListStaleReminder } from '#/agent/todoList/todoListReminder';
import type { TodoItem } from '#/agent/todoList/tools/todo-list';
import { todoListStaleReminder, type TodoItem } from '#/session/todo';
function assistantMessage(): ContextMessage {
return {
@ -51,7 +50,7 @@ function priorTodoReminder(): ContextMessage {
};
}
describe('TodoListReminderInjector', () => {
describe('todoListStaleReminder', () => {
it('skips reminder injection when TodoList is not active', async () => {
const history = Array.from({ length: 10 }, () => assistantMessage());
const result = todoListStaleReminder({

View file

@ -2,33 +2,32 @@ import { describe, expect, it } from 'vitest';
import {
TODO_LIST_TOOL_NAME,
TODO_STORE_KEY,
TodoListInputSchema,
TodoListTool,
type ISessionTodoService,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
import type { IAgentToolState } from '#/agent/toolState';
} from '#/session/todo';
import { executeTool } from '../tools/fixtures/execute-tool';
const signal = new AbortController().signal;
function makeStore(initial: readonly TodoItem[] = []): {
readonly store: IAgentToolState;
function makeTodoService(initial: readonly TodoItem[] = []): {
readonly service: ISessionTodoService;
readonly getTodos: () => readonly TodoItem[];
} {
let todos = [...initial];
return {
store: {
service: {
_serviceBrand: undefined,
get: (key: string) => (key === TODO_STORE_KEY ? todos : undefined),
set: (key: string, value: unknown) => {
if (key === TODO_STORE_KEY) {
todos = [...(value as readonly TodoItem[])];
}
getTodos: () => todos,
setTodos: (next: readonly TodoItem[]) => {
todos = next.map((todo) => ({ title: todo.title, status: todo.status }));
},
data: () => ({ [TODO_STORE_KEY]: todos }),
hooks: { onUpdated: { register: () => ({ dispose: () => {} }) } },
} as unknown as IAgentToolState,
clear: () => {
todos = [];
},
onDidChange: () => ({ dispose: () => {} }),
},
getTodos: () => todos,
};
}
@ -37,8 +36,8 @@ function makeTool(initial: readonly TodoItem[] = []): {
readonly tool: TodoListTool;
readonly getTodos: () => readonly TodoItem[];
} {
const { store, getTodos } = makeStore(initial);
return { tool: new TodoListTool(store), getTodos };
const { service, getTodos } = makeTodoService(initial);
return { tool: new TodoListTool(service), getTodos };
}
describe('TodoListTool', () => {
@ -46,7 +45,6 @@ describe('TodoListTool', () => {
const { tool } = makeTool();
expect(TODO_LIST_TOOL_NAME).toBe('TodoList');
expect(TODO_STORE_KEY).toBe('todo');
expect(tool.name).toBe(TODO_LIST_TOOL_NAME);
expect(tool.description.length).toBeGreaterThan(0);
expect(TodoListInputSchema.safeParse({}).success).toBe(true);
@ -99,7 +97,7 @@ describe('TodoListTool', () => {
expect(getTodos()).toEqual([{ title: 'existing', status: 'in_progress' }]);
});
it('write mode replaces the list and defensively copies todos into the store', async () => {
it('write mode replaces the list and defensively copies todos into the service', async () => {
const { tool, getTodos } = makeTool();
const todos: TodoItem[] = [
{ title: 'first', status: 'pending' },