From cb26602a02e0d83a13bad010b7984fdf187da3e7 Mon Sep 17 00:00:00 2001 From: Arjun Date: Tue, 18 Nov 2025 10:00:33 +0530 Subject: [PATCH] implement system reminder and move todos to not use files --- .../src/application/assistant/instructions.ts | 2 +- apps/cli/src/application/config/config.ts | 11 +-- apps/cli/src/application/lib/agent.ts | 30 +++++- apps/cli/src/application/lib/builtin-tools.ts | 95 ++----------------- 4 files changed, 40 insertions(+), 98 deletions(-) diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index d4aea6ad..063d7002 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -21,7 +21,7 @@ Always consult this catalog first so you load the right skills before taking act - Always ask for confirmation before taking destructive actions. ## Task tracking -- Maintain a durable todo list for multi-step efforts using the \`todoList\`, \`todoWrite\`, and \`todoUpdate\` builtin tools (data lives under ~/.rowboatx/copilot/todos.json). +- Maintain a durable todo list for multi-step efforts using the \`todoList\`, \`todoWrite\`, and \`todoUpdate\` builtin tools (state persists only within this copilot session). - Treat the text returned by those tools as internal guidance—never echo these reminders to the user verbatim. ## Execution reminders diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index a506e5d0..ec544eb0 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -7,8 +7,7 @@ import { homedir } from "os"; // Resolve app root relative to compiled file location (dist/...) export const WorkDir = path.join(homedir(), ".rowboat"); -export const RowboatXDir = path.join(homedir(), ".rowboatx"); -export const CopilotDataDir = path.join(RowboatXDir, "copilot"); +export const CopilotDataDir = path.join(WorkDir, "copilot"); const baseMcpConfig: z.infer = { mcpServers: { @@ -54,20 +53,14 @@ function ensureModelConfig() { } } -function ensureRowboatXDirs() { - const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; - ensure(RowboatXDir); - ensure(CopilotDataDir); -} - function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; ensure(WorkDir); ensure(path.join(WorkDir, "agents")); ensure(path.join(WorkDir, "config")); + ensure(CopilotDataDir); ensureMcpConfig(); ensureModelConfig(); - ensureRowboatXDirs(); } ensureDirs(); diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 3ee99728..d9d0c70b 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -13,6 +13,7 @@ import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; import { execTool } from "./exec-tool.js"; import { RunEvent } from "../entities/run-events.js"; import { BuiltinTools } from "./builtin-tools.js"; +import { collectSystemReminders } from "../assistant/reminders/manager.js"; export async function mapAgentTool(t: z.infer): Promise { switch (t.type) { @@ -310,9 +311,14 @@ export async function* streamAgentTurn(opts: { input: JSON.stringify(toolCall.arguments), }; const result = await execTool(agentTool, toolCall.arguments); + const reminders = await collectSystemReminders({ + source: "tool-result", + toolName: toolCall.toolName, + }); + const decoratedResult = reminders.length > 0 ? attachRemindersToResult(result, reminders) : result; const resultMsg: z.infer = { role: "tool", - content: JSON.stringify(result), + content: JSON.stringify(decoratedResult), toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, }; @@ -320,7 +326,7 @@ export async function* streamAgentTurn(opts: { yield { type: "tool-result", toolName: toolCall.toolName, - result: result, + result: decoratedResult, }; yield { type: "message", @@ -418,6 +424,26 @@ async function* streamLlm( } } } +function attachRemindersToResult(result: any, reminders: string[]) { + if (!reminders.length) { + return result; + } + + if (result && typeof result === "object" && !Array.isArray(result)) { + const existing = Array.isArray((result as any).systemReminders) + ? (result as any).systemReminders + : []; + return { + ...result, + systemReminders: [...existing, ...reminders], + }; + } + + return { + data: result, + systemReminders: reminders, + }; +} export const MappedToolCall = z.object({ toolCall: ToolCallPart, agentTool: ToolAttachment, diff --git a/apps/cli/src/application/lib/builtin-tools.ts b/apps/cli/src/application/lib/builtin-tools.ts index 821bfb2e..125d8c3b 100644 --- a/apps/cli/src/application/lib/builtin-tools.ts +++ b/apps/cli/src/application/lib/builtin-tools.ts @@ -1,16 +1,15 @@ import { z, ZodType } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; -import { CopilotDataDir, WorkDir as BASE_DIR } from "../config/config.js"; +import { WorkDir as BASE_DIR } from "../config/config.js"; import { executeCommand } from "./command-executor.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; +import { TodoStatusSchema, readTodoState, writeTodoState, buildTodoReminder } from "./todo-store.js"; -const TODO_FILE = path.join(CopilotDataDir, "todos.json"); -const TodoStatusSchema = z.enum(["pending", "in_progress", "done"]); const TodoItemInputSchema = z.object({ id: z.string().min(1, "Todo id is required"), content: z.string().min(1, "Todo content cannot be empty"), @@ -24,77 +23,6 @@ const TodoUpdateInputSchema = z.object({ message: "Provide content and/or status when updating a todo", }); -type TodoItem = { - id: string; - content: string; - status: z.infer; -}; - -type TodoState = { - todos: TodoItem[]; - updatedAt: string; -}; - -const defaultTodoState: TodoState = { - todos: [], - updatedAt: new Date(0).toISOString(), -}; - -async function ensureTodoFile(): Promise { - try { - await fs.access(TODO_FILE); - } catch { - await fs.mkdir(path.dirname(TODO_FILE), { recursive: true }); - await fs.writeFile(TODO_FILE, JSON.stringify(defaultTodoState, null, 2), "utf-8"); - } -} - -async function readTodoState(): Promise { - await ensureTodoFile(); - try { - const contents = await fs.readFile(TODO_FILE, "utf-8"); - const parsed = JSON.parse(contents); - return { - todos: Array.isArray(parsed.todos) ? sanitiseTodos(parsed.todos) : [], - updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date(0).toISOString(), - }; - } catch { - return defaultTodoState; - } -} - -async function writeTodoState(todos: TodoItem[]): Promise { - await fs.mkdir(path.dirname(TODO_FILE), { recursive: true }); - const payload: TodoState = { - todos: sanitiseTodos(todos), - updatedAt: new Date().toISOString(), - }; - await fs.writeFile(TODO_FILE, JSON.stringify(payload, null, 2), "utf-8"); - return payload; -} - -function buildTodoReminder(todos: TodoItem[], preface: string) { - return `\n${preface}\n\n${JSON.stringify(todos)}\n`; -} - -function sanitiseTodos(todos: TodoItem[]): TodoItem[] { - const seen = new Set(); - const sanitized: TodoItem[] = []; - for (const todo of todos) { - if (!todo) continue; - const id = typeof todo.id === "string" ? todo.id.trim() : ""; - const content = typeof todo.content === "string" ? todo.content : ""; - const statusResult = TodoStatusSchema.safeParse(todo.status); - const status = statusResult.success ? statusResult.data : "pending"; - if (!id || !content || seen.has(id)) { - continue; - } - seen.add(id); - sanitized.push({ id, content, status }); - } - return sanitized; -} - const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), inputSchema: z.custom(), @@ -347,7 +275,7 @@ export const BuiltinTools: z.infer = { }, todoList: { - description: 'Return the durable todo list stored under ~/.rowboatx/copilot/todos.json', + description: 'Return the durable todo list for the current session', inputSchema: z.object({}), execute: async () => { const state = await readTodoState(); @@ -362,7 +290,6 @@ export const BuiltinTools: z.infer = { todos: state.todos, updatedAt: state.updatedAt, reminder, - location: TODO_FILE, }; }, }, @@ -373,15 +300,13 @@ export const BuiltinTools: z.infer = { todos: z.array(TodoItemInputSchema).describe('Ordered array of todos to persist (replaces the current list)'), }), execute: async ({ todos }: { todos: z.infer[] }) => { - const sanitized = sanitiseTodos( - todos.map((todo) => ({ - id: todo.id, - content: todo.content, - status: todo.status ?? 'pending', - })), - ); + const normalized = todos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: todo.status ?? 'pending', + })); - const state = await writeTodoState(sanitized); + const state = await writeTodoState(normalized); const reminder = buildTodoReminder(state.todos, 'Your todo list has changed. Keep this reminder internal and continue executing the plan.'); return { @@ -390,7 +315,6 @@ export const BuiltinTools: z.infer = { updatedAt: state.updatedAt, reminder, count: state.todos.length, - location: TODO_FILE, }; }, }, @@ -442,7 +366,6 @@ export const BuiltinTools: z.infer = { todos: newState.todos, updatedAt: newState.updatedAt, reminder, - location: TODO_FILE, }; }, },