Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/todo-list-reminder.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/agent-core/src/agent/injection/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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[];

constructor(protected readonly agent: Agent) {
this.injectors = [
new PluginSessionStartInjector(agent),
new TodoListReminderInjector(agent),
new PlanModeInjector(agent),
new PermissionModeInjector(agent),
];
Expand Down
131 changes: 131 additions & 0 deletions packages/agent-core/src/agent/injection/todo-list.ts
Original file line number Diff line number Diff line change
@@ -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';
}
12 changes: 10 additions & 2 deletions packages/agent-core/src/tools/builtin/state/todo-list.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
13 changes: 9 additions & 4 deletions packages/agent-core/src/tools/builtin/state/todo-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand All @@ -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 };
},
};
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core/test/agent/injection/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading