use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::tools::{Tool, ToolContext};
pub const ENTER_PLAN_MODE: &str = "enter_plan_mode";
pub const EXIT_PLAN_MODE: &str = "exit_plan_mode";
#[derive(Debug, Default)]
pub struct PlanModeState {
active: AtomicBool,
plan: Mutex<Vec<String>>,
}
impl PlanModeState {
pub fn new() -> Self {
Self::default()
}
pub fn is_active(&self) -> bool {
self.active.load(Ordering::SeqCst)
}
pub fn enter(&self, note: Option<&str>) -> bool {
let was = self.active.swap(true, Ordering::SeqCst);
if let Some(note) = note {
self.append(note);
}
!was
}
pub fn append(&self, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
if let Ok(mut plan) = self.plan.lock() {
plan.push(text.to_string());
}
}
pub fn plan(&self) -> String {
self.plan.lock().map(|p| p.join("\n\n")).unwrap_or_default()
}
pub fn exit(&self) -> String {
self.active.store(false, Ordering::SeqCst);
let plan = self.plan();
if let Ok(mut p) = self.plan.lock() {
p.clear();
}
plan
}
}
pub fn deny_rules(state: &PlanModeState) -> Vec<String> {
if !state.is_active() {
return Vec::new();
}
[
"write(*)",
"write_file",
"edit_file",
"apply_patch",
"bash",
"shell",
"background_exec",
"image_gen",
"new_context",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[derive(Debug, Default, Deserialize)]
struct EnterArgs {
#[serde(default)]
plan: Option<String>,
}
#[derive(Debug, Default)]
pub struct EnterPlanModeTool;
#[async_trait]
impl Tool for EnterPlanModeTool {
fn name(&self) -> &str {
ENTER_PLAN_MODE
}
fn description(&self) -> &str {
"Enter plan mode: a read-only research phase. While it is active every write and \
execution tool is refused by the permissions engine, and anything you pass to this \
tool (or to further calls) accumulates as the plan. Call exit_plan_mode with the \
finished plan to ask the user to approve it and leave the mode."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"plan": {
"type": "string",
"description": "Optional opening note or draft plan to record."
}
},
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: EnterArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: self.name().to_string(),
message: e.to_string(),
})?;
let fresh = ctx.plan_mode.enter(a.plan.as_deref());
Ok(if fresh {
"Plan mode is ON: write and execution tools are refused until the user approves a \
plan. Research with the read-only tools, then call exit_plan_mode with the plan."
.to_string()
} else {
"Plan mode was already on; the note was appended to the plan.".to_string()
})
}
}
#[derive(Debug, Deserialize)]
struct ExitArgs {
plan: String,
}
#[derive(Debug, Default)]
pub struct ExitPlanModeTool;
const SUBJECT_BUDGET: usize = 400;
#[async_trait]
impl Tool for ExitPlanModeTool {
fn name(&self) -> &str {
EXIT_PLAN_MODE
}
fn description(&self) -> &str {
"Present the finished plan to the user and ask to leave plan mode. The user must \
approve; only then are write and execution tools re-enabled. A refusal keeps plan \
mode on so you can revise the plan."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"plan": {
"type": "string",
"description": "The complete plan the user is being asked to approve."
}
},
"required": ["plan"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ExitArgs =
serde_json::from_value(args.clone()).map_err(|e| Error::InvalidArguments {
tool: self.name().to_string(),
message: e.to_string(),
})?;
if !ctx.plan_mode.is_active() {
return Err(Error::tool(
self.name(),
"plan mode is not active; there is nothing to exit",
));
}
ctx.plan_mode.append(&a.plan);
let plan = ctx.plan_mode.plan();
let Some(handler) = ctx.approval_handler.as_ref() else {
return Err(Error::tool(
self.name(),
"no approval door is attached, so the plan cannot be approved; plan mode stays \
on (attach an interactive frontend, or leave plan mode from the REPL's /plan)",
));
};
let mut subject: String = plan.chars().take(SUBJECT_BUDGET).collect();
if subject.chars().count() < plan.chars().count() {
subject.push('…');
}
let raw_args = json!({ "plan": plan });
let req = crate::permissions::ApprovalRequest {
tool: self.name(),
subject: Some(subject.as_str()),
raw_args: &raw_args,
};
let outcome = handler.ask(&req);
match outcome {
crate::permissions::ApprovalOutcome::Allow
| crate::permissions::ApprovalOutcome::AllowForSession => {
let approved = ctx.plan_mode.exit();
Ok(format!(
"The user APPROVED the plan. Plan mode is off; write and execution tools \
are available again.\n\nApproved plan:\n{approved}"
))
}
crate::permissions::ApprovalOutcome::Deny => Ok(
"The user did NOT approve the plan. Plan mode stays on — revise the plan and \
call exit_plan_mode again."
.to_string(),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn deny_rules_are_empty_until_the_mode_is_entered() {
let state = PlanModeState::new();
assert!(deny_rules(&state).is_empty());
state.enter(None);
let rules = deny_rules(&state);
assert!(rules.contains(&"write(*)".to_string()));
assert!(rules.contains(&"bash".to_string()));
assert!(!rules.contains(&EXIT_PLAN_MODE.to_string()));
assert!(!rules.contains(&"read_file".to_string()));
state.exit();
assert!(deny_rules(&state).is_empty());
}
#[test]
fn the_plan_accumulates_and_clears_on_exit() {
let state = PlanModeState::new();
state.enter(Some("first"));
state.append("second");
assert_eq!(state.plan(), "first\n\nsecond");
assert_eq!(state.exit(), "first\n\nsecond");
assert!(state.plan().is_empty());
assert!(!state.is_active());
}
#[tokio::test]
async fn exit_without_an_approval_door_keeps_the_mode_on() {
let ctx = ToolContext::new(std::env::temp_dir());
ctx.plan_mode.enter(None);
let err = ExitPlanModeTool
.execute(json!({"plan": "do the thing"}), &ctx)
.await
.expect_err("no handler must refuse");
assert!(err.to_string().contains("no approval door"), "{err}");
assert!(ctx.plan_mode.is_active());
}
#[tokio::test]
async fn enter_then_approved_exit_clears_the_restriction() {
struct Approve;
impl crate::permissions::PermissionsApprovalHandler for Approve {
fn ask(
&self,
_req: &crate::permissions::ApprovalRequest,
) -> crate::permissions::ApprovalOutcome {
crate::permissions::ApprovalOutcome::Allow
}
}
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Approve)));
EnterPlanModeTool
.execute(json!({"plan": "research first"}), &ctx)
.await
.unwrap();
assert!(ctx.plan_mode.is_active());
assert!(!deny_rules(&ctx.plan_mode).is_empty());
let out = ExitPlanModeTool
.execute(json!({"plan": "then build"}), &ctx)
.await
.unwrap();
assert!(out.contains("APPROVED"), "{out}");
assert!(!ctx.plan_mode.is_active());
assert!(deny_rules(&ctx.plan_mode).is_empty());
}
#[tokio::test]
async fn a_denied_exit_keeps_the_mode_and_the_plan() {
struct Refuse;
impl crate::permissions::PermissionsApprovalHandler for Refuse {
fn ask(
&self,
_req: &crate::permissions::ApprovalRequest,
) -> crate::permissions::ApprovalOutcome {
crate::permissions::ApprovalOutcome::Deny
}
}
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(Arc::new(Refuse)));
ctx.plan_mode.enter(None);
let out = ExitPlanModeTool
.execute(json!({"plan": "ship it"}), &ctx)
.await
.unwrap();
assert!(out.contains("did NOT approve"), "{out}");
assert!(ctx.plan_mode.is_active());
assert_eq!(ctx.plan_mode.plan(), "ship it");
}
}