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(),
),
}
}
}