supercode_harness/tools/
plan_mode.rs1use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::Mutex;
36
37use async_trait::async_trait;
38use serde::Deserialize;
39use serde_json::{json, Value};
40
41use crate::error::{Error, Result};
42use crate::tools::{Tool, ToolContext};
43
44pub const ENTER_PLAN_MODE: &str = "enter_plan_mode";
46
47pub const EXIT_PLAN_MODE: &str = "exit_plan_mode";
49
50#[derive(Debug, Default)]
57pub struct PlanModeState {
58 active: AtomicBool,
59 plan: Mutex<Vec<String>>,
60}
61
62impl PlanModeState {
63 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn is_active(&self) -> bool {
70 self.active.load(Ordering::SeqCst)
71 }
72
73 pub fn enter(&self, note: Option<&str>) -> bool {
76 let was = self.active.swap(true, Ordering::SeqCst);
77 if let Some(note) = note {
78 self.append(note);
79 }
80 !was
81 }
82
83 pub fn append(&self, text: &str) {
85 let text = text.trim();
86 if text.is_empty() {
87 return;
88 }
89 if let Ok(mut plan) = self.plan.lock() {
90 plan.push(text.to_string());
91 }
92 }
93
94 pub fn plan(&self) -> String {
96 self.plan.lock().map(|p| p.join("\n\n")).unwrap_or_default()
97 }
98
99 pub fn exit(&self) -> String {
101 self.active.store(false, Ordering::SeqCst);
102 let plan = self.plan();
103 if let Ok(mut p) = self.plan.lock() {
104 p.clear();
105 }
106 plan
107 }
108}
109
110pub fn deny_rules(state: &PlanModeState) -> Vec<String> {
133 if !state.is_active() {
134 return Vec::new();
135 }
136 [
137 "write(*)",
138 "write_file",
139 "edit_file",
140 "apply_patch",
141 "bash",
142 "shell",
143 "background_exec",
144 "image_gen",
145 "new_context",
146 ]
147 .iter()
148 .map(|s| (*s).to_string())
149 .collect()
150}
151
152#[derive(Debug, Default, Deserialize)]
153struct EnterArgs {
154 #[serde(default)]
156 plan: Option<String>,
157}
158
159#[derive(Debug, Default)]
161pub struct EnterPlanModeTool;
162
163#[async_trait]
164impl Tool for EnterPlanModeTool {
165 fn name(&self) -> &str {
166 ENTER_PLAN_MODE
167 }
168 fn description(&self) -> &str {
169 "Enter plan mode: a read-only research phase. While it is active every write and \
170 execution tool is refused by the permissions engine, and anything you pass to this \
171 tool (or to further calls) accumulates as the plan. Call exit_plan_mode with the \
172 finished plan to ask the user to approve it and leave the mode."
173 }
174 fn parameters(&self) -> Value {
175 json!({
176 "type": "object",
177 "properties": {
178 "plan": {
179 "type": "string",
180 "description": "Optional opening note or draft plan to record."
181 }
182 },
183 "additionalProperties": false
184 })
185 }
186 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
187 let a: EnterArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
188 tool: self.name().to_string(),
189 message: e.to_string(),
190 })?;
191 let fresh = ctx.plan_mode.enter(a.plan.as_deref());
192 Ok(if fresh {
193 "Plan mode is ON: write and execution tools are refused until the user approves a \
194 plan. Research with the read-only tools, then call exit_plan_mode with the plan."
195 .to_string()
196 } else {
197 "Plan mode was already on; the note was appended to the plan.".to_string()
198 })
199 }
200}
201
202#[derive(Debug, Deserialize)]
203struct ExitArgs {
204 plan: String,
206}
207
208#[derive(Debug, Default)]
211pub struct ExitPlanModeTool;
212
213const SUBJECT_BUDGET: usize = 400;
217
218#[async_trait]
219impl Tool for ExitPlanModeTool {
220 fn name(&self) -> &str {
221 EXIT_PLAN_MODE
222 }
223 fn description(&self) -> &str {
224 "Present the finished plan to the user and ask to leave plan mode. The user must \
225 approve; only then are write and execution tools re-enabled. A refusal keeps plan \
226 mode on so you can revise the plan."
227 }
228 fn parameters(&self) -> Value {
229 json!({
230 "type": "object",
231 "properties": {
232 "plan": {
233 "type": "string",
234 "description": "The complete plan the user is being asked to approve."
235 }
236 },
237 "required": ["plan"],
238 "additionalProperties": false
239 })
240 }
241 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242 let a: ExitArgs =
243 serde_json::from_value(args.clone()).map_err(|e| Error::InvalidArguments {
244 tool: self.name().to_string(),
245 message: e.to_string(),
246 })?;
247 if !ctx.plan_mode.is_active() {
248 return Err(Error::tool(
249 self.name(),
250 "plan mode is not active; there is nothing to exit",
251 ));
252 }
253 ctx.plan_mode.append(&a.plan);
254 let plan = ctx.plan_mode.plan();
255
256 let Some(handler) = ctx.approval_handler.as_ref() else {
262 return Err(Error::tool(
263 self.name(),
264 "no approval door is attached, so the plan cannot be approved; plan mode stays \
265 on (attach an interactive frontend, or leave plan mode from the REPL's /plan)",
266 ));
267 };
268 let mut subject: String = plan.chars().take(SUBJECT_BUDGET).collect();
269 if subject.chars().count() < plan.chars().count() {
270 subject.push('…');
271 }
272 let raw_args = json!({ "plan": plan });
273 let req = crate::permissions::ApprovalRequest {
274 tool: self.name(),
275 subject: Some(subject.as_str()),
276 raw_args: &raw_args,
277 };
278 let outcome = handler.ask(&req);
279 match outcome {
280 crate::permissions::ApprovalOutcome::Allow
281 | crate::permissions::ApprovalOutcome::AllowForSession => {
282 let approved = ctx.plan_mode.exit();
283 Ok(format!(
284 "The user APPROVED the plan. Plan mode is off; write and execution tools \
285 are available again.\n\nApproved plan:\n{approved}"
286 ))
287 }
288 crate::permissions::ApprovalOutcome::Deny => Ok(
289 "The user did NOT approve the plan. Plan mode stays on — revise the plan and \
290 call exit_plan_mode again."
291 .to_string(),
292 ),
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use std::sync::Arc;
301
302 #[test]
303 fn deny_rules_are_empty_until_the_mode_is_entered() {
304 let state = PlanModeState::new();
305 assert!(deny_rules(&state).is_empty());
306 state.enter(None);
307 let rules = deny_rules(&state);
308 assert!(rules.contains(&"write(*)".to_string()));
309 assert!(rules.contains(&"bash".to_string()));
310 assert!(!rules.contains(&EXIT_PLAN_MODE.to_string()));
311 assert!(!rules.contains(&"read_file".to_string()));
312 state.exit();
313 assert!(deny_rules(&state).is_empty());
314 }
315
316 #[test]
317 fn the_plan_accumulates_and_clears_on_exit() {
318 let state = PlanModeState::new();
319 state.enter(Some("first"));
320 state.append("second");
321 assert_eq!(state.plan(), "first\n\nsecond");
322 assert_eq!(state.exit(), "first\n\nsecond");
323 assert!(state.plan().is_empty());
324 assert!(!state.is_active());
325 }
326
327 #[tokio::test]
328 async fn exit_without_an_approval_door_keeps_the_mode_on() {
329 let ctx = ToolContext::new(std::env::temp_dir());
330 ctx.plan_mode.enter(None);
331 let err = ExitPlanModeTool
332 .execute(json!({"plan": "do the thing"}), &ctx)
333 .await
334 .expect_err("no handler must refuse");
335 assert!(err.to_string().contains("no approval door"), "{err}");
336 assert!(ctx.plan_mode.is_active());
337 }
338
339 #[tokio::test]
340 async fn enter_then_approved_exit_clears_the_restriction() {
341 struct Approve;
342 impl crate::permissions::PermissionsApprovalHandler for Approve {
343 fn ask(
344 &self,
345 _req: &crate::permissions::ApprovalRequest,
346 ) -> crate::permissions::ApprovalOutcome {
347 crate::permissions::ApprovalOutcome::Allow
348 }
349 }
350 let mut ctx = ToolContext::new(std::env::temp_dir());
351 ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Approve)));
352 EnterPlanModeTool
353 .execute(json!({"plan": "research first"}), &ctx)
354 .await
355 .unwrap();
356 assert!(ctx.plan_mode.is_active());
357 assert!(!deny_rules(&ctx.plan_mode).is_empty());
358 let out = ExitPlanModeTool
359 .execute(json!({"plan": "then build"}), &ctx)
360 .await
361 .unwrap();
362 assert!(out.contains("APPROVED"), "{out}");
363 assert!(!ctx.plan_mode.is_active());
364 assert!(deny_rules(&ctx.plan_mode).is_empty());
365 }
366
367 #[tokio::test]
368 async fn a_denied_exit_keeps_the_mode_and_the_plan() {
369 struct Refuse;
370 impl crate::permissions::PermissionsApprovalHandler for Refuse {
371 fn ask(
372 &self,
373 _req: &crate::permissions::ApprovalRequest,
374 ) -> crate::permissions::ApprovalOutcome {
375 crate::permissions::ApprovalOutcome::Deny
376 }
377 }
378 let mut ctx = ToolContext::new(std::env::temp_dir());
379 ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Refuse)));
380 ctx.plan_mode.enter(None);
381 let out = ExitPlanModeTool
382 .execute(json!({"plan": "ship it"}), &ctx)
383 .await
384 .unwrap();
385 assert!(out.contains("did NOT approve"), "{out}");
386 assert!(ctx.plan_mode.is_active());
387 assert_eq!(ctx.plan_mode.plan(), "ship it");
388 }
389}