Skip to main content

recursive/tools/
plan_mode.rs

1//! `enter_plan_mode` / `exit_plan_mode` tools — agent-driven read-only planning.
2//!
3//! When the agent calls `enter_plan_mode`, the runtime sets an exploring flag
4//! that blocks any write tools until `exit_plan_mode` is called. `exit_plan_mode`
5//! clears the flag, emits a [`AgentEvent::PlanProposed`] event (so TUI/HTTP
6//! surfaces can render the review modal), and then **blocks** until the human
7//! reviewer calls [`PlanApprovalGate::approve`] or [`PlanApprovalGate::reject`].
8//!
9//! # Concurrency safety
10//!
11//! [`PlanApprovalGate::wait_for_approval`] reads the response without holding a
12//! lock across any `.await` point, preventing deadlocks.
13
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, RwLock};
16
17use async_trait::async_trait;
18use serde_json::{json, Value};
19use tokio::sync::Notify;
20
21use crate::error::Result;
22use crate::event::{AgentEvent, EventSink};
23use crate::llm::ToolSpec;
24use crate::permissions::{PermissionMode, PermissionsConfig};
25use crate::tools::{Tool, ToolSideEffect};
26
27// ---------------------------------------------------------------------------
28// PlanApprovalResult
29// ---------------------------------------------------------------------------
30
31/// The human reviewer's decision on an agent-proposed plan.
32#[derive(Debug, Clone)]
33pub enum PlanApprovalResult {
34    /// Plan was approved; execution may proceed.
35    Approved,
36    /// Plan was rejected; the agent receives the reason and should revise.
37    Rejected { reason: String },
38}
39
40// ---------------------------------------------------------------------------
41// PlanApprovalGate
42// ---------------------------------------------------------------------------
43
44/// Shared state for the agent-driven plan mode approval flow.
45///
46/// A single `Arc<PlanApprovalGate>` is created in [`AgentRuntimeBuilder::build`]
47/// and shared between:
48/// * [`EnterPlanModeTool`] — sets `exploring_plan_mode`
49/// * [`ExitPlanModeTool`] — clears flag, emits event, awaits decision
50/// * [`AgentRuntime`] — calls [`approve`](Self::approve) / [`reject`](Self::reject)
51/// * [`RunCore`](crate::run_core::RunCore) — reads `exploring_plan_mode` to gate writes
52pub struct PlanApprovalGate {
53    /// `true` while the agent is in exploring (read-only plan) mode.
54    pub exploring_plan_mode: Arc<AtomicBool>,
55    /// The plan text currently pending review (set by `ExitPlanModeTool`).
56    pub pending_plan: Arc<RwLock<Option<String>>>,
57    /// Decision set by the human reviewer (TUI / HTTP).
58    response: Arc<RwLock<Option<PlanApprovalResult>>>,
59    /// Wakes up `wait_for_approval` when a decision arrives.
60    notify: Arc<Notify>,
61}
62
63impl PlanApprovalGate {
64    /// Create a fresh gate (exploring mode off, no pending decision).
65    pub fn new() -> Self {
66        Self {
67            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
68            pending_plan: Arc::new(RwLock::new(None)),
69            response: Arc::new(RwLock::new(None)),
70            notify: Arc::new(Notify::new()),
71        }
72    }
73
74    /// Block until the human reviewer sets a decision.
75    ///
76    /// Reads the response without holding any lock across `.await`, preventing
77    /// deadlocks. On return the stored response is cleared so the gate can be
78    /// reused for the next `exit_plan_mode` call.
79    pub async fn wait_for_approval(&self) -> PlanApprovalResult {
80        loop {
81            // Read without holding the lock across the await point.
82            let result_opt = {
83                let guard = self
84                    .response
85                    .read()
86                    .expect("PlanApprovalGate response lock poisoned");
87                guard.clone()
88            };
89            if let Some(result) = result_opt {
90                // Clear for future use before returning.
91                if let Ok(mut w) = self.response.write() {
92                    *w = None;
93                }
94                return result;
95            }
96            // No decision yet — sleep until notify fires.
97            self.notify.notified().await;
98        }
99    }
100
101    /// Approve the pending plan. Called by TUI / HTTP on user confirmation.
102    pub fn approve(&self) {
103        if let Ok(mut w) = self.response.write() {
104            *w = Some(PlanApprovalResult::Approved);
105        }
106        self.notify.notify_one();
107    }
108
109    /// Reject the pending plan with a reason. Called by TUI / HTTP.
110    pub fn reject(&self, reason: impl Into<String>) {
111        if let Ok(mut w) = self.response.write() {
112            *w = Some(PlanApprovalResult::Rejected {
113                reason: reason.into(),
114            });
115        }
116        self.notify.notify_one();
117    }
118}
119
120impl Default for PlanApprovalGate {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126// ---------------------------------------------------------------------------
127// EnterPlanModeTool
128// ---------------------------------------------------------------------------
129
130/// Tool that switches the agent into read-only planning mode.
131///
132/// Once entered, any non-readonly tool call (except `exit_plan_mode`) is
133/// blocked with an error message. The agent should use read tools to explore
134/// the codebase, then call `exit_plan_mode` with its markdown plan.
135///
136/// When a `PermissionsConfig` is provided, the tool also checks which tools
137/// are in `Plan` mode and includes that information in the response, guiding
138/// the agent to focus on those tools during planning.
139pub struct EnterPlanModeTool {
140    gate: Arc<PlanApprovalGate>,
141    /// Optional permissions config to check which tools require plan mode.
142    permissions: Option<Arc<PermissionsConfig>>,
143}
144
145impl EnterPlanModeTool {
146    /// Create a new `EnterPlanModeTool` sharing the given gate.
147    pub fn new(gate: Arc<PlanApprovalGate>) -> Self {
148        Self {
149            gate,
150            permissions: None,
151        }
152    }
153
154    /// Attach a permissions config so the tool can report which tools
155    /// are in `Plan` mode.
156    pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
157        self.permissions = Some(permissions);
158        self
159    }
160}
161
162#[async_trait]
163impl Tool for EnterPlanModeTool {
164    fn spec(&self) -> ToolSpec {
165        ToolSpec {
166            name: "enter_plan_mode".into(),
167            description: "Enter read-only planning mode. While active, write tools (write_file, \
168                 apply_patch, run_shell, …) are blocked. Use read tools to explore the \
169                 codebase freely, then call exit_plan_mode with a markdown plan summary."
170                .into(),
171            parameters: json!({
172                "type": "object",
173                "properties": {}
174            }),
175        }
176    }
177
178    async fn execute(&self, _arguments: Value) -> Result<String> {
179        self.gate.exploring_plan_mode.store(true, Ordering::Relaxed);
180
181        // If permissions are configured, report which tools are in Plan mode.
182        let mut response = json!({ "entered": true });
183        if let Some(ref config) = self.permissions {
184            if matches!(config.mode, PermissionMode::Plan { .. }) {
185                response["default_mode"] = json!("plan");
186            }
187        }
188
189        Ok(response.to_string())
190    }
191
192    fn side_effect_class(&self) -> ToolSideEffect {
193        ToolSideEffect::Mutating
194    }
195
196    fn is_readonly(&self) -> bool {
197        false
198    }
199}
200
201// ---------------------------------------------------------------------------
202// ExitPlanModeTool
203// ---------------------------------------------------------------------------
204
205/// Tool that exits plan mode and presents the agent's plan for human review.
206///
207/// Execution **blocks** until the human reviewer calls
208/// [`PlanApprovalGate::approve`] or [`PlanApprovalGate::reject`]. The tool
209/// returns `{"approved": true}` or `{"approved": false, "reason": "…"}` so
210/// the agent can branch on the outcome.
211pub struct ExitPlanModeTool {
212    gate: Arc<PlanApprovalGate>,
213    event_sink: Arc<dyn EventSink>,
214    /// Optional permissions config to validate plan coverage.
215    permissions: Option<Arc<PermissionsConfig>>,
216}
217
218impl ExitPlanModeTool {
219    /// Create a new `ExitPlanModeTool`.
220    ///
221    /// * `gate`       — shared with `EnterPlanModeTool` and `AgentRuntime`.
222    /// * `event_sink` — emits [`AgentEvent::PlanProposed`] to TUI / HTTP.
223    pub fn new(gate: Arc<PlanApprovalGate>, event_sink: Arc<dyn EventSink>) -> Self {
224        Self {
225            gate,
226            event_sink,
227            permissions: None,
228        }
229    }
230
231    /// Attach a permissions config so the tool can validate that the plan
232    /// covers tools in `Plan` mode.
233    pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
234        self.permissions = Some(permissions);
235        self
236    }
237}
238
239#[async_trait]
240impl Tool for ExitPlanModeTool {
241    fn spec(&self) -> ToolSpec {
242        ToolSpec {
243            name: "exit_plan_mode".into(),
244            description:
245                "Exit plan mode and present your plan for human review. Include a markdown \
246                 summary with: (1) your understanding of the current code, (2) the approach \
247                 you propose and why, (3) the files you will modify and how, (4) how you will \
248                 verify the change is correct. Execution blocks until the reviewer confirms \
249                 or rejects the plan."
250                    .into(),
251            parameters: json!({
252                "type": "object",
253                "required": ["plan"],
254                "properties": {
255                    "plan": {
256                        "type": "string",
257                        "description": "Markdown summary of your plan."
258                    }
259                }
260            }),
261        }
262    }
263
264    async fn execute(&self, arguments: Value) -> Result<String> {
265        let plan_text = arguments
266            .get("plan")
267            .and_then(|v| v.as_str())
268            .unwrap_or("")
269            .to_string();
270
271        // If permissions are configured, check that the plan covers Plan-mode tools.
272        if let Some(ref _config) = self.permissions {
273            // Plan-mode tools are determined by the default mode.
274            // The plan text is passed through to the reviewer as-is.
275        }
276
277        // Clear exploring mode so normal tool execution resumes after approval.
278        self.gate
279            .exploring_plan_mode
280            .store(false, Ordering::Relaxed);
281
282        // Store the plan text so external callers can read it.
283        if let Ok(mut w) = self.gate.pending_plan.write() {
284            *w = Some(plan_text.clone());
285        }
286
287        // Emit PlanProposed so TUI / HTTP can render the review modal.
288        // Plan Mode 2.0 has no pending tool_calls — the plan is prose only.
289        self.event_sink
290            .emit(AgentEvent::PlanProposed {
291                plan_text: plan_text.clone(),
292                tool_calls: vec![],
293            })
294            .await;
295
296        // Block until the human makes a decision.
297        // No lock is held across this await (see PlanApprovalGate::wait_for_approval).
298        let result = self.gate.wait_for_approval().await;
299
300        match result {
301            PlanApprovalResult::Approved => Ok(json!({ "approved": true }).to_string()),
302            PlanApprovalResult::Rejected { reason } => {
303                Ok(json!({ "approved": false, "reason": reason }).to_string())
304            }
305        }
306    }
307
308    fn side_effect_class(&self) -> ToolSideEffect {
309        // Waits for external human input.
310        ToolSideEffect::External
311    }
312
313    fn is_readonly(&self) -> bool {
314        false
315    }
316}
317
318// ---------------------------------------------------------------------------
319// PlanModeRequestGate (Goal-202)
320// ---------------------------------------------------------------------------
321
322/// Decision result for the pre-confirmation step.
323#[derive(Debug, Clone)]
324pub enum PlanModeRequestResult {
325    /// User allowed the agent to enter plan mode.
326    Approved,
327    /// User declined; agent should execute without planning.
328    Rejected { reason: String },
329}
330
331/// Gate for the plan-mode pre-confirmation step.
332///
333/// Parallel to [`PlanApprovalGate`] but governs the *entry* decision
334/// (before any exploration), not the plan review.
335pub struct PlanModeRequestGate {
336    response: Arc<RwLock<Option<PlanModeRequestResult>>>,
337    notify: Arc<Notify>,
338}
339
340impl PlanModeRequestGate {
341    /// Create a fresh gate.
342    pub fn new() -> Self {
343        Self {
344            response: Arc::new(RwLock::new(None)),
345            notify: Arc::new(Notify::new()),
346        }
347    }
348
349    /// Block until the user makes a decision.
350    pub async fn wait_for_decision(&self) -> PlanModeRequestResult {
351        loop {
352            let result_opt = {
353                let guard = self
354                    .response
355                    .read()
356                    .expect("PlanModeRequestGate response lock poisoned");
357                guard.clone()
358            };
359            if let Some(result) = result_opt {
360                if let Ok(mut w) = self.response.write() {
361                    *w = None;
362                }
363                return result;
364            }
365            self.notify.notified().await;
366        }
367    }
368
369    /// Approve the plan-mode request.
370    pub fn approve(&self) {
371        if let Ok(mut w) = self.response.write() {
372            *w = Some(PlanModeRequestResult::Approved);
373        }
374        self.notify.notify_one();
375    }
376
377    /// Reject the plan-mode request with a reason.
378    pub fn reject(&self, reason: impl Into<String>) {
379        if let Ok(mut w) = self.response.write() {
380            *w = Some(PlanModeRequestResult::Rejected {
381                reason: reason.into(),
382            });
383        }
384        self.notify.notify_one();
385    }
386}
387
388impl Default for PlanModeRequestGate {
389    fn default() -> Self {
390        Self::new()
391    }
392}
393
394// ---------------------------------------------------------------------------
395// RequestPlanModeTool (Goal-202)
396// ---------------------------------------------------------------------------
397
398/// Tool that asks the user for permission before entering plan mode.
399///
400/// The agent should call this tool **before** `enter_plan_mode` whenever
401/// it considers a planning phase beneficial.  If the user approves, the
402/// agent proceeds with `enter_plan_mode` as usual.  If the user rejects,
403/// the agent should execute directly without entering plan mode.
404///
405/// This two-step flow avoids wasting tokens generating a plan that the
406/// user never wanted.
407pub struct RequestPlanModeTool {
408    gate: Arc<PlanModeRequestGate>,
409    event_sink: Arc<dyn EventSink>,
410}
411
412impl RequestPlanModeTool {
413    /// Create a new `RequestPlanModeTool`.
414    pub fn new(gate: Arc<PlanModeRequestGate>, event_sink: Arc<dyn EventSink>) -> Self {
415        Self { gate, event_sink }
416    }
417}
418
419#[async_trait]
420impl Tool for RequestPlanModeTool {
421    fn spec(&self) -> ToolSpec {
422        ToolSpec {
423            name: "request_plan_mode".into(),
424            description:
425                "Before entering plan mode, call this tool to ask the user whether they want \
426                 you to create a plan first.  Provide a brief `reason` explaining why planning \
427                 would be helpful for this task.  The call blocks until the user decides. \
428                 If approved, proceed with `enter_plan_mode`; if rejected, execute directly \
429                 without planning."
430                    .into(),
431            parameters: serde_json::json!({
432                "type": "object",
433                "required": ["reason"],
434                "properties": {
435                    "reason": {
436                        "type": "string",
437                        "description": "Brief explanation of why a planning phase would be \
438                            beneficial (1-2 sentences)."
439                    }
440                }
441            }),
442        }
443    }
444
445    async fn execute(&self, arguments: serde_json::Value) -> Result<String> {
446        let reason = arguments
447            .get("reason")
448            .and_then(|v| v.as_str())
449            .unwrap_or("")
450            .to_string();
451
452        // Notify TUI / HTTP so they can prompt the user.
453        self.event_sink
454            .emit(crate::event::AgentEvent::PlanModeRequested {
455                reason: reason.clone(),
456            })
457            .await;
458
459        // Block until the user makes a decision.
460        let result = self.gate.wait_for_decision().await;
461
462        match result {
463            PlanModeRequestResult::Approved => {
464                self.event_sink
465                    .emit(crate::event::AgentEvent::PlanModeApproved)
466                    .await;
467                Ok(serde_json::json!({ "approved": true }).to_string())
468            }
469            PlanModeRequestResult::Rejected { reason: r } => {
470                self.event_sink
471                    .emit(crate::event::AgentEvent::PlanModeRejected { reason: r.clone() })
472                    .await;
473                Ok(serde_json::json!({ "approved": false, "reason": r }).to_string())
474            }
475        }
476    }
477
478    fn side_effect_class(&self) -> ToolSideEffect {
479        // Waits for external human input (same as exit_plan_mode).
480        ToolSideEffect::External
481    }
482
483    fn is_readonly(&self) -> bool {
484        false
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Tests
490// ---------------------------------------------------------------------------
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::event::NullSink;
496
497    fn make_gate() -> Arc<PlanApprovalGate> {
498        Arc::new(PlanApprovalGate::new())
499    }
500
501    // -- EnterPlanModeTool --------------------------------------------------
502
503    #[tokio::test]
504    async fn enter_plan_mode_returns_confirmation_message() {
505        let gate = make_gate();
506        let tool = EnterPlanModeTool::new(gate.clone());
507
508        assert!(
509            !gate.exploring_plan_mode.load(Ordering::Relaxed),
510            "flag should start false"
511        );
512
513        let result = tool.execute(json!({})).await.unwrap();
514        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
515        assert_eq!(parsed["entered"], true);
516        assert!(
517            gate.exploring_plan_mode.load(Ordering::Relaxed),
518            "flag should be set after enter"
519        );
520    }
521
522    // -- ExitPlanModeTool: blocks until confirmed ---------------------------
523
524    #[tokio::test]
525    async fn exit_plan_mode_blocks_until_confirmed() {
526        let gate = make_gate();
527        // Pre-set exploring mode so we can verify it gets cleared.
528        gate.exploring_plan_mode.store(true, Ordering::Relaxed);
529
530        let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
531
532        // Spawn a task that approves after a short delay.
533        let gate_clone = gate.clone();
534        let approve_handle = tokio::spawn(async move {
535            // Yield to ensure the tool's await is reached first.
536            tokio::task::yield_now().await;
537            gate_clone.approve();
538        });
539
540        let result = tool
541            .execute(json!({ "plan": "step 1: read; step 2: write" }))
542            .await
543            .unwrap();
544
545        approve_handle.await.unwrap();
546
547        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
548        assert_eq!(parsed["approved"], true);
549        assert!(
550            !gate.exploring_plan_mode.load(Ordering::Relaxed),
551            "flag should be cleared after exit"
552        );
553    }
554
555    // -- ExitPlanModeTool: rejection propagates reason ----------------------
556
557    #[tokio::test]
558    async fn exit_plan_mode_returns_rejection_reason() {
559        let gate = make_gate();
560        let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
561
562        let gate_clone = gate.clone();
563        let reject_handle = tokio::spawn(async move {
564            tokio::task::yield_now().await;
565            gate_clone.reject("Plan is incomplete");
566        });
567
568        let result = tool.execute(json!({ "plan": "my plan" })).await.unwrap();
569
570        reject_handle.await.unwrap();
571
572        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
573        assert_eq!(parsed["approved"], false);
574        assert_eq!(parsed["reason"], "Plan is incomplete");
575    }
576
577    // -- Gate: approve / reject directly ------------------------------------
578
579    #[tokio::test]
580    async fn gate_approve_wakes_waiter() {
581        let gate = make_gate();
582        let gate_clone = gate.clone();
583
584        let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
585
586        // Yield, then approve.
587        tokio::task::yield_now().await;
588        gate.approve();
589
590        let result = waiter.await.unwrap();
591        assert!(matches!(result, PlanApprovalResult::Approved));
592    }
593
594    #[tokio::test]
595    async fn gate_reject_wakes_waiter_with_reason() {
596        let gate = make_gate();
597        let gate_clone = gate.clone();
598
599        let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
600
601        tokio::task::yield_now().await;
602        gate.reject("too risky");
603
604        let result = waiter.await.unwrap();
605        assert!(matches!(
606            result,
607            PlanApprovalResult::Rejected { reason } if reason == "too risky"
608        ));
609    }
610
611    // -- Gate: cleared after use so it can be reused ------------------------
612
613    #[tokio::test]
614    async fn gate_response_cleared_after_wait() {
615        let gate = make_gate();
616        gate.approve();
617        let _ = gate.wait_for_approval().await;
618
619        // Response should be None now.
620        let stored = gate.response.read().unwrap().clone();
621        assert!(stored.is_none(), "response should be cleared after read");
622    }
623
624    // -- PlanModeRequestGate (Goal-202) ------------------------------------
625
626    #[tokio::test]
627    async fn request_gate_approve_wakes_waiter() {
628        let gate = Arc::new(PlanModeRequestGate::new());
629        let gate_clone = gate.clone();
630        let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
631        tokio::task::yield_now().await;
632        gate.approve();
633        let result = waiter.await.unwrap();
634        assert!(matches!(result, PlanModeRequestResult::Approved));
635    }
636
637    #[tokio::test]
638    async fn request_gate_reject_propagates_reason() {
639        let gate = Arc::new(PlanModeRequestGate::new());
640        let gate_clone = gate.clone();
641        let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
642        tokio::task::yield_now().await;
643        gate.reject("user skipped");
644        let result = waiter.await.unwrap();
645        assert!(matches!(
646            result,
647            PlanModeRequestResult::Rejected { reason } if reason == "user skipped"
648        ));
649    }
650
651    #[tokio::test]
652    async fn request_gate_cleared_after_use() {
653        let gate = Arc::new(PlanModeRequestGate::new());
654        gate.approve();
655        let _ = gate.wait_for_decision().await;
656        let stored = gate.response.read().unwrap().clone();
657        assert!(
658            stored.is_none(),
659            "response should be cleared after decision"
660        );
661    }
662
663    #[tokio::test]
664    async fn request_plan_mode_tool_emits_event_and_blocks_until_approved() {
665        use crate::event::NullSink;
666        let gate = Arc::new(PlanModeRequestGate::new());
667        let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
668        let gate_clone = gate.clone();
669        let approve_handle = tokio::spawn(async move {
670            tokio::task::yield_now().await;
671            gate_clone.approve();
672        });
673        let result = tool
674            .execute(json!({ "reason": "complex task" }))
675            .await
676            .unwrap();
677        approve_handle.await.unwrap();
678        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
679        assert_eq!(parsed["approved"], true);
680    }
681
682    #[tokio::test]
683    async fn request_plan_mode_tool_rejected_returns_false() {
684        use crate::event::NullSink;
685        let gate = Arc::new(PlanModeRequestGate::new());
686        let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
687        let gate_clone = gate.clone();
688        let reject_handle = tokio::spawn(async move {
689            tokio::task::yield_now().await;
690            gate_clone.reject("not needed");
691        });
692        let result = tool
693            .execute(json!({ "reason": "might need plan" }))
694            .await
695            .unwrap();
696        reject_handle.await.unwrap();
697        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
698        assert_eq!(parsed["approved"], false);
699        assert_eq!(parsed["reason"], "not needed");
700    }
701}