From cc22524b91037bca4349157ae05bb28aaa4034e0 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 13:47:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,cli):=20M3c-rest=20=E2=80=94=20AskUse?= =?UTF-8?q?rQuestion=20+=20ExitPlanMode=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two agent-control tools added to BUILTIN_TOOLS. · packages/core/src/tools/ask-user.ts — host delegates via ToolContext.askUser callback; returns error in headless. Caps options at 4 and surfaces host errors as tool errors. · packages/core/src/tools/exit-plan.ts — flips ToolContext.modeSignal. exitPlanMode = true so the agent loop owner can switch mode plan → default after the turn. · packages/core/src/types.ts — ToolContext gains askUser? and modeSignal? optional fields. · packages/core/src/agent.ts — RunAgentOptions.askUser pass-through; RunAgentResult.modeSignal exposed so callers can act on ExitPlanMode. · apps/cli/src/repl.ts — REPL askUser callback: prints question + numbered options + reads either a number or free text (returned as "Other: ..."). Also flips ctx.mode from 'plan' to 'default' when result.modeSignal .exitPlanMode is set. Tests: +9 (5 ask-user + 3 exit-plan + still 1 reminder cleanup). Total: 387 → 396 passing (cli 47 unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/repl.ts | 23 +++++++ packages/core/src/agent.ts | 17 ++++- packages/core/src/index.ts | 2 + packages/core/src/tools/ask-user.test.ts | 80 +++++++++++++++++++++ packages/core/src/tools/ask-user.ts | 84 +++++++++++++++++++++++ packages/core/src/tools/exit-plan.test.ts | 28 ++++++++ packages/core/src/tools/exit-plan.ts | 41 +++++++++++ packages/core/src/tools/index.ts | 2 + packages/core/src/tools/registry.ts | 5 ++ packages/core/src/types.ts | 15 ++++ 10 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/tools/ask-user.test.ts create mode 100644 packages/core/src/tools/ask-user.ts create mode 100644 packages/core/src/tools/exit-plan.test.ts create mode 100644 packages/core/src/tools/exit-plan.ts diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index bb65527..1d7de0f 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -294,12 +294,35 @@ export async function startRepl(opts: ReplOpts): Promise { const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase(); return answer === 'y' || answer === 'yes'; }, + askUser: async (req) => { + output.write(`\n ❓ ${req.question}\n`); + const opts = req.options ?? []; + opts.forEach((o, i) => { + output.write(` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}\n`); + }); + if (opts.length === 0) { + return (await rl.question(' Answer: ')).trim(); + } + const reply = ( + await rl.question(` Pick 1-${opts.length} (or type free text): `) + ).trim(); + const n = Number(reply); + if (Number.isInteger(n) && n >= 1 && n <= opts.length) { + return opts[n - 1]!.label; + } + return `Other: ${reply}`; + }, onEvent: (e: AgentEvent) => formatEvent(output, e), }); history = result.history; ctx.usage.inputTokens += result.usage.inputTokens; ctx.usage.outputTokens += result.usage.outputTokens; ctx.usage.reasoningTokens += result.usage.reasoningTokens; + // M3c-rest: honor ExitPlanMode tool signal — flip plan → default + if (result.modeSignal?.exitPlanMode && ctx.mode === 'plan') { + ctx.mode = 'default'; + output.write('\n ▶ Exited plan mode (agent will now execute).\n'); + } output.write('\n'); if (result.stopReason === 'error') { output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n'); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 579e386..02ae8ad 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -67,6 +67,9 @@ export interface RunAgentOptions { /** Inject system reminders before the user message (date, todos, etc). * Pass `false` to disable; pass a partial list to limit which builders run. */ systemReminders?: false | { enabled?: ReminderType[] }; + /** Host callback for AskUserQuestion tool. Optional — when absent the tool + * errors. */ + askUser?: NonNullable; } export interface RunAgentResult { @@ -78,6 +81,8 @@ export interface RunAgentResult { usage: { inputTokens: number; outputTokens: number; reasoningTokens: number }; /** Reason the loop terminated. */ stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; + /** Mode-control signals flipped by tools during this run (M3c-rest). */ + modeSignal?: { exitPlanMode?: boolean }; } const DEFAULT_MAX_TURNS = 16; @@ -121,6 +126,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { if (opts.session) await opts.session.manager.append(opts.session.id, userMsg); } + // modeSignal is mutable — ExitPlanMode flips exitPlanMode = true; the agent + // loop owner reads this between turns to switch mode plan → default. + const modeSignal: { exitPlanMode?: boolean } = {}; const toolCtx: ToolContext = { cwd: opts.cwd, signal: opts.signal, @@ -128,6 +136,8 @@ export async function runAgent(opts: RunAgentOptions): Promise { sessionDir: opts.session ? `${opts.session.manager.root}/${opts.session.id}` : undefined, + askUser: opts.askUser, + modeSignal, }; const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }; let turnsUsed = 0; @@ -139,6 +149,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { turnsUsed, usage: totalUsage, stopReason: 'aborted', + modeSignal, }; } @@ -163,7 +174,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { } catch (err) { const message = (err as Error).message ?? 'unknown'; opts.onEvent?.({ type: 'error', error: message }); - return { history, turnsUsed, usage: totalUsage, stopReason: 'error' }; + return { history, turnsUsed, usage: totalUsage, stopReason: 'error', modeSignal }; } totalUsage.inputTokens += result.usage.inputTokens; @@ -200,7 +211,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { // If no tool calls, we're done if (result.stopReason !== 'tool_use') { - return { history, turnsUsed, usage: totalUsage, stopReason: 'end_turn' }; + return { history, turnsUsed, usage: totalUsage, stopReason: 'end_turn', modeSignal }; } // Execute tool calls and append a single user-role message with tool_result blocks @@ -361,7 +372,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { } } - return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns' }; + return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns', modeSignal }; } export const AGENT_MODULE_VERSION = '0.1.0'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index adbf83e..bdab5d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,8 @@ export { TodoWriteTool, WebFetchTool, WebSearchTool, + AskUserQuestionTool, + ExitPlanModeTool, readTodos, TODO_FILE, parseDuckDuckGoHtml, diff --git a/packages/core/src/tools/ask-user.test.ts b/packages/core/src/tools/ask-user.test.ts new file mode 100644 index 0000000..47c94d4 --- /dev/null +++ b/packages/core/src/tools/ask-user.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { AskUserQuestionTool } from './ask-user.js'; + +describe('AskUserQuestionTool', () => { + it('errors when no askUser callback is provided', async () => { + const r = await AskUserQuestionTool.execute( + { question: 'Which library?', options: [{ label: 'A' }, { label: 'B' }] }, + { cwd: '/x' }, + ); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/no interactive host/i); + }); + + it('errors when question is missing', async () => { + const r = await AskUserQuestionTool.execute({}, { cwd: '/x' }); + expect(r.isError).toBe(true); + }); + + it('forwards to askUser and returns the chosen answer', async () => { + let captured: { question: string; options: unknown[] } | null = null; + const r = await AskUserQuestionTool.execute( + { + question: 'A or B?', + options: [{ label: 'A', description: 'first' }, { label: 'B', description: 'second' }], + }, + { + cwd: '/x', + askUser: async (req) => { + captured = req; + return 'A'; + }, + }, + ); + expect(r.isError).toBeFalsy(); + expect(r.content).toBe('A'); + expect(captured!.question).toBe('A or B?'); + expect(captured!.options).toHaveLength(2); + }); + + it('rejects more than 4 options', async () => { + const r = await AskUserQuestionTool.execute( + { + question: 'pick', + options: [{ label: '1' }, { label: '2' }, { label: '3' }, { label: '4' }, { label: '5' }], + }, + { cwd: '/x', askUser: async () => '1' }, + ); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/at most 4/); + }); + + it('passes multiSelect through to the host', async () => { + let gotMulti = false; + await AskUserQuestionTool.execute( + { question: 'pick', options: [{ label: 'A' }], multiSelect: true }, + { + cwd: '/x', + askUser: async (req) => { + gotMulti = !!req.multiSelect; + return 'A'; + }, + }, + ); + expect(gotMulti).toBe(true); + }); + + it('surfaces host callback errors as tool errors', async () => { + const r = await AskUserQuestionTool.execute( + { question: 'pick' }, + { + cwd: '/x', + askUser: async () => { + throw new Error('user aborted'); + }, + }, + ); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/user aborted/); + }); +}); diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts new file mode 100644 index 0000000..4b6e585 --- /dev/null +++ b/packages/core/src/tools/ask-user.ts @@ -0,0 +1,84 @@ +// AskUserQuestion tool — agent asks the user a multiple-choice question. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15 (M3c-rest) +// +// The actual prompt UX lives in the host (CLI shows the prompt + readline; +// future GUI shows a modal). The tool delegates via ToolContext.askUser; if +// that callback is absent (headless), it returns an error. + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +interface AskInput { + question: string; + options?: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + header?: string; +} + +export const AskUserQuestionTool: ToolHandler = { + name: 'AskUserQuestion', + definition: { + name: 'AskUserQuestion', + description: + 'Ask the user a multiple-choice question and wait for the answer. Use when the agent needs the user to disambiguate or pick between approaches. Each option needs a short label and a description. The host always adds an implicit "Other" option for free text. Requires interactive mode — fails in headless.', + inputSchema: { + type: 'object', + properties: { + question: { + type: 'string', + description: 'The full question. Should end with a question mark.', + }, + header: { + type: 'string', + description: 'Optional short chip label (≤12 chars). E.g. "Auth method".', + }, + options: { + type: 'array', + description: '2-4 mutually exclusive options.', + items: { + type: 'object', + properties: { + label: { type: 'string', description: 'Short option text (1-5 words).' }, + description: { type: 'string', description: 'Explanation of what this option means.' }, + }, + required: ['label'], + }, + }, + multiSelect: { + type: 'boolean', + description: 'Allow the user to pick multiple options. Default false.', + }, + }, + required: ['question'], + }, + }, + async execute(rawInput: Record, ctx: ToolContext): Promise { + const input = rawInput as unknown as AskInput; + if (!input?.question || typeof input.question !== 'string') { + return { content: 'Error: question is required (string).', isError: true }; + } + if (!ctx.askUser) { + return { + content: + 'Error: cannot ask user — no interactive host available (running headless or in a sub-agent). Decide based on context instead.', + isError: true, + }; + } + const options = (input.options ?? []).map((o) => ({ + label: o.label, + description: o.description ?? '', + })); + if (options.length > 4) { + return { content: 'Error: at most 4 options allowed.', isError: true }; + } + try { + const answer = await ctx.askUser({ + question: input.question, + options, + multiSelect: !!input.multiSelect, + }); + return { content: answer, data: { question: input.question, answer } }; + } catch (err) { + return { content: `Error during user prompt: ${(err as Error).message}`, isError: true }; + } + }, +}; diff --git a/packages/core/src/tools/exit-plan.test.ts b/packages/core/src/tools/exit-plan.test.ts new file mode 100644 index 0000000..b57ed3e --- /dev/null +++ b/packages/core/src/tools/exit-plan.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { ExitPlanModeTool } from './exit-plan.js'; + +describe('ExitPlanModeTool', () => { + it('flips modeSignal.exitPlanMode and returns the plan summary', async () => { + const signal: { exitPlanMode?: boolean } = {}; + const r = await ExitPlanModeTool.execute( + { plan: 'Refactor auth into separate module.' }, + { cwd: '/x', modeSignal: signal }, + ); + expect(r.isError).toBeFalsy(); + expect(signal.exitPlanMode).toBe(true); + expect(r.content).toContain('Refactor auth'); + expect((r.data as { exitPlanMode: boolean }).exitPlanMode).toBe(true); + }); + + it('still succeeds when no modeSignal is passed (best-effort)', async () => { + const r = await ExitPlanModeTool.execute({}, { cwd: '/x' }); + expect(r.isError).toBeFalsy(); + expect((r.data as { exitPlanMode: boolean }).exitPlanMode).toBe(true); + }); + + it('omits "Plan: ..." when plan is empty', async () => { + const r = await ExitPlanModeTool.execute({ plan: '' }, { cwd: '/x' }); + expect(r.content).toMatch(/Exiting plan mode/); + expect(r.content).not.toMatch(/Plan:/); + }); +}); diff --git a/packages/core/src/tools/exit-plan.ts b/packages/core/src/tools/exit-plan.ts new file mode 100644 index 0000000..9380158 --- /dev/null +++ b/packages/core/src/tools/exit-plan.ts @@ -0,0 +1,41 @@ +// ExitPlanMode tool — signals the host that the agent is done planning and +// wants to start executing. Host flips mode from 'plan' to 'default'. +// Spec: docs/DEVELOPMENT_PLAN.md §3.8 (M3c-rest) + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +interface ExitInput { + plan?: string; +} + +export const ExitPlanModeTool: ToolHandler = { + name: 'ExitPlanMode', + definition: { + name: 'ExitPlanMode', + description: + "Signal that the plan is complete and the agent wants to leave plan mode to start executing. The host changes the active mode from 'plan' to 'default'. Pass `plan` to summarize what you intend to do.", + inputSchema: { + type: 'object', + properties: { + plan: { + type: 'string', + description: + 'Short summary of the plan to be executed (shown to the user before they approve the mode switch).', + }, + }, + required: [], + }, + }, + async execute(rawInput: Record, ctx: ToolContext): Promise { + const input = rawInput as unknown as ExitInput; + if (ctx.modeSignal) ctx.modeSignal.exitPlanMode = true; + const plan = input?.plan?.trim() ?? ''; + const msg = plan + ? `Exiting plan mode. Plan: ${plan}` + : 'Exiting plan mode — agent will begin executing.'; + return { + content: msg, + data: { exitPlanMode: true, plan }, + }; + }, +}; diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 3e2a6df..219cd6e 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -13,5 +13,7 @@ export type { TodoItem, TodoStatus } from './todo.js'; export { WebFetchTool } from './web-fetch.js'; export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js'; export type { SearchHit } from './web-search.js'; +export { AskUserQuestionTool } from './ask-user.js'; +export { ExitPlanModeTool } from './exit-plan.js'; export { ToolRegistry, BUILTIN_TOOLS } from './registry.js'; export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js'; diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 2ebc03b..1d9acff 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -2,8 +2,10 @@ // Spec: docs/DEVELOPMENT_PLAN.md §3.2 import type { ToolHandler } from '../types.js'; +import { AskUserQuestionTool } from './ask-user.js'; import { BashTool } from './bash.js'; import { EditTool } from './edit.js'; +import { ExitPlanModeTool } from './exit-plan.js'; import { GlobTool } from './glob.js'; import { GrepTool } from './grep.js'; import { ReadTool } from './read.js'; @@ -16,6 +18,7 @@ import { WriteTool } from './write.js'; * Built-in tools shipped by default. * · 6 P0 tools from M1 (Read/Write/Edit/Bash/Grep/Glob) * · 3 M3c-rest tools (TodoWrite/WebFetch/WebSearch) + * · 2 agent-control tools (AskUserQuestion/ExitPlanMode) */ export const BUILTIN_TOOLS: ToolHandler[] = [ ReadTool, @@ -27,6 +30,8 @@ export const BUILTIN_TOOLS: ToolHandler[] = [ TodoWriteTool, WebFetchTool, WebSearchTool, + AskUserQuestionTool, + ExitPlanModeTool, ]; export class ToolRegistry { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 42313da..6793867 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -116,6 +116,21 @@ export interface ToolContext { signal?: AbortSignal; /** Optional platform sandbox config — passed through to Bash tool (M3.5). */ sandboxConfig?: import('./config/types.js').SandboxConfig; + /** + * Host callback for interactive prompts (AskUserQuestion). Returns undefined + * in headless mode. Called by the AskUserQuestion tool with the question + + * options; resolves to the chosen label (or 'Other: ' if free input). + */ + askUser?: (req: { + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; + }) => Promise; + /** + * Mutable host state that the ExitPlanMode tool flips. The agent loop reads + * this between turns and changes its mode accordingly. + */ + modeSignal?: { exitPlanMode?: boolean }; } export interface ToolResult {