mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: add todo list reminder injection (#333)
This commit is contained in:
parent
7a47045af2
commit
1178c5cd14
8 changed files with 378 additions and 9 deletions
6
.changeset/todo-list-reminder.md
Normal file
6
.changeset/todo-list-reminder.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Remind the model to refresh TodoList during long-running tasks and strengthen TodoList progress-tracking guidance.
|
||||
|
|
@ -3,6 +3,7 @@ import type { DynamicInjector } from './injector';
|
|||
import { PermissionModeInjector } from './permission-mode';
|
||||
import { PluginSessionStartInjector } from './plugin-session-start';
|
||||
import { PlanModeInjector } from './plan-mode';
|
||||
import { TodoListReminderInjector } from './todo-list';
|
||||
|
||||
export class InjectionManager {
|
||||
private readonly injectors: DynamicInjector[];
|
||||
|
|
@ -10,6 +11,7 @@ export class InjectionManager {
|
|||
constructor(protected readonly agent: Agent) {
|
||||
this.injectors = [
|
||||
new PluginSessionStartInjector(agent),
|
||||
new TodoListReminderInjector(agent),
|
||||
new PlanModeInjector(agent),
|
||||
new PermissionModeInjector(agent),
|
||||
];
|
||||
|
|
|
|||
131
packages/agent-core/src/agent/injection/todo-list.ts
Normal file
131
packages/agent-core/src/agent/injection/todo-list.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type { ContextMessage } from '#/agent/context';
|
||||
import {
|
||||
TODO_LIST_TOOL_NAME,
|
||||
TODO_STORE_KEY,
|
||||
type TodoItem,
|
||||
type TodoStatus,
|
||||
} from '#/tools/builtin/state/todo-list';
|
||||
|
||||
import { DynamicInjector } from './injector';
|
||||
|
||||
const TODO_LIST_REMINDER_VARIANT = 'todo_list_reminder';
|
||||
const TODO_LIST_REMINDER_TURNS_SINCE_WRITE = 10;
|
||||
const TODO_LIST_REMINDER_TURNS_BETWEEN_REMINDERS = 10;
|
||||
|
||||
interface TodoListReminderTurnCounts {
|
||||
readonly turnsSinceLastWrite: number;
|
||||
readonly turnsSinceLastReminder: number;
|
||||
}
|
||||
|
||||
export class TodoListReminderInjector extends DynamicInjector {
|
||||
protected override readonly injectionVariant = TODO_LIST_REMINDER_VARIANT;
|
||||
|
||||
protected override getInjection(): string | undefined {
|
||||
if (!this.isTodoListActive()) return undefined;
|
||||
|
||||
const counts = getTodoListReminderTurnCounts(this.agent.context.history);
|
||||
if (
|
||||
counts.turnsSinceLastWrite < TODO_LIST_REMINDER_TURNS_SINCE_WRITE ||
|
||||
counts.turnsSinceLastReminder < TODO_LIST_REMINDER_TURNS_BETWEEN_REMINDERS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return renderTodoListReminder(this.currentTodos());
|
||||
}
|
||||
|
||||
private isTodoListActive(): boolean {
|
||||
return this.agent.tools.data().some((tool) => {
|
||||
return tool.name === TODO_LIST_TOOL_NAME && tool.active;
|
||||
});
|
||||
}
|
||||
|
||||
private currentTodos(): readonly TodoItem[] {
|
||||
const raw = this.agent.tools.storeData()[TODO_STORE_KEY];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(isTodoItem).map((todo) => ({
|
||||
title: todo.title,
|
||||
status: todo.status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function getTodoListReminderTurnCounts(
|
||||
history: readonly ContextMessage[],
|
||||
): TodoListReminderTurnCounts {
|
||||
let foundWrite = false;
|
||||
let foundReminder = false;
|
||||
let turnsSinceLastWrite = 0;
|
||||
let turnsSinceLastReminder = 0;
|
||||
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
const message = history[i];
|
||||
if (message === undefined) continue;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
if (!foundWrite && hasTodoListWrite(message)) {
|
||||
foundWrite = true;
|
||||
}
|
||||
if (!foundWrite) turnsSinceLastWrite += 1;
|
||||
if (!foundReminder) turnsSinceLastReminder += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!foundReminder && isTodoListReminder(message)) {
|
||||
foundReminder = true;
|
||||
}
|
||||
|
||||
if (foundWrite && foundReminder) break;
|
||||
}
|
||||
|
||||
return {
|
||||
turnsSinceLastWrite,
|
||||
turnsSinceLastReminder,
|
||||
};
|
||||
}
|
||||
|
||||
function hasTodoListWrite(message: ContextMessage): boolean {
|
||||
return message.toolCalls.some((toolCall) => {
|
||||
if (toolCall.name !== TODO_LIST_TOOL_NAME) return false;
|
||||
if (typeof toolCall.arguments !== 'string') return false;
|
||||
|
||||
try {
|
||||
const args = JSON.parse(toolCall.arguments) as { todos?: unknown };
|
||||
return Array.isArray(args.todos);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isTodoListReminder(message: ContextMessage): boolean {
|
||||
return (
|
||||
message.origin?.kind === 'injection' && message.origin.variant === TODO_LIST_REMINDER_VARIANT
|
||||
);
|
||||
}
|
||||
|
||||
function renderTodoListReminder(todos: readonly TodoItem[]): string {
|
||||
let message =
|
||||
'The TodoList tool has not been updated recently. If you are working on tasks that benefit from progress tracking, consider using TodoList to update task status. Also consider clearing or rewriting the todo list if it has become stale and no longer matches the current work. Only use it if relevant. This is a gentle reminder; ignore it if not applicable. Make sure that you NEVER mention this reminder to the user.';
|
||||
|
||||
const items = renderTodoItems(todos);
|
||||
if (items.length > 0) {
|
||||
message += `\n\nCurrent todo list:\n${items}`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function renderTodoItems(todos: readonly TodoItem[]): string {
|
||||
return todos.map((todo, index) => `${index + 1}. [${todo.status}] ${todo.title}`).join('\n');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
Use this tool to maintain a structured TODO list as you work through a multi-step task. This is especially useful in plan mode and for long-running investigations.
|
||||
Use this tool to maintain a structured TODO list as you work through a multi-step task. Use it proactively and often when progress tracking helps the current work. This is especially useful in Plan mode, long-running investigations, and implementation tasks with several tool calls.
|
||||
|
||||
**When to use:**
|
||||
- Multi-step tasks that span several tool calls
|
||||
- Tracking investigation progress across a large codebase search
|
||||
- Planning a sequence of edits before making them
|
||||
- After receiving new multi-step instructions, capture the requirements as todos
|
||||
- Before starting a tracked task, mark exactly one item as `in_progress`
|
||||
- Immediately after finishing a tracked task, mark it `done`; do not batch completions at the end
|
||||
|
||||
**When NOT to use:**
|
||||
- Single-shot answers that complete in one or two tool calls
|
||||
- Trivial requests where tracking adds no clarity
|
||||
- Purely conversational or informational replies
|
||||
|
||||
**Avoid churn:**
|
||||
- Do not re-call this tool when nothing meaningful has changed since the last call — update the list only after real progress.
|
||||
|
|
@ -19,4 +23,8 @@ Use this tool to maintain a structured TODO list as you work through a multi-ste
|
|||
- Call with no arguments to retrieve the current list without changing it.
|
||||
- Call with `todos: []` to clear the list.
|
||||
- Keep titles short and actionable (e.g. "Read session-control.ts", "Add planMode flag to TurnManager").
|
||||
- Update statuses as you make progress — mark one item in_progress at a time.
|
||||
- Update statuses as you make progress.
|
||||
- When work is underway, keep exactly one task `in_progress`.
|
||||
- Only mark a task `done` when it is fully accomplished.
|
||||
- Never mark a task `done` if tests are failing, implementation is partial, unresolved errors remain, or required files/dependencies could not be found.
|
||||
- If you encounter a blocker, keep the blocked task `in_progress` or add a new pending task describing what must be resolved.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ import DESCRIPTION from './todo-list.md';
|
|||
|
||||
// ── TODO state shape ─────────────────────────────────────────────────
|
||||
|
||||
export const TODO_LIST_TOOL_NAME = 'TodoList' as const;
|
||||
export const TODO_STORE_KEY = 'todo';
|
||||
const TODO_LIST_WRITE_REMINDER =
|
||||
'Ensure that you continue to use the todo list to track progress. Mark tasks done immediately after finishing them, and keep exactly one task in_progress when work is underway.';
|
||||
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'done';
|
||||
|
||||
export interface TodoItem {
|
||||
|
|
@ -56,8 +61,6 @@ export const TodoListInputSchema: z.ZodType<TodoListInput> = z.object({
|
|||
),
|
||||
});
|
||||
|
||||
const TODO_STORE_KEY = 'todo';
|
||||
|
||||
// ── Implementation ───────────────────────────────────────────────────
|
||||
|
||||
export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string {
|
||||
|
|
@ -87,7 +90,7 @@ function statusMarker(status: TodoStatus): string {
|
|||
}
|
||||
|
||||
export class TodoListTool implements BuiltinTool<TodoListInput> {
|
||||
readonly name = 'TodoList' as const;
|
||||
readonly name = TODO_LIST_TOOL_NAME;
|
||||
readonly description: string = DESCRIPTION;
|
||||
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
|
||||
|
||||
|
|
@ -114,7 +117,9 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
|
|||
this.setTodos(args.todos);
|
||||
const stored = this.getTodos();
|
||||
const output =
|
||||
stored.length === 0 ? 'Todo list cleared.' : `Todo list updated.\n${renderTodoList(stored)}`;
|
||||
stored.length === 0
|
||||
? 'Todo list cleared.'
|
||||
: `Todo list updated.\n${renderTodoList(stored)}\n\n${TODO_LIST_WRITE_REMINDER}`;
|
||||
return { isError: false, output };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import { DynamicInjector } from '../../../src/agent/injection/injector';
|
||||
import { InjectionManager } from '../../../src/agent/injection/manager';
|
||||
import { TodoListReminderInjector } from '../../../src/agent/injection/todo-list';
|
||||
import { testAgent } from '../harness/agent';
|
||||
|
||||
class RecordingInjector extends DynamicInjector {
|
||||
|
|
@ -100,3 +101,14 @@ describe('InjectionManager.onContextCompacted', () => {
|
|||
expect(recorder.compactionCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InjectionManager registration', () => {
|
||||
it('registers TodoListReminderInjector in the default injector chain', () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure();
|
||||
|
||||
const injectors = (ctx.agent.injection as unknown as { injectors: DynamicInjector[] }).injectors;
|
||||
|
||||
expect(injectors.some((injector) => injector instanceof TodoListReminderInjector)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
172
packages/agent-core/test/agent/injection/todo-list.test.ts
Normal file
172
packages/agent-core/test/agent/injection/todo-list.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Agent } from '../../../src/agent';
|
||||
import type { ContextMessage } from '../../../src/agent/context';
|
||||
import { TodoListReminderInjector } from '../../../src/agent/injection/todo-list';
|
||||
import type { TodoItem } from '../../../src/tools/builtin/state/todo-list';
|
||||
|
||||
interface TodoAgentStub {
|
||||
readonly history: ContextMessage[];
|
||||
readonly todos: readonly TodoItem[];
|
||||
readonly todoListActive: boolean;
|
||||
}
|
||||
|
||||
function todoAgent(stub: TodoAgentStub): Agent {
|
||||
return {
|
||||
type: 'main',
|
||||
context: {
|
||||
get history() {
|
||||
return stub.history;
|
||||
},
|
||||
appendSystemReminder: (content: string, origin: ContextMessage['origin']) => {
|
||||
stub.history.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `<system-reminder>\n${content}\n</system-reminder>` }],
|
||||
toolCalls: [],
|
||||
origin,
|
||||
});
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
data: () => [
|
||||
{
|
||||
name: 'TodoList',
|
||||
description: 'Todo list',
|
||||
active: stub.todoListActive,
|
||||
source: 'builtin',
|
||||
},
|
||||
],
|
||||
storeData: () => ({ todo: stub.todos }),
|
||||
},
|
||||
} as unknown as Agent;
|
||||
}
|
||||
|
||||
function assistantMessage(): ContextMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'working' }],
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
function todoListWrite(todos: readonly TodoItem[]): ContextMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
toolCalls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: 'call_todo_write',
|
||||
name: 'TodoList',
|
||||
arguments: JSON.stringify({ todos }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function todoListQuery(): ContextMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
toolCalls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: 'call_todo_query',
|
||||
name: 'TodoList',
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function priorTodoReminder(): ContextMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>\nPrior todo reminder\n</system-reminder>' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'todo_list_reminder' },
|
||||
};
|
||||
}
|
||||
|
||||
function lastReminderText(history: readonly ContextMessage[]): string {
|
||||
const message = history.findLast((entry) => entry.origin?.kind === 'injection');
|
||||
return message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '';
|
||||
}
|
||||
|
||||
describe('TodoListReminderInjector', () => {
|
||||
it('skips reminder injection when TodoList is not active', async () => {
|
||||
const history = Array.from({ length: 10 }, () => assistantMessage());
|
||||
const agent = todoAgent({
|
||||
history,
|
||||
todos: [{ title: 'Investigate todo reminder', status: 'in_progress' }],
|
||||
todoListActive: false,
|
||||
});
|
||||
const injector = new TodoListReminderInjector(agent);
|
||||
|
||||
await injector.inject();
|
||||
|
||||
expect(history).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('injects a reminder after enough assistant turns since the last TodoList write', async () => {
|
||||
const todos: TodoItem[] = [
|
||||
{ title: 'Read current TodoList implementation', status: 'in_progress' },
|
||||
{ title: 'Add reminder injector tests', status: 'pending' },
|
||||
];
|
||||
const history = [todoListWrite(todos), ...Array.from({ length: 10 }, () => assistantMessage())];
|
||||
const agent = todoAgent({ history, todos, todoListActive: true });
|
||||
const injector = new TodoListReminderInjector(agent);
|
||||
|
||||
await injector.inject();
|
||||
|
||||
const text = lastReminderText(history);
|
||||
expect(text).toContain('The TodoList tool has not been updated recently');
|
||||
expect(text).toContain('NEVER mention this reminder to the user');
|
||||
expect(text).toContain('Current todo list:');
|
||||
expect(text).toContain('1. [in_progress] Read current TodoList implementation');
|
||||
expect(text).toContain('2. [pending] Add reminder injector tests');
|
||||
});
|
||||
|
||||
it('does not inject before the assistant-turn threshold', async () => {
|
||||
const todos: TodoItem[] = [{ title: 'Read code', status: 'in_progress' }];
|
||||
const history = [todoListWrite(todos), ...Array.from({ length: 9 }, () => assistantMessage())];
|
||||
const agent = todoAgent({ history, todos, todoListActive: true });
|
||||
const injector = new TodoListReminderInjector(agent);
|
||||
|
||||
await injector.inject();
|
||||
|
||||
expect(history).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('does not inject another reminder before the reminder spacing threshold', async () => {
|
||||
const todos: TodoItem[] = [{ title: 'Read code', status: 'in_progress' }];
|
||||
const history = [
|
||||
todoListWrite(todos),
|
||||
...Array.from({ length: 10 }, () => assistantMessage()),
|
||||
priorTodoReminder(),
|
||||
...Array.from({ length: 9 }, () => assistantMessage()),
|
||||
];
|
||||
const agent = todoAgent({ history, todos, todoListActive: true });
|
||||
const injector = new TodoListReminderInjector(agent);
|
||||
|
||||
await injector.inject();
|
||||
|
||||
expect(history).toHaveLength(21);
|
||||
});
|
||||
|
||||
it('does not treat TodoList query mode as a write', async () => {
|
||||
const todos: TodoItem[] = [{ title: 'Read code', status: 'in_progress' }];
|
||||
const history = [
|
||||
todoListWrite(todos),
|
||||
...Array.from({ length: 5 }, () => assistantMessage()),
|
||||
todoListQuery(),
|
||||
...Array.from({ length: 4 }, () => assistantMessage()),
|
||||
];
|
||||
const agent = todoAgent({ history, todos, todoListActive: true });
|
||||
const injector = new TodoListReminderInjector(agent);
|
||||
|
||||
await injector.inject();
|
||||
|
||||
expect(lastReminderText(history)).toContain('The TodoList tool has not been updated recently');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
TODO_LIST_TOOL_NAME,
|
||||
TODO_STORE_KEY,
|
||||
TodoListInputSchema,
|
||||
TodoListTool,
|
||||
type TodoItem,
|
||||
|
|
@ -25,9 +27,9 @@ function makeStore(initial: readonly TodoItem[] = []): {
|
|||
let todos = [...initial];
|
||||
return {
|
||||
store: {
|
||||
get: (key) => (key === 'todo' ? todos : undefined),
|
||||
get: (key) => (key === TODO_STORE_KEY ? todos : undefined),
|
||||
set: (key, value) => {
|
||||
if (key === 'todo') {
|
||||
if (key === TODO_STORE_KEY) {
|
||||
todos = [...(value as readonly TodoItem[])];
|
||||
}
|
||||
},
|
||||
|
|
@ -48,7 +50,9 @@ describe('TodoListTool', () => {
|
|||
it('has name, description, and parameters from the current schema', () => {
|
||||
const { tool } = makeTool();
|
||||
|
||||
expect(tool.name).toBe('TodoList');
|
||||
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);
|
||||
expect(
|
||||
|
|
@ -76,6 +80,18 @@ describe('TodoListTool', () => {
|
|||
expect(description).toMatch(/tell the user/i);
|
||||
});
|
||||
|
||||
it('description encourages proactive progress updates without allowing churn', () => {
|
||||
const { tool } = makeTool();
|
||||
const { description } = tool;
|
||||
|
||||
expect(description).toMatch(/proactively and often/i);
|
||||
expect(description).toMatch(/immediately after finishing/i);
|
||||
expect(description).toMatch(/exactly one/i);
|
||||
expect(description).toMatch(/in_progress/i);
|
||||
expect(description).toMatch(/tests are failing/i);
|
||||
expect(description).toContain('**Avoid churn:**');
|
||||
});
|
||||
|
||||
it('query mode renders the current list without mutating it', async () => {
|
||||
const { tool, getTodos } = makeTool([{ title: 'existing', status: 'in_progress' }]);
|
||||
|
||||
|
|
@ -111,6 +127,10 @@ describe('TodoListTool', () => {
|
|||
expect(result.output).toContain('Todo list updated');
|
||||
expect(result.output).toContain('[pending] first');
|
||||
expect(result.output).toContain('[in_progress] second');
|
||||
expect(result.output).toContain(
|
||||
'Ensure that you continue to use the todo list to track progress.',
|
||||
);
|
||||
expect(result.output).toContain('exactly one task in_progress');
|
||||
expect(getTodos()).toEqual([
|
||||
{ title: 'first', status: 'pending' },
|
||||
{ title: 'second', status: 'in_progress' },
|
||||
|
|
@ -146,6 +166,19 @@ describe('TodoListTool', () => {
|
|||
expect(getTodos()).toEqual([]);
|
||||
});
|
||||
|
||||
it('clear mode does not add the progress-tracking reminder', async () => {
|
||||
const { tool } = makeTool([{ title: 'x', status: 'pending' }]);
|
||||
|
||||
const result = await executeTool(tool, {
|
||||
turnId: 't1',
|
||||
toolCallId: 'call_1',
|
||||
args: { todos: [] },
|
||||
signal,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ isError: false, output: 'Todo list cleared.' });
|
||||
});
|
||||
|
||||
it('resolveExecution description reflects the mode', () => {
|
||||
const { tool } = makeTool();
|
||||
const readExecution = tool.resolveExecution({});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue