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
23 changes: 23 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,12 +294,35 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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');
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolContext['askUser']>;
}

export interface RunAgentResult {
Expand All @@ -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;
Expand Down Expand Up @@ -121,13 +126,18 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
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,
sandboxConfig: opts.sandboxConfig,
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;
Expand All @@ -139,6 +149,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
turnsUsed,
usage: totalUsage,
stopReason: 'aborted',
modeSignal,
};
}

Expand All @@ -163,7 +174,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
} 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;
Expand Down Expand Up @@ -200,7 +211,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {

// 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
Expand Down Expand Up @@ -361,7 +372,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
}
}

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';
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export {
TodoWriteTool,
WebFetchTool,
WebSearchTool,
AskUserQuestionTool,
ExitPlanModeTool,
readTodos,
TODO_FILE,
parseDuckDuckGoHtml,
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/tools/ask-user.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
84 changes: 84 additions & 0 deletions packages/core/src/tools/ask-user.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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 };
}
},
};
28 changes: 28 additions & 0 deletions packages/core/src/tools/exit-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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:/);
});
});
41 changes: 41 additions & 0 deletions packages/core/src/tools/exit-plan.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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 },
};
},
};
2 changes: 2 additions & 0 deletions packages/core/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 5 additions & 0 deletions packages/core/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -27,6 +30,8 @@ export const BUILTIN_TOOLS: ToolHandler[] = [
TodoWriteTool,
WebFetchTool,
WebSearchTool,
AskUserQuestionTool,
ExitPlanModeTool,
];

export class ToolRegistry {
Expand Down
Loading
Loading