dirge-agent 0.10.0

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
//! Plan-**mode** tools (`plan_enter` / `plan_exit`): a user-confirmed,
//! read-only lock the model can request for a turn (propose a plan before
//! touching anything), toggled via the `PlanSwitchRequest` channel.
//!
//! NOTE: this is NOT the phased `/plan` workflow (explore→plan→implement→
//! review) — that lives in [`crate::agent::plan`]. Unrelated feature, similar
//! name; don't cross the wires (`plan_tx` here vs `plan_phase`/`active_plan`
//! there). For how this relates to the phased workflow, the `write_todo_list`
//! checklist, and background `task`s, see the canonical map of all four
//! work-tracking concepts in [`crate::agent::plan`].

#[allow(unused_imports)]
use crate::sync_util::LockExt;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use tokio::sync::{mpsc, oneshot};

use crate::agent::tools::ToolError;
use crate::permission::checker::PermCheck;

pub type PlanSwitchSender = mpsc::Sender<PlanSwitchRequest>;
pub type PlanSwitchReceiver = mpsc::Receiver<PlanSwitchRequest>;

/// Refuse the call if the active prompt's `deny_tools` list names
/// this plan tool. Lets a prompt author say `deny_tools: [plan_exit]`
/// to lock the LLM into plan mode for the whole turn (adversarial-
/// review #6). Returns Ok if no deny-list match.
fn check_prompt_deny(perm: &Option<PermCheck>, tool_name: &str) -> Result<(), ToolError> {
    let Some(p) = perm else {
        return Ok(());
    };
    let denied = {
        let guard = p.lock_ignore_poison();
        guard.any_prompt_denied(&[tool_name])
    };
    if denied {
        Err(ToolError::Msg(format!(
            "Tool {:?} is denied by the active prompt's `deny_tools` frontmatter.",
            tool_name,
        )))
    } else {
        Ok(())
    }
}

#[derive(Debug)]
pub struct PlanSwitchRequest {
    pub action: PlanAction,
    pub reply: oneshot::Sender<PlanSwitchResponse>,
}

#[derive(Debug, Clone, Copy)]
pub enum PlanAction {
    Enter,
    Exit,
}

#[derive(Debug)]
pub enum PlanSwitchResponse {
    Accepted,
    Rejected,
}

// --- plan_enter ---

pub struct PlanEnterTool {
    plan_tx: PlanSwitchSender,
    permission: Option<PermCheck>,
}

impl PlanEnterTool {
    pub fn new(plan_tx: PlanSwitchSender) -> Self {
        Self {
            plan_tx,
            permission: None,
        }
    }

    pub fn with_permission(mut self, perm: Option<PermCheck>) -> Self {
        self.permission = perm;
        self
    }
}

#[derive(Deserialize)]
pub struct PlanEnterArgs {}

impl Tool for PlanEnterTool {
    const NAME: &'static str = "plan_enter";

    type Error = ToolError;
    type Args = PlanEnterArgs;
    type Output = String;

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition {
            name: "plan_enter".to_string(),
            description: "Suggest switching to plan mode for complex tasks. The user will be asked to confirm. In plan mode, the agent uses a planning prompt that focuses on analysis and creating a detailed implementation plan rather than writing code."
                .to_string(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {},
                "required": []
            }),
        }
    }

    async fn call(&self, _args: PlanEnterArgs) -> Result<String, ToolError> {
        // Note: this tool doesn't go through `check_perm` because the
        // plan_tx channel itself surfaces a confirmation dialog to the
        // user — the user has to explicitly Accept or Reject the mode
        // switch via `PlanSwitchResponse`. Routing through `check_perm`
        // would double-ask. This matches how `harness/confirm` works
        // in the plugin layer.
        //
        // Adversarial-review #6: still consult the prompt deny-list
        // before opening the dialog. A prompt that denies plan_enter
        // (e.g. a strict mode that wants to block mode-switches)
        // should fail fast without prompting the user.
        check_prompt_deny(&self.permission, "plan_enter")?;
        let (reply_tx, reply_rx) = oneshot::channel();

        self.plan_tx
            .send(PlanSwitchRequest {
                action: PlanAction::Enter,
                reply: reply_tx,
            })
            .await
            .map_err(|_| ToolError::Msg("plan system unavailable".to_string()))?;

        match reply_rx.await {
            Ok(PlanSwitchResponse::Accepted) => Ok("plan mode activated".to_string()),
            Ok(PlanSwitchResponse::Rejected) => {
                Err(ToolError::Msg("user declined plan mode".to_string()))
            }
            Err(_) => Err(ToolError::Msg(
                "plan channel closed unexpectedly".to_string(),
            )),
        }
    }
}

// --- plan_exit ---

pub struct PlanExitTool {
    plan_tx: PlanSwitchSender,
    permission: Option<PermCheck>,
}

impl PlanExitTool {
    pub fn new(plan_tx: PlanSwitchSender) -> Self {
        Self {
            plan_tx,
            permission: None,
        }
    }

    pub fn with_permission(mut self, perm: Option<PermCheck>) -> Self {
        self.permission = perm;
        self
    }
}

#[derive(Deserialize)]
pub struct PlanExitArgs {}

impl Tool for PlanExitTool {
    const NAME: &'static str = "plan_exit";

    type Error = ToolError;
    type Args = PlanExitArgs;
    type Output = String;

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition {
            name: "plan_exit".to_string(),
            description: "Suggest switching from plan mode to implementation mode. The user will be asked to confirm. The agent will switch to the code prompt for writing and executing code."
                .to_string(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {},
                "required": []
            }),
        }
    }

    async fn call(&self, _args: PlanExitArgs) -> Result<String, ToolError> {
        // Adversarial-review #6: respect prompt deny-list. A locked-
        // in plan-mode session can declare `deny_tools: [plan_exit]`
        // to block the LLM from mode-switching away.
        check_prompt_deny(&self.permission, "plan_exit")?;
        let (reply_tx, reply_rx) = oneshot::channel();

        self.plan_tx
            .send(PlanSwitchRequest {
                action: PlanAction::Exit,
                reply: reply_tx,
            })
            .await
            .map_err(|_| ToolError::Msg("plan system unavailable".to_string()))?;

        match reply_rx.await {
            Ok(PlanSwitchResponse::Accepted) => Ok("switched to implementation mode".to_string()),
            Ok(PlanSwitchResponse::Rejected) => {
                Err(ToolError::Msg("user declined mode switch".to_string()))
            }
            Err(_) => Err(ToolError::Msg(
                "plan channel closed unexpectedly".to_string(),
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_plan_enter_accepted() {
        let (tx, mut rx) = mpsc::channel(1);
        let tool = PlanEnterTool::new(tx);

        let handle = tokio::spawn(async move { tool.call(PlanEnterArgs {}).await });

        let req = rx.recv().await.unwrap();
        assert!(matches!(req.action, PlanAction::Enter));
        let _ = req.reply.send(PlanSwitchResponse::Accepted);

        let result = handle.await.unwrap().unwrap();
        assert_eq!(result, "plan mode activated");
    }

    #[tokio::test]
    async fn test_plan_enter_rejected() {
        let (tx, mut rx) = mpsc::channel(1);
        let tool = PlanEnterTool::new(tx);

        let handle = tokio::spawn(async move { tool.call(PlanEnterArgs {}).await });

        let req = rx.recv().await.unwrap();
        let _ = req.reply.send(PlanSwitchResponse::Rejected);

        let result = handle.await.unwrap();
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("declined"));
    }

    #[tokio::test]
    async fn test_plan_exit_accepted() {
        let (tx, mut rx) = mpsc::channel(1);
        let tool = PlanExitTool::new(tx);

        let handle = tokio::spawn(async move { tool.call(PlanExitArgs {}).await });

        let req = rx.recv().await.unwrap();
        assert!(matches!(req.action, PlanAction::Exit));
        let _ = req.reply.send(PlanSwitchResponse::Accepted);

        let result = handle.await.unwrap().unwrap();
        assert_eq!(result, "switched to implementation mode");
    }

    #[tokio::test]
    async fn test_plan_exit_rejected() {
        let (tx, mut rx) = mpsc::channel(1);
        let tool = PlanExitTool::new(tx);

        let handle = tokio::spawn(async move { tool.call(PlanExitArgs {}).await });

        let req = rx.recv().await.unwrap();
        let _ = req.reply.send(PlanSwitchResponse::Rejected);

        let result = handle.await.unwrap();
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("declined"));
    }

    #[tokio::test]
    async fn test_both_definitions() {
        let (tx1, _rx) = mpsc::channel(1);
        let (tx2, _rx) = mpsc::channel(1);

        let enter = PlanEnterTool::new(tx1).definition(String::new()).await;
        assert_eq!(enter.name, "plan_enter");

        let exit = PlanExitTool::new(tx2).definition(String::new()).await;
        assert_eq!(exit.name, "plan_exit");
    }

    // Regression: a prior version of plan_exit wrote a "Implementation Plan"
    // placeholder to PLAN.md in CWD whenever the user accepted the mode
    // switch. That side-effect bypassed the file-write permission system and
    // surprised users whose CWD already contained an unrelated PLAN.md. The
    // fix removed the write entirely — this test guards against
    // re-introducing it by inspecting the source.
    #[test]
    fn regression_plan_exit_has_no_filesystem_side_effects() {
        let src = include_str!("plan.rs");
        // The impl block for PlanExitTool. We don't want fs::write or PLAN.md
        // string literals anywhere in the call() path.
        let impl_start = src
            .find("impl Tool for PlanExitTool")
            .expect("PlanExitTool impl present");
        let impl_end = src[impl_start..]
            .find("\n}\n")
            .map(|i| impl_start + i)
            .unwrap_or(src.len());
        let body = &src[impl_start..impl_end];
        assert!(
            !body.contains("PLAN.md"),
            "plan_exit must not reference PLAN.md (side-effect regression)"
        );
        assert!(
            !body.contains("fs::write"),
            "plan_exit must not write files (side-effect regression)"
        );
    }

    // Regression: dropping the receiver (UI not subscribed) must surface a
    // clean error rather than panic or hang.
    #[tokio::test]
    async fn plan_enter_channel_unavailable() {
        let (tx, rx) = mpsc::channel(1);
        drop(rx);
        let tool = PlanEnterTool::new(tx);
        let result = tool.call(PlanEnterArgs {}).await;
        assert!(result.is_err());
        assert!(
            result.unwrap_err().to_string().contains("unavailable"),
            "expected 'unavailable' error",
        );
    }

    // Regression: if the UI accepts the request handle but drops the oneshot
    // before replying, the tool must error cleanly (channel closed) rather
    // than block forever.
    #[tokio::test]
    async fn plan_enter_reply_dropped() {
        let (tx, mut rx) = mpsc::channel(1);
        let tool = PlanEnterTool::new(tx);
        let handle = tokio::spawn(async move { tool.call(PlanEnterArgs {}).await });

        let req = rx.recv().await.unwrap();
        drop(req.reply);

        let result = handle.await.unwrap();
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("channel closed"));
    }
}