use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::Notify;
use crate::error::Result;
use crate::event::{AgentEvent, EventSink};
use crate::llm::ToolSpec;
use crate::permissions::{PermissionMode, PermissionsConfig};
use crate::tools::{Tool, ToolSideEffect};
#[derive(Debug, Clone)]
pub enum PlanApprovalResult {
Approved,
Rejected { reason: String },
}
pub struct PlanApprovalGate {
pub exploring_plan_mode: Arc<AtomicBool>,
pub pending_plan: Arc<RwLock<Option<String>>>,
response: Arc<RwLock<Option<PlanApprovalResult>>>,
notify: Arc<Notify>,
}
impl PlanApprovalGate {
pub fn new() -> Self {
Self {
exploring_plan_mode: Arc::new(AtomicBool::new(false)),
pending_plan: Arc::new(RwLock::new(None)),
response: Arc::new(RwLock::new(None)),
notify: Arc::new(Notify::new()),
}
}
pub async fn wait_for_approval(&self) -> PlanApprovalResult {
loop {
let result_opt = {
let guard = self
.response
.read()
.expect("PlanApprovalGate response lock poisoned");
guard.clone()
};
if let Some(result) = result_opt {
if let Ok(mut w) = self.response.write() {
*w = None;
}
return result;
}
self.notify.notified().await;
}
}
pub fn approve(&self) {
if let Ok(mut w) = self.response.write() {
*w = Some(PlanApprovalResult::Approved);
}
self.notify.notify_one();
}
pub fn reject(&self, reason: impl Into<String>) {
if let Ok(mut w) = self.response.write() {
*w = Some(PlanApprovalResult::Rejected {
reason: reason.into(),
});
}
self.notify.notify_one();
}
}
impl Default for PlanApprovalGate {
fn default() -> Self {
Self::new()
}
}
pub struct EnterPlanModeTool {
gate: Arc<PlanApprovalGate>,
permissions: Option<Arc<PermissionsConfig>>,
}
impl EnterPlanModeTool {
pub fn new(gate: Arc<PlanApprovalGate>) -> Self {
Self {
gate,
permissions: None,
}
}
pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
self.permissions = Some(permissions);
self
}
}
#[async_trait]
impl Tool for EnterPlanModeTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "enter_plan_mode".into(),
description: "Enter read-only planning mode. While active, write tools (write_file, \
apply_patch, run_shell, …) are blocked. Use read tools to explore the \
codebase freely, then call exit_plan_mode with a markdown plan summary."
.into(),
parameters: json!({
"type": "object",
"properties": {}
}),
}
}
async fn execute(&self, _arguments: Value) -> Result<String> {
self.gate.exploring_plan_mode.store(true, Ordering::Relaxed);
let mut response = json!({ "entered": true });
if let Some(ref config) = self.permissions {
if matches!(config.mode, PermissionMode::Plan { .. }) {
response["default_mode"] = json!("plan");
}
}
Ok(response.to_string())
}
fn side_effect_class(&self) -> ToolSideEffect {
ToolSideEffect::Mutating
}
fn is_readonly(&self) -> bool {
false
}
}
pub struct ExitPlanModeTool {
gate: Arc<PlanApprovalGate>,
event_sink: Arc<dyn EventSink>,
permissions: Option<Arc<PermissionsConfig>>,
}
impl ExitPlanModeTool {
pub fn new(gate: Arc<PlanApprovalGate>, event_sink: Arc<dyn EventSink>) -> Self {
Self {
gate,
event_sink,
permissions: None,
}
}
pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
self.permissions = Some(permissions);
self
}
}
#[async_trait]
impl Tool for ExitPlanModeTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "exit_plan_mode".into(),
description:
"Exit plan mode and present your plan for human review. Include a markdown \
summary with: (1) your understanding of the current code, (2) the approach \
you propose and why, (3) the files you will modify and how, (4) how you will \
verify the change is correct. Execution blocks until the reviewer confirms \
or rejects the plan."
.into(),
parameters: json!({
"type": "object",
"required": ["plan"],
"properties": {
"plan": {
"type": "string",
"description": "Markdown summary of your plan."
}
}
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let plan_text = arguments
.get("plan")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if let Some(ref _config) = self.permissions {
}
self.gate
.exploring_plan_mode
.store(false, Ordering::Relaxed);
if let Ok(mut w) = self.gate.pending_plan.write() {
*w = Some(plan_text.clone());
}
self.event_sink
.emit(AgentEvent::PlanProposed {
plan_text: plan_text.clone(),
tool_calls: vec![],
})
.await;
let result = self.gate.wait_for_approval().await;
match result {
PlanApprovalResult::Approved => Ok(json!({ "approved": true }).to_string()),
PlanApprovalResult::Rejected { reason } => {
Ok(json!({ "approved": false, "reason": reason }).to_string())
}
}
}
fn side_effect_class(&self) -> ToolSideEffect {
ToolSideEffect::External
}
fn is_readonly(&self) -> bool {
false
}
}
#[derive(Debug, Clone)]
pub enum PlanModeRequestResult {
Approved,
Rejected { reason: String },
}
pub struct PlanModeRequestGate {
response: Arc<RwLock<Option<PlanModeRequestResult>>>,
notify: Arc<Notify>,
}
impl PlanModeRequestGate {
pub fn new() -> Self {
Self {
response: Arc::new(RwLock::new(None)),
notify: Arc::new(Notify::new()),
}
}
pub async fn wait_for_decision(&self) -> PlanModeRequestResult {
loop {
let result_opt = {
let guard = self
.response
.read()
.expect("PlanModeRequestGate response lock poisoned");
guard.clone()
};
if let Some(result) = result_opt {
if let Ok(mut w) = self.response.write() {
*w = None;
}
return result;
}
self.notify.notified().await;
}
}
pub fn approve(&self) {
if let Ok(mut w) = self.response.write() {
*w = Some(PlanModeRequestResult::Approved);
}
self.notify.notify_one();
}
pub fn reject(&self, reason: impl Into<String>) {
if let Ok(mut w) = self.response.write() {
*w = Some(PlanModeRequestResult::Rejected {
reason: reason.into(),
});
}
self.notify.notify_one();
}
}
impl Default for PlanModeRequestGate {
fn default() -> Self {
Self::new()
}
}
pub struct RequestPlanModeTool {
gate: Arc<PlanModeRequestGate>,
event_sink: Arc<dyn EventSink>,
}
impl RequestPlanModeTool {
pub fn new(gate: Arc<PlanModeRequestGate>, event_sink: Arc<dyn EventSink>) -> Self {
Self { gate, event_sink }
}
}
#[async_trait]
impl Tool for RequestPlanModeTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "request_plan_mode".into(),
description:
"Before entering plan mode, call this tool to ask the user whether they want \
you to create a plan first. Provide a brief `reason` explaining why planning \
would be helpful for this task. The call blocks until the user decides. \
If approved, proceed with `enter_plan_mode`; if rejected, execute directly \
without planning."
.into(),
parameters: serde_json::json!({
"type": "object",
"required": ["reason"],
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why a planning phase would be \
beneficial (1-2 sentences)."
}
}
}),
}
}
async fn execute(&self, arguments: serde_json::Value) -> Result<String> {
let reason = arguments
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
self.event_sink
.emit(crate::event::AgentEvent::PlanModeRequested {
reason: reason.clone(),
})
.await;
let result = self.gate.wait_for_decision().await;
match result {
PlanModeRequestResult::Approved => {
self.event_sink
.emit(crate::event::AgentEvent::PlanModeApproved)
.await;
Ok(serde_json::json!({ "approved": true }).to_string())
}
PlanModeRequestResult::Rejected { reason: r } => {
self.event_sink
.emit(crate::event::AgentEvent::PlanModeRejected { reason: r.clone() })
.await;
Ok(serde_json::json!({ "approved": false, "reason": r }).to_string())
}
}
}
fn side_effect_class(&self) -> ToolSideEffect {
ToolSideEffect::External
}
fn is_readonly(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::NullSink;
fn make_gate() -> Arc<PlanApprovalGate> {
Arc::new(PlanApprovalGate::new())
}
#[tokio::test]
async fn enter_plan_mode_returns_confirmation_message() {
let gate = make_gate();
let tool = EnterPlanModeTool::new(gate.clone());
assert!(
!gate.exploring_plan_mode.load(Ordering::Relaxed),
"flag should start false"
);
let result = tool.execute(json!({})).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["entered"], true);
assert!(
gate.exploring_plan_mode.load(Ordering::Relaxed),
"flag should be set after enter"
);
}
#[tokio::test]
async fn exit_plan_mode_blocks_until_confirmed() {
let gate = make_gate();
gate.exploring_plan_mode.store(true, Ordering::Relaxed);
let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
let gate_clone = gate.clone();
let approve_handle = tokio::spawn(async move {
tokio::task::yield_now().await;
gate_clone.approve();
});
let result = tool
.execute(json!({ "plan": "step 1: read; step 2: write" }))
.await
.unwrap();
approve_handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["approved"], true);
assert!(
!gate.exploring_plan_mode.load(Ordering::Relaxed),
"flag should be cleared after exit"
);
}
#[tokio::test]
async fn exit_plan_mode_returns_rejection_reason() {
let gate = make_gate();
let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
let gate_clone = gate.clone();
let reject_handle = tokio::spawn(async move {
tokio::task::yield_now().await;
gate_clone.reject("Plan is incomplete");
});
let result = tool.execute(json!({ "plan": "my plan" })).await.unwrap();
reject_handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["approved"], false);
assert_eq!(parsed["reason"], "Plan is incomplete");
}
#[tokio::test]
async fn gate_approve_wakes_waiter() {
let gate = make_gate();
let gate_clone = gate.clone();
let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
tokio::task::yield_now().await;
gate.approve();
let result = waiter.await.unwrap();
assert!(matches!(result, PlanApprovalResult::Approved));
}
#[tokio::test]
async fn gate_reject_wakes_waiter_with_reason() {
let gate = make_gate();
let gate_clone = gate.clone();
let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
tokio::task::yield_now().await;
gate.reject("too risky");
let result = waiter.await.unwrap();
assert!(matches!(
result,
PlanApprovalResult::Rejected { reason } if reason == "too risky"
));
}
#[tokio::test]
async fn gate_response_cleared_after_wait() {
let gate = make_gate();
gate.approve();
let _ = gate.wait_for_approval().await;
let stored = gate.response.read().unwrap().clone();
assert!(stored.is_none(), "response should be cleared after read");
}
#[tokio::test]
async fn request_gate_approve_wakes_waiter() {
let gate = Arc::new(PlanModeRequestGate::new());
let gate_clone = gate.clone();
let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
tokio::task::yield_now().await;
gate.approve();
let result = waiter.await.unwrap();
assert!(matches!(result, PlanModeRequestResult::Approved));
}
#[tokio::test]
async fn request_gate_reject_propagates_reason() {
let gate = Arc::new(PlanModeRequestGate::new());
let gate_clone = gate.clone();
let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
tokio::task::yield_now().await;
gate.reject("user skipped");
let result = waiter.await.unwrap();
assert!(matches!(
result,
PlanModeRequestResult::Rejected { reason } if reason == "user skipped"
));
}
#[tokio::test]
async fn request_gate_cleared_after_use() {
let gate = Arc::new(PlanModeRequestGate::new());
gate.approve();
let _ = gate.wait_for_decision().await;
let stored = gate.response.read().unwrap().clone();
assert!(
stored.is_none(),
"response should be cleared after decision"
);
}
#[tokio::test]
async fn request_plan_mode_tool_emits_event_and_blocks_until_approved() {
use crate::event::NullSink;
let gate = Arc::new(PlanModeRequestGate::new());
let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
let gate_clone = gate.clone();
let approve_handle = tokio::spawn(async move {
tokio::task::yield_now().await;
gate_clone.approve();
});
let result = tool
.execute(json!({ "reason": "complex task" }))
.await
.unwrap();
approve_handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["approved"], true);
}
#[tokio::test]
async fn request_plan_mode_tool_rejected_returns_false() {
use crate::event::NullSink;
let gate = Arc::new(PlanModeRequestGate::new());
let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
let gate_clone = gate.clone();
let reject_handle = tokio::spawn(async move {
tokio::task::yield_now().await;
gate_clone.reject("not needed");
});
let result = tool
.execute(json!({ "reason": "might need plan" }))
.await
.unwrap();
reject_handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["approved"], false);
assert_eq!(parsed["reason"], "not needed");
}
}