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
5 changes: 5 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
ctx.mode = 'default';
output.write('\n ▶ Exited plan mode (agent will now execute).\n');
}
// Honor EnterPlanMode tool signal — flip into plan mode (writes blocked).
if (result.modeSignal?.enterPlanMode && ctx.mode !== 'plan') {
ctx.mode = 'plan';
output.write('\n ◐ Entered plan mode (write tools blocked until you exit).\n');
}
output.write('\n');
if (result.stopReason === 'error') {
output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n');
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface RunAgentResult {
/** 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 };
modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean };
}

const DEFAULT_MAX_TURNS = 16;
Expand Down Expand Up @@ -155,9 +155,9 @@ 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 } = {};
// modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent
// loop owner reads them after the run to switch mode (default ⇄ plan).
const modeSignal: { exitPlanMode?: boolean; enterPlanMode?: boolean } = {};
const toolCtx: ToolContext = {
cwd: opts.cwd,
signal: opts.signal,
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/tools/enter-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { EnterPlanModeTool } from './enter-plan.js';

describe('EnterPlanModeTool', () => {
it('flips modeSignal.enterPlanMode and echoes the reason', async () => {
const signal: { enterPlanMode?: boolean } = {};
const r = await EnterPlanModeTool.execute(
{ reason: 'the refactor touches many files' },
{ cwd: '/x', modeSignal: signal },
);
expect(r.isError).toBeFalsy();
expect(signal.enterPlanMode).toBe(true);
expect(r.content).toContain('the refactor touches many files');
expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true);
});

it('still succeeds when no modeSignal is passed (best-effort)', async () => {
const r = await EnterPlanModeTool.execute({}, { cwd: '/x' });
expect(r.isError).toBeFalsy();
expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true);
});

it('uses a generic message when no reason is given', async () => {
const r = await EnterPlanModeTool.execute({}, { cwd: '/x' });
expect(r.content).toMatch(/Entering plan mode/);
});
});
41 changes: 41 additions & 0 deletions packages/core/src/tools/enter-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// EnterPlanMode tool — signals the host that the agent wants to STOP executing
// and switch into read-only "plan" mode (present a plan before touching files).
// Mirror of ExitPlanMode. The agent-loop owner reads modeSignal.enterPlanMode
// after the run and switches the active mode default → plan.
// Spec: docs/DEVELOPMENT_PLAN.md §3.8 / §0.1 (parity tool)

import type { ToolContext, ToolHandler, ToolResult } from '../types.js';

interface EnterInput {
reason?: string;
}

export const EnterPlanModeTool: ToolHandler = {
name: 'EnterPlanMode',
definition: {
name: 'EnterPlanMode',
description:
'Switch into plan mode: stop making changes and instead research + present a plan for approval before executing. Use when a task is ambiguous or risky enough that the user should review the approach first. Write/Edit/Bash become blocked until the user leaves plan mode (or you call ExitPlanMode). Pass `reason` to explain why planning first.',
inputSchema: {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Why planning first is warranted (shown to the user).',
},
},
required: [],
},
},
async execute(rawInput: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const input = rawInput as unknown as EnterInput;
if (ctx.modeSignal) ctx.modeSignal.enterPlanMode = true;
const reason = input?.reason?.trim() ?? '';
return {
content: reason
? `Entering plan mode — ${reason}. I'll research and present a plan before making changes.`
: "Entering plan mode — I'll research and present a plan before making changes.",
data: { enterPlanMode: true, reason },
};
},
};
4 changes: 3 additions & 1 deletion packages/core/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ToolHandler } from '../types.js';
import { AskUserQuestionTool } from './ask-user.js';
import { BashTool } from './bash.js';
import { EditTool } from './edit.js';
import { EnterPlanModeTool } from './enter-plan.js';
import { ExitPlanModeTool } from './exit-plan.js';
import { GlobTool } from './glob.js';
import { GrepTool } from './grep.js';
Expand All @@ -18,7 +19,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)
* · 3 agent-control tools (AskUserQuestion/EnterPlanMode/ExitPlanMode)
*/
export const BUILTIN_TOOLS: ToolHandler[] = [
ReadTool,
Expand All @@ -31,6 +32,7 @@ export const BUILTIN_TOOLS: ToolHandler[] = [
WebFetchTool,
WebSearchTool,
AskUserQuestionTool,
EnterPlanModeTool,
ExitPlanModeTool,
];

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ export interface ToolContext {
multiSelect?: boolean;
}) => Promise<string>;
/**
* Mutable host state that the ExitPlanMode tool flips. The agent loop reads
* this between turns and changes its mode accordingly.
* Mutable host state that the EnterPlanMode / ExitPlanMode tools flip. The
* agent-loop owner reads this after the run and changes the active mode
* accordingly (plan ⇄ default).
*/
modeSignal?: { exitPlanMode?: boolean };
modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean };
}

export interface ToolResult {
Expand Down
Loading