1use 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
27pub const ENTER_PLAN_MODE_TOOL_NAME: &str = "enter_plan_mode";
36
37pub const EXIT_PLAN_MODE_TOOL_NAME: &str = "exit_plan_mode";
42
43#[derive(Debug, Clone)]
49pub enum PlanApprovalResult {
50 Approved,
52 Rejected { reason: String },
54}
55
56#[derive(Debug)]
69pub struct PlanApprovalGate {
70 pub exploring_plan_mode: Arc<AtomicBool>,
72 pub pending_plan: Arc<RwLock<Option<String>>>,
74 response: Arc<RwLock<Option<PlanApprovalResult>>>,
76 notify: Arc<Notify>,
78}
79
80impl PlanApprovalGate {
81 pub fn new() -> Self {
83 Self {
84 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
85 pending_plan: Arc::new(RwLock::new(None)),
86 response: Arc::new(RwLock::new(None)),
87 notify: Arc::new(Notify::new()),
88 }
89 }
90
91 pub async fn wait_for_approval(&self) -> PlanApprovalResult {
97 loop {
98 let result_opt = {
100 let guard = self.response.read().unwrap_or_else(|e| e.into_inner());
101 guard.clone()
102 };
103 if let Some(result) = result_opt {
104 if let Ok(mut w) = self.response.write() {
106 *w = None;
107 }
108 return result;
109 }
110 self.notify.notified().await;
112 }
113 }
114
115 pub fn approve(&self) {
121 if let Ok(mut w) = self.response.write() {
122 *w = Some(PlanApprovalResult::Approved);
123 }
124 if let Ok(mut w) = self.pending_plan.write() {
125 *w = None;
126 }
127 self.notify.notify_one();
128 }
129
130 pub fn reject(&self, reason: impl Into<String>) {
135 if let Ok(mut w) = self.response.write() {
136 *w = Some(PlanApprovalResult::Rejected {
137 reason: reason.into(),
138 });
139 }
140 if let Ok(mut w) = self.pending_plan.write() {
141 *w = None;
142 }
143 self.notify.notify_one();
144 }
145
146 pub fn pending_plan(&self) -> Option<String> {
155 self.pending_plan.read().ok().and_then(|g| g.clone())
156 }
157
158 pub fn begin_approval(&self, plan_text: String) {
165 if let Ok(mut w) = self.pending_plan.write() {
166 *w = Some(plan_text);
167 }
168 if let Ok(mut w) = self.response.write() {
171 *w = None;
172 }
173 }
174}
175
176impl Default for PlanApprovalGate {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182pub struct EnterPlanModeTool {
196 gate: Arc<PlanApprovalGate>,
197 permissions: Option<Arc<PermissionsConfig>>,
199}
200
201impl EnterPlanModeTool {
202 pub fn new(gate: Arc<PlanApprovalGate>) -> Self {
204 Self {
205 gate,
206 permissions: None,
207 }
208 }
209
210 pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
213 self.permissions = Some(permissions);
214 self
215 }
216}
217
218#[async_trait]
219impl Tool for EnterPlanModeTool {
220 fn is_deferred(&self) -> bool {
221 true
222 }
223
224 fn spec(&self) -> ToolSpec {
225 ToolSpec {
226 name: ENTER_PLAN_MODE_TOOL_NAME.into(),
227 description: "Enter read-only planning mode for non-trivial or architectural \
228 changes. While active, write tools (Write, Edit, Bash, …) are blocked.\n\n\
229 Use enter_plan_mode when:\n\
230 - The task requires exploring 3+ files before deciding what to change\n\
231 - The task touches architectural boundaries (new module, new tool, API change)\n\
232 - You are unsure of the correct approach and want to discuss options first\n\n\
233 While in plan mode:\n\
234 - Read files freely (Read, Glob, Grep)\n\
235 - Think through trade-offs in your responses\n\
236 - DO NOT call Write, Edit, or Bash\n\
237 - When you have a clear plan, call exit_plan_mode with a markdown summary"
238 .into(),
239 parameters: json!({
240 "type": "object",
241 "properties": {}
242 }),
243 }
244 }
245
246 async fn execute(&self, _arguments: Value) -> Result<String> {
247 self.gate.exploring_plan_mode.store(true, Ordering::Relaxed);
248
249 let mut response = json!({ "entered": true });
251 if let Some(ref config) = self.permissions {
252 if matches!(config.mode, PermissionMode::Plan { .. }) {
253 response["default_mode"] = json!("plan");
254 }
255 }
256
257 Ok(response.to_string())
258 }
259
260 fn side_effect_class(&self) -> ToolSideEffect {
261 ToolSideEffect::Mutating
262 }
263
264 fn is_readonly(&self) -> bool {
265 false
266 }
267}
268
269pub struct ExitPlanModeTool {
280 gate: Arc<PlanApprovalGate>,
281 event_sink: Arc<dyn EventSink>,
282 permissions: Option<Arc<PermissionsConfig>>,
284}
285
286impl ExitPlanModeTool {
287 pub fn new(gate: Arc<PlanApprovalGate>, event_sink: Arc<dyn EventSink>) -> Self {
292 Self {
293 gate,
294 event_sink,
295 permissions: None,
296 }
297 }
298
299 pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
302 self.permissions = Some(permissions);
303 self
304 }
305}
306
307#[async_trait]
308impl Tool for ExitPlanModeTool {
309 fn is_deferred(&self) -> bool {
310 true
311 }
312
313 fn spec(&self) -> ToolSpec {
314 ToolSpec {
315 name: EXIT_PLAN_MODE_TOOL_NAME.into(),
316 description:
317 "Exit plan mode and present your plan for human review. Include a markdown \
318 summary with: (1) your understanding of the current code, (2) the approach \
319 you propose and why, (3) the files you will modify and how, (4) how you will \
320 verify the change is correct. Execution blocks until the reviewer confirms \
321 or rejects the plan."
322 .into(),
323 parameters: json!({
324 "type": "object",
325 "required": ["plan"],
326 "properties": {
327 "plan": {
328 "type": "string",
329 "description": "Markdown summary of your plan."
330 }
331 }
332 }),
333 }
334 }
335
336 async fn execute(&self, arguments: Value) -> Result<String> {
337 let plan_text = arguments
338 .get("plan")
339 .and_then(|v| v.as_str())
340 .unwrap_or("")
341 .to_string();
342
343 if let Some(ref _config) = self.permissions {
345 }
348
349 self.gate
351 .exploring_plan_mode
352 .store(false, Ordering::Relaxed);
353
354 self.gate.begin_approval(plan_text.clone());
357
358 self.event_sink
361 .emit(AgentEvent::PlanProposed {
362 plan_text: plan_text.clone(),
363 tool_calls: vec![],
364 })
365 .await;
366
367 let result = self.gate.wait_for_approval().await;
370
371 match result {
372 PlanApprovalResult::Approved => Ok(json!({ "approved": true }).to_string()),
373 PlanApprovalResult::Rejected { reason } => {
374 Ok(json!({ "approved": false, "reason": reason }).to_string())
375 }
376 }
377 }
378
379 fn side_effect_class(&self) -> ToolSideEffect {
380 ToolSideEffect::External
382 }
383
384 fn is_readonly(&self) -> bool {
385 false
386 }
387}
388
389#[derive(Debug, Clone)]
395pub enum PlanModeRequestResult {
396 Approved,
398 Rejected { reason: String },
400}
401
402pub struct PlanModeRequestGate {
407 response: Arc<RwLock<Option<PlanModeRequestResult>>>,
408 notify: Arc<Notify>,
409}
410
411impl PlanModeRequestGate {
412 pub fn new() -> Self {
414 Self {
415 response: Arc::new(RwLock::new(None)),
416 notify: Arc::new(Notify::new()),
417 }
418 }
419
420 pub async fn wait_for_decision(&self) -> PlanModeRequestResult {
422 loop {
423 let result_opt = {
424 let guard = self.response.read().unwrap_or_else(|e| e.into_inner());
425 guard.clone()
426 };
427 if let Some(result) = result_opt {
428 if let Ok(mut w) = self.response.write() {
429 *w = None;
430 }
431 return result;
432 }
433 self.notify.notified().await;
434 }
435 }
436
437 pub fn approve(&self) {
439 if let Ok(mut w) = self.response.write() {
440 *w = Some(PlanModeRequestResult::Approved);
441 }
442 self.notify.notify_one();
443 }
444
445 pub fn reject(&self, reason: impl Into<String>) {
447 if let Ok(mut w) = self.response.write() {
448 *w = Some(PlanModeRequestResult::Rejected {
449 reason: reason.into(),
450 });
451 }
452 self.notify.notify_one();
453 }
454}
455
456impl Default for PlanModeRequestGate {
457 fn default() -> Self {
458 Self::new()
459 }
460}
461
462pub struct RequestPlanModeTool {
476 gate: Arc<PlanModeRequestGate>,
477 event_sink: Arc<dyn EventSink>,
478}
479
480impl RequestPlanModeTool {
481 pub fn new(gate: Arc<PlanModeRequestGate>, event_sink: Arc<dyn EventSink>) -> Self {
483 Self { gate, event_sink }
484 }
485}
486
487#[async_trait]
488impl Tool for RequestPlanModeTool {
489 fn is_deferred(&self) -> bool {
490 true
491 }
492
493 fn spec(&self) -> ToolSpec {
494 ToolSpec {
495 name: "request_plan_mode".into(),
496 description:
497 "Before entering plan mode, call this tool to ask the user whether they want \
498 you to create a plan first. Provide a brief `reason` explaining why planning \
499 would be helpful for this task. The call blocks until the user decides. \
500 If approved, proceed with `enter_plan_mode`; if rejected, execute directly \
501 without planning."
502 .into(),
503 parameters: serde_json::json!({
504 "type": "object",
505 "required": ["reason"],
506 "properties": {
507 "reason": {
508 "type": "string",
509 "description": "Brief explanation of why a planning phase would be \
510 beneficial (1-2 sentences)."
511 }
512 }
513 }),
514 }
515 }
516
517 async fn execute(&self, arguments: serde_json::Value) -> Result<String> {
518 let reason = arguments
519 .get("reason")
520 .and_then(|v| v.as_str())
521 .unwrap_or("")
522 .to_string();
523
524 self.event_sink
526 .emit(crate::event::AgentEvent::PlanModeRequested {
527 reason: reason.clone(),
528 })
529 .await;
530
531 let result = self.gate.wait_for_decision().await;
533
534 match result {
535 PlanModeRequestResult::Approved => {
536 self.event_sink
537 .emit(crate::event::AgentEvent::PlanModeApproved)
538 .await;
539 Ok(serde_json::json!({ "approved": true }).to_string())
540 }
541 PlanModeRequestResult::Rejected { reason: r } => {
542 self.event_sink
543 .emit(crate::event::AgentEvent::PlanModeRejected { reason: r.clone() })
544 .await;
545 Ok(serde_json::json!({ "approved": false, "reason": r }).to_string())
546 }
547 }
548 }
549
550 fn side_effect_class(&self) -> ToolSideEffect {
551 ToolSideEffect::External
553 }
554
555 fn is_readonly(&self) -> bool {
556 false
557 }
558}
559
560#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::event::NullSink;
568
569 fn make_gate() -> Arc<PlanApprovalGate> {
570 Arc::new(PlanApprovalGate::new())
571 }
572
573 #[tokio::test]
576 async fn enter_plan_mode_returns_confirmation_message() {
577 let gate = make_gate();
578 let tool = EnterPlanModeTool::new(gate.clone());
579
580 assert!(
581 !gate.exploring_plan_mode.load(Ordering::Relaxed),
582 "flag should start false"
583 );
584
585 let result = tool.execute(json!({})).await.unwrap();
586 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
587 assert_eq!(parsed["entered"], true);
588 assert!(
589 gate.exploring_plan_mode.load(Ordering::Relaxed),
590 "flag should be set after enter"
591 );
592 }
593
594 #[tokio::test]
597 async fn exit_plan_mode_blocks_until_confirmed() {
598 let gate = make_gate();
599 gate.exploring_plan_mode.store(true, Ordering::Relaxed);
601
602 let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
603
604 let gate_clone = gate.clone();
606 let approve_handle = tokio::spawn(async move {
607 tokio::task::yield_now().await;
609 gate_clone.approve();
610 });
611
612 let result = tool
613 .execute(json!({ "plan": "step 1: read; step 2: write" }))
614 .await
615 .unwrap();
616
617 approve_handle.await.unwrap();
618
619 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
620 assert_eq!(parsed["approved"], true);
621 assert!(
622 !gate.exploring_plan_mode.load(Ordering::Relaxed),
623 "flag should be cleared after exit"
624 );
625 }
626
627 #[tokio::test]
630 async fn exit_plan_mode_returns_rejection_reason() {
631 let gate = make_gate();
632 let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
633
634 let gate_clone = gate.clone();
635 let reject_handle = tokio::spawn(async move {
636 tokio::task::yield_now().await;
637 gate_clone.reject("Plan is incomplete");
638 });
639
640 let result = tool.execute(json!({ "plan": "my plan" })).await.unwrap();
641
642 reject_handle.await.unwrap();
643
644 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
645 assert_eq!(parsed["approved"], false);
646 assert_eq!(parsed["reason"], "Plan is incomplete");
647 }
648
649 #[tokio::test]
652 async fn gate_approve_wakes_waiter() {
653 let gate = make_gate();
654 let gate_clone = gate.clone();
655
656 let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
657
658 tokio::task::yield_now().await;
660 gate.approve();
661
662 let result = waiter.await.unwrap();
663 assert!(matches!(result, PlanApprovalResult::Approved));
664 }
665
666 #[tokio::test]
667 async fn gate_reject_wakes_waiter_with_reason() {
668 let gate = make_gate();
669 let gate_clone = gate.clone();
670
671 let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
672
673 tokio::task::yield_now().await;
674 gate.reject("too risky");
675
676 let result = waiter.await.unwrap();
677 assert!(matches!(
678 result,
679 PlanApprovalResult::Rejected { reason } if reason == "too risky"
680 ));
681 }
682
683 #[tokio::test]
686 async fn gate_response_cleared_after_wait() {
687 let gate = make_gate();
688 gate.approve();
689 let _ = gate.wait_for_approval().await;
690
691 let stored = gate.response.read().unwrap().clone();
693 assert!(stored.is_none(), "response should be cleared after read");
694 }
695
696 #[tokio::test]
699 async fn request_gate_approve_wakes_waiter() {
700 let gate = Arc::new(PlanModeRequestGate::new());
701 let gate_clone = gate.clone();
702 let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
703 tokio::task::yield_now().await;
704 gate.approve();
705 let result = waiter.await.unwrap();
706 assert!(matches!(result, PlanModeRequestResult::Approved));
707 }
708
709 #[tokio::test]
710 async fn request_gate_reject_propagates_reason() {
711 let gate = Arc::new(PlanModeRequestGate::new());
712 let gate_clone = gate.clone();
713 let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
714 tokio::task::yield_now().await;
715 gate.reject("user skipped");
716 let result = waiter.await.unwrap();
717 assert!(matches!(
718 result,
719 PlanModeRequestResult::Rejected { reason } if reason == "user skipped"
720 ));
721 }
722
723 #[tokio::test]
724 async fn request_gate_cleared_after_use() {
725 let gate = Arc::new(PlanModeRequestGate::new());
726 gate.approve();
727 let _ = gate.wait_for_decision().await;
728 let stored = gate.response.read().unwrap().clone();
729 assert!(
730 stored.is_none(),
731 "response should be cleared after decision"
732 );
733 }
734
735 #[tokio::test]
736 async fn request_plan_mode_tool_emits_event_and_blocks_until_approved() {
737 use crate::event::NullSink;
738 let gate = Arc::new(PlanModeRequestGate::new());
739 let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
740 let gate_clone = gate.clone();
741 let approve_handle = tokio::spawn(async move {
742 tokio::task::yield_now().await;
743 gate_clone.approve();
744 });
745 let result = tool
746 .execute(json!({ "reason": "complex task" }))
747 .await
748 .unwrap();
749 approve_handle.await.unwrap();
750 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
751 assert_eq!(parsed["approved"], true);
752 }
753
754 #[tokio::test]
755 async fn request_plan_mode_tool_rejected_returns_false() {
756 use crate::event::NullSink;
757 let gate = Arc::new(PlanModeRequestGate::new());
758 let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
759 let gate_clone = gate.clone();
760 let reject_handle = tokio::spawn(async move {
761 tokio::task::yield_now().await;
762 gate_clone.reject("not needed");
763 });
764 let result = tool
765 .execute(json!({ "reason": "might need plan" }))
766 .await
767 .unwrap();
768 reject_handle.await.unwrap();
769 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
770 assert_eq!(parsed["approved"], false);
771 assert_eq!(parsed["reason"], "not needed");
772 }
773
774 #[test]
777 fn pending_plan_returns_begin_approval_text() {
778 let gate = PlanApprovalGate::new();
779 assert_eq!(gate.pending_plan(), None, "starts empty");
780 gate.begin_approval("my plan".to_string());
781 assert_eq!(
782 gate.pending_plan().as_deref(),
783 Some("my plan"),
784 "returns the text set by begin_approval"
785 );
786 }
787
788 #[test]
789 fn pending_plan_cleared_on_approve() {
790 let gate = PlanApprovalGate::new();
791 gate.begin_approval("plan text".to_string());
792 assert_eq!(gate.pending_plan().as_deref(), Some("plan text"));
793 gate.approve();
794 assert_eq!(
795 gate.pending_plan(),
796 None,
797 "pending_plan must be cleared after approve() to prevent stale re-injection"
798 );
799 }
800
801 #[test]
802 fn pending_plan_cleared_on_reject() {
803 let gate = PlanApprovalGate::new();
804 gate.begin_approval("plan text".to_string());
805 assert_eq!(gate.pending_plan().as_deref(), Some("plan text"));
806 gate.reject("bad plan");
807 assert_eq!(
808 gate.pending_plan(),
809 None,
810 "pending_plan must be cleared after reject() to prevent stale re-injection"
811 );
812 }
813
814 #[test]
817 fn begin_approval_sets_plan_and_clears_stale_response() {
818 let gate = PlanApprovalGate::new();
819
820 *gate.response.write().unwrap() = Some(PlanApprovalResult::Approved);
822
823 gate.begin_approval("new plan text".to_string());
824
825 assert!(
826 gate.response.read().unwrap().is_none(),
827 "begin_approval must clear stale response to prevent TOCTOU bypass"
828 );
829 assert_eq!(
830 gate.pending_plan.read().unwrap().as_deref(),
831 Some("new plan text"),
832 "begin_approval must store the new plan text"
833 );
834 }
835
836 #[test]
839 fn enter_plan_mode_tool_is_deferred_and_not_readonly() {
840 let gate = make_gate();
843 let tool = EnterPlanModeTool::new(gate);
844 assert!(
845 tool.is_deferred(),
846 "EnterPlanModeTool must be deferred (not eagerly loaded)"
847 );
848 assert!(
849 !tool.is_readonly(),
850 "EnterPlanModeTool must NOT be readonly (it sets exploring_plan_mode)"
851 );
852 }
853
854 #[test]
855 fn exit_plan_mode_tool_is_deferred_and_not_readonly() {
856 let gate = make_gate();
859 let tool = ExitPlanModeTool::new(gate, Arc::new(NullSink));
860 assert!(tool.is_deferred(), "ExitPlanModeTool must be deferred");
861 assert!(
862 !tool.is_readonly(),
863 "ExitPlanModeTool must NOT be readonly (it blocks until the user reviews the plan)"
864 );
865 }
866
867 #[test]
868 fn begin_approval_called_twice_no_ghost_approval() {
869 let gate = PlanApprovalGate::new();
870
871 gate.begin_approval("plan v1".to_string());
873 *gate.response.write().unwrap() = Some(PlanApprovalResult::Approved);
874
875 gate.begin_approval("plan v2".to_string());
877
878 assert!(
879 gate.response.read().unwrap().is_none(),
880 "second begin_approval must clear the cycle-1 approval (ghost-approval prevention)"
881 );
882 assert_eq!(
883 gate.pending_plan.read().unwrap().as_deref(),
884 Some("plan v2"),
885 "pending_plan should reflect the latest plan text"
886 );
887 }
888}