mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
bcc9bc095f
commit
b3ac27e79a
21 changed files with 665 additions and 331 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* `todoList` domain barrel - re-exports the todoList service contract and implementation.
|
||||
*/
|
||||
|
||||
export * from './todoList';
|
||||
export * from './todoListReminder';
|
||||
export * from './todoListService';
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
|
||||
export interface IAgentTodoListService {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const IAgentTodoListService = createDecorator<IAgentTodoListService>('agentTodoListService');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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 })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
12
packages/agent-core-v2/src/session/todo/index.ts
Normal file
12
packages/agent-core-v2/src/session/todo/index.ts
Normal 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';
|
||||
28
packages/agent-core-v2/src/session/todo/sessionTodo.ts
Normal file
28
packages/agent-core-v2/src/session/todo/sessionTodo.ts
Normal 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');
|
||||
164
packages/agent-core-v2/src/session/todo/sessionTodoService.ts
Normal file
164
packages/agent-core-v2/src/session/todo/sessionTodoService.ts
Normal 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',
|
||||
);
|
||||
61
packages/agent-core-v2/src/session/todo/todoItem.ts
Normal file
61
packages/agent-core-v2/src/session/todo/todoItem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
86
packages/agent-core-v2/src/session/todo/tools/todo-list.ts
Normal file
86
packages/agent-core-v2/src/session/todo/tools/todo-list.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
|
|
|
|||
271
packages/agent-core-v2/test/todo/session-todo.test.ts
Normal file
271
packages/agent-core-v2/test/todo/session-todo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
@ -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' },
|
||||
Loading…
Add table
Add a link
Reference in a new issue