Skip to content

Commit 0a3c72d

Browse files
authored
feat: add plan mode with enter/exit tools (anomalyco#8281)
1 parent 66b7a49 commit 0a3c72d

File tree

16 files changed

+824
-46
lines changed

16 files changed

+824
-46
lines changed
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
# Plan: Implement enter_plan and exit_plan Tools
2+
3+
## Summary
4+
5+
The plan mode workflow in `prompt.ts` references `exit_plan` tool that doesn't exist. We need to implement two tools:
6+
7+
1. **`exit_plan`** - Called when the AI finishes planning; uses the Question module to ask the user if they want to switch to build mode (yes/no). **Only available in plan mode.** If user says yes, creates a synthetic user message with the "build" agent to trigger the mode switch in the loop.
8+
2. **`enter_plan`** - Called to enter plan mode. **Only available in build mode.** If user says yes, creates a synthetic user message with the "plan" agent.
9+
10+
## Key Insight: How Mode Switching Works
11+
12+
Looking at `prompt.ts:455-478`, the session loop determines the current agent from the last user message's `agent` field (line 510: `const agent = await Agent.get(lastUser.agent)`).
13+
14+
To switch modes, we need to:
15+
16+
1. Ask the user for confirmation
17+
2. If confirmed, create a synthetic user message with the **new agent** specified
18+
3. The loop will pick up this new user message and use the new agent
19+
20+
## Files to Modify
21+
22+
| File | Action |
23+
| ------------------------------------------ | --------------------------------------------------------------- |
24+
| `packages/opencode/src/tool/plan.ts` | **CREATE** - New file with both tools |
25+
| `packages/opencode/src/tool/exitplan.txt` | **CREATE** - Description for exit_plan tool |
26+
| `packages/opencode/src/tool/enterplan.txt` | **CREATE** - Description for enter_plan tool |
27+
| `packages/opencode/src/tool/registry.ts` | **MODIFY** - Register the new tools |
28+
| `packages/opencode/src/agent/agent.ts` | **MODIFY** - Add permission rules to restrict tool availability |
29+
30+
## Implementation Details
31+
32+
### 1. Create `packages/opencode/src/tool/plan.ts`
33+
34+
```typescript
35+
import z from "zod"
36+
import { Tool } from "./tool"
37+
import { Question } from "../question"
38+
import { Session } from "../session"
39+
import { MessageV2 } from "../session/message-v2"
40+
import { Identifier } from "../id/id"
41+
import { Provider } from "../provider/provider"
42+
import EXIT_DESCRIPTION from "./exitplan.txt"
43+
import ENTER_DESCRIPTION from "./enterplan.txt"
44+
45+
export const ExitPlanTool = Tool.define("exit_plan", {
46+
description: EXIT_DESCRIPTION,
47+
parameters: z.object({}),
48+
async execute(_params, ctx) {
49+
const answers = await Question.ask({
50+
sessionID: ctx.sessionID,
51+
questions: [
52+
{
53+
question: "Planning is complete. Would you like to switch to build mode and start implementing?",
54+
header: "Build Mode",
55+
options: [
56+
{ label: "Yes", description: "Switch to build mode and start implementing the plan" },
57+
{ label: "No", description: "Stay in plan mode to continue refining the plan" },
58+
],
59+
},
60+
],
61+
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
62+
})
63+
64+
const answer = answers[0]?.[0]
65+
const shouldSwitch = answer === "Yes"
66+
67+
// If user wants to switch, create a synthetic user message with the new agent
68+
if (shouldSwitch) {
69+
// Get model from the last user message in the session
70+
const model = await getLastModel(ctx.sessionID)
71+
72+
const userMsg: MessageV2.User = {
73+
id: Identifier.ascending("message"),
74+
sessionID: ctx.sessionID,
75+
role: "user",
76+
time: {
77+
created: Date.now(),
78+
},
79+
agent: "build", // Switch to build agent
80+
model,
81+
}
82+
await Session.updateMessage(userMsg)
83+
await Session.updatePart({
84+
id: Identifier.ascending("part"),
85+
messageID: userMsg.id,
86+
sessionID: ctx.sessionID,
87+
type: "text",
88+
text: "User has approved the plan. Switch to build mode and begin implementing the plan.",
89+
synthetic: true,
90+
} satisfies MessageV2.TextPart)
91+
}
92+
93+
return {
94+
title: shouldSwitch ? "Switching to build mode" : "Staying in plan mode",
95+
output: shouldSwitch
96+
? "User confirmed to switch to build mode. A new message has been created to switch you to build mode. Begin implementing the plan."
97+
: "User chose to stay in plan mode. Continue refining the plan or address any concerns.",
98+
metadata: {
99+
switchToBuild: shouldSwitch,
100+
answer,
101+
},
102+
}
103+
},
104+
})
105+
106+
export const EnterPlanTool = Tool.define("enter_plan", {
107+
description: ENTER_DESCRIPTION,
108+
parameters: z.object({}),
109+
async execute(_params, ctx) {
110+
const answers = await Question.ask({
111+
sessionID: ctx.sessionID,
112+
questions: [
113+
{
114+
question:
115+
"Would you like to switch to plan mode? In plan mode, the AI will only research and create a plan without making changes.",
116+
header: "Plan Mode",
117+
options: [
118+
{ label: "Yes", description: "Switch to plan mode for research and planning" },
119+
{ label: "No", description: "Stay in build mode to continue making changes" },
120+
],
121+
},
122+
],
123+
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
124+
})
125+
126+
const answer = answers[0]?.[0]
127+
const shouldSwitch = answer === "Yes"
128+
129+
// If user wants to switch, create a synthetic user message with the new agent
130+
if (shouldSwitch) {
131+
const model = await getLastModel(ctx.sessionID)
132+
133+
const userMsg: MessageV2.User = {
134+
id: Identifier.ascending("message"),
135+
sessionID: ctx.sessionID,
136+
role: "user",
137+
time: {
138+
created: Date.now(),
139+
},
140+
agent: "plan", // Switch to plan agent
141+
model,
142+
}
143+
await Session.updateMessage(userMsg)
144+
await Session.updatePart({
145+
id: Identifier.ascending("part"),
146+
messageID: userMsg.id,
147+
sessionID: ctx.sessionID,
148+
type: "text",
149+
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
150+
synthetic: true,
151+
} satisfies MessageV2.TextPart)
152+
}
153+
154+
return {
155+
title: shouldSwitch ? "Switching to plan mode" : "Staying in build mode",
156+
output: shouldSwitch
157+
? "User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. Begin planning."
158+
: "User chose to stay in build mode. Continue with the current task.",
159+
metadata: {
160+
switchToPlan: shouldSwitch,
161+
answer,
162+
},
163+
}
164+
},
165+
})
166+
167+
// Helper to get the model from the last user message
168+
async function getLastModel(sessionID: string) {
169+
for await (const item of MessageV2.stream(sessionID)) {
170+
if (item.info.role === "user" && item.info.model) return item.info.model
171+
}
172+
return Provider.defaultModel()
173+
}
174+
```
175+
176+
### 2. Create `packages/opencode/src/tool/exitplan.txt`
177+
178+
```
179+
Use this tool when you have completed the planning phase and are ready to exit plan mode.
180+
181+
This tool will ask the user if they want to switch to build mode to start implementing the plan.
182+
183+
Call this tool:
184+
- After you have written a complete plan to the plan file
185+
- After you have clarified any questions with the user
186+
- When you are confident the plan is ready for implementation
187+
188+
Do NOT call this tool:
189+
- Before you have created or finalized the plan
190+
- If you still have unanswered questions about the implementation
191+
- If the user has indicated they want to continue planning
192+
```
193+
194+
### 3. Create `packages/opencode/src/tool/enterplan.txt`
195+
196+
```
197+
Use this tool to suggest entering plan mode when the user's request would benefit from planning before implementation.
198+
199+
This tool will ask the user if they want to switch to plan mode.
200+
201+
Call this tool when:
202+
- The user's request is complex and would benefit from planning first
203+
- You want to research and design before making changes
204+
- The task involves multiple files or significant architectural decisions
205+
206+
Do NOT call this tool:
207+
- For simple, straightforward tasks
208+
- When the user explicitly wants immediate implementation
209+
- When already in plan mode
210+
```
211+
212+
### 4. Modify `packages/opencode/src/tool/registry.ts`
213+
214+
Add import and register tools:
215+
216+
```typescript
217+
// Add import at top (around line 27)
218+
import { ExitPlanTool, EnterPlanTool } from "./plan"
219+
220+
// Add to the all() function return array (around line 110-112)
221+
return [
222+
// ... existing tools
223+
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
224+
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
225+
ExitPlanTool,
226+
EnterPlanTool,
227+
...custom,
228+
]
229+
```
230+
231+
### 5. Modify `packages/opencode/src/agent/agent.ts`
232+
233+
Add permission rules to control which agent can use which tool:
234+
235+
**In the `defaults` ruleset (around line 47-63):**
236+
237+
```typescript
238+
const defaults = PermissionNext.fromConfig({
239+
"*": "allow",
240+
doom_loop: "ask",
241+
// Add these new defaults - both denied by default
242+
exit_plan: "deny",
243+
enter_plan: "deny",
244+
external_directory: {
245+
// ... existing
246+
},
247+
// ... rest of existing defaults
248+
})
249+
```
250+
251+
**In the `build` agent (around line 67-79):**
252+
253+
```typescript
254+
build: {
255+
name: "build",
256+
options: {},
257+
permission: PermissionNext.merge(
258+
defaults,
259+
PermissionNext.fromConfig({
260+
question: "allow",
261+
enter_plan: "allow", // Allow build agent to suggest plan mode
262+
}),
263+
user,
264+
),
265+
mode: "primary",
266+
native: true,
267+
},
268+
```
269+
270+
**In the `plan` agent (around line 80-96):**
271+
272+
```typescript
273+
plan: {
274+
name: "plan",
275+
options: {},
276+
permission: PermissionNext.merge(
277+
defaults,
278+
PermissionNext.fromConfig({
279+
question: "allow",
280+
exit_plan: "allow", // Allow plan agent to exit plan mode
281+
edit: {
282+
"*": "deny",
283+
".opencode/plans/*.md": "allow",
284+
},
285+
}),
286+
user,
287+
),
288+
mode: "primary",
289+
native: true,
290+
},
291+
```
292+
293+
## Design Decisions
294+
295+
1. **Synthetic user message for mode switching**: When the user confirms a mode switch, a synthetic user message is created with the new agent specified. The loop picks this up on the next iteration and switches to the new agent. This follows the existing pattern in `prompt.ts:455-478`.
296+
297+
2. **Permission-based tool availability**: Uses the existing permission system to control which tools are available to which agents. `exit_plan` is only available in plan mode, `enter_plan` only in build mode.
298+
299+
3. **Question-based confirmation**: Both tools use the Question module for consistent UX.
300+
301+
4. **Model preservation**: The synthetic user message preserves the model from the previous user message.
302+
303+
## Verification
304+
305+
1. Run `bun dev` in `packages/opencode`
306+
2. Start a session in build mode
307+
- Verify `exit_plan` is NOT available (denied by permission)
308+
- Verify `enter_plan` IS available
309+
3. Call `enter_plan` in build mode
310+
- Verify the question prompt appears
311+
- Select "Yes" and verify:
312+
- A synthetic user message is created with `agent: "plan"`
313+
- The next assistant response is from the plan agent
314+
- The plan mode system reminder appears
315+
4. In plan mode, call `exit_plan`
316+
- Verify the question prompt appears
317+
- Select "Yes" and verify:
318+
- A synthetic user message is created with `agent: "build"`
319+
- The next assistant response is from the build agent
320+
5. Test "No" responses - verify no mode switch occurs

packages/opencode/src/agent/agent.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export namespace Agent {
5353
[Truncate.GLOB]: "allow",
5454
},
5555
question: "deny",
56+
plan_enter: "deny",
57+
plan_exit: "deny",
5658
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
5759
read: {
5860
"*": "allow",
@@ -71,6 +73,7 @@ export namespace Agent {
7173
defaults,
7274
PermissionNext.fromConfig({
7375
question: "allow",
76+
plan_enter: "allow",
7477
}),
7578
user,
7679
),
@@ -84,9 +87,10 @@ export namespace Agent {
8487
defaults,
8588
PermissionNext.fromConfig({
8689
question: "allow",
90+
plan_exit: "allow",
8791
edit: {
8892
"*": "deny",
89-
".opencode/plan/*.md": "allow",
93+
".opencode/plans/*.md": "allow",
9094
},
9195
}),
9296
user,

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,23 @@ export function Session() {
196196
}
197197
})
198198

199+
let lastSwitch: string | undefined = undefined
200+
sdk.event.on("message.part.updated", (evt) => {
201+
const part = evt.properties.part
202+
if (part.type !== "tool") return
203+
if (part.sessionID !== route.sessionID) return
204+
if (part.state.status !== "completed") return
205+
if (part.id === lastSwitch) return
206+
207+
if (part.tool === "plan_exit") {
208+
local.agent.set("build")
209+
lastSwitch = part.id
210+
} else if (part.tool === "plan_enter") {
211+
local.agent.set("plan")
212+
lastSwitch = part.id
213+
}
214+
})
215+
199216
let scroll: ScrollBoxRenderable
200217
let prompt: PromptRef
201218
const keybind = useKeybind()

0 commit comments

Comments
 (0)