cruise 0.1.45

YAML-driven coding agent workflow orchestrator
Documentation
//! Custom seher tools (function calling) for interactive planning.
//!
//! Three tools are exposed to the planning agent:
//! - `ask_user(question)` — ask the user a clarifying question and return their
//!   answer (delegates to an [`AskHandler`]).
//! - `submit_plan(content)` — write the full plan markdown to the session
//!   `plan.md`.
//! - `update_plan(old, new)` — find/replace a section of the existing `plan.md`.
//!
//! Tool handlers are synchronous `Arc` closures (`'static`), so the plan path is
//! captured by value and the [`AskHandler`] is shared via `Arc`. A handler that
//! returns `Err(msg)` surfaces to the model with `is_error: true` so it can
//! recover (e.g. re-read the plan and retry an `update_plan` whose `old` text no
//! longer matches) rather than aborting the turn.

use std::path::PathBuf;
use std::sync::Arc;

use seher::sdk::{SeherTool, ToolHandler};
use serde_json::json;

use crate::ask_handler::AskHandler;

/// Tool name for the clarifying-question tool.
pub const ASK_USER_TOOL: &str = "ask_user";
/// Tool name for the full-plan submission tool.
pub const SUBMIT_PLAN_TOOL: &str = "submit_plan";
/// Tool name for the section find/replace tool.
pub const UPDATE_PLAN_TOOL: &str = "update_plan";

/// Build the planning tool set.
///
/// `interactive` controls whether the user can be reached:
/// - `true`  -> `[ask_user, submit_plan, update_plan]` (the agent can ask
///   questions and iteratively revise the plan).
/// - `false` -> `[submit_plan]` only (non-TTY runs: the agent proceeds on
///   assumptions and submits a single plan).
#[must_use]
pub fn planning_tools(
    plan_path: PathBuf,
    ask: Arc<dyn AskHandler>,
    interactive: bool,
) -> Vec<SeherTool> {
    let mut tools = Vec::new();
    if interactive {
        tools.push(ask_user_tool(ask));
    }
    tools.push(submit_plan_tool(plan_path.clone()));
    if interactive {
        tools.push(update_plan_tool(plan_path));
    }
    tools
}

/// `ask_user` — delegates the agent's question to the [`AskHandler`].
#[must_use]
pub fn ask_user_tool(ask: Arc<dyn AskHandler>) -> SeherTool {
    let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
        let question = require_str(&input, "question")?;
        ask.ask_user(question).map_err(|e| e.to_string())
    });
    SeherTool::new(
        ASK_USER_TOOL,
        "Ask the user a clarifying question and get their answer. Use this whenever a \
         requirement is ambiguous instead of guessing.",
        json!({
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "The question to ask the user."
                }
            },
            "required": ["question"]
        }),
        handler,
    )
}

/// `submit_plan` — writes the full plan markdown to `plan_path`.
#[must_use]
pub fn submit_plan_tool(plan_path: PathBuf) -> SeherTool {
    let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
        let content = require_str(&input, "content")?;
        write_plan(&plan_path, content)
    });
    SeherTool::new(
        SUBMIT_PLAN_TOOL,
        "Submit the complete implementation plan as markdown. Call this once the plan is \
         ready; it overwrites the plan document.",
        json!({
            "type": "object",
            "properties": {
                "content": {
                    "type": "string",
                    "description": "The full plan, as markdown."
                }
            },
            "required": ["content"]
        }),
        handler,
    )
}

/// `update_plan` — find/replace a section of the existing plan document.
#[must_use]
pub fn update_plan_tool(plan_path: PathBuf) -> SeherTool {
    let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
        let old = require_str(&input, "old")?;
        let new = require_str(&input, "new")?;
        let current = std::fs::read_to_string(&plan_path)
            .map_err(|e| format!("failed to read plan at {}: {e}", plan_path.display()))?;
        let updated = apply_update(&current, old, new)?;
        std::fs::write(&plan_path, &updated)
            .map_err(|e| format!("failed to write plan at {}: {e}", plan_path.display()))?;
        Ok("Plan updated.".to_string())
    });
    SeherTool::new(
        UPDATE_PLAN_TOOL,
        "Revise the existing plan by replacing an exact snippet. `old` must match a unique \
         span of the current plan verbatim; if it does not match, re-read the plan and retry.",
        json!({
            "type": "object",
            "properties": {
                "old": {
                    "type": "string",
                    "description": "Exact text to replace (must occur exactly once)."
                },
                "new": {
                    "type": "string",
                    "description": "Replacement text."
                }
            },
            "required": ["old", "new"]
        }),
        handler,
    )
}

/// Extract a required string field from the tool input JSON.
fn require_str<'a>(input: &'a serde_json::Value, field: &str) -> Result<&'a str, String> {
    input
        .get(field)
        .and_then(serde_json::Value::as_str)
        .ok_or_else(|| format!("missing or non-string `{field}` argument"))
}

/// Write `content` to the plan file, returning the success message or an error.
fn write_plan(plan_path: &std::path::Path, content: &str) -> Result<String, String> {
    std::fs::write(plan_path, content)
        .map(|()| "Plan saved.".to_string())
        .map_err(|e| format!("failed to write plan at {}: {e}", plan_path.display()))
}

/// Apply an exact-match find/replace to `current`.
///
/// `old` must occur **exactly once**:
/// - zero occurrences -> the snippet is stale; the caller should re-read.
/// - multiple occurrences -> ambiguous; the caller should widen the snippet.
fn apply_update(current: &str, old: &str, new: &str) -> Result<String, String> {
    if old.is_empty() {
        return Err("`old` must not be empty".to_string());
    }
    let count = current.matches(old).count();
    match count {
        0 => Err(
            "`old` text was not found in the current plan; re-read the plan and retry".to_string(),
        ),
        1 => Ok(current.replacen(old, new, 1)),
        n => Err(format!(
            "`old` text matched {n} times; provide a longer, unique snippet"
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ask_handler::ScriptedAskHandler;
    use tempfile::TempDir;

    fn invoke(tool: &SeherTool, input: serde_json::Value) -> Result<String, String> {
        (tool.handler)(input)
    }

    // -- apply_update (pure) --------------------------------------------------

    #[test]
    fn apply_update_replaces_unique_snippet() {
        let out = apply_update("# Plan\nUse JWT auth.\n", "JWT", "session")
            .unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(out, "# Plan\nUse session auth.\n");
    }

    #[test]
    fn apply_update_errors_when_not_found() {
        match apply_update("# Plan\n", "missing", "x") {
            Err(err) => assert!(err.contains("not found"), "got: {err}"),
            Ok(_) => panic!("expected error for stale snippet"),
        }
    }

    #[test]
    fn apply_update_errors_when_ambiguous() {
        match apply_update("a a", "a", "b") {
            Err(err) => assert!(err.contains("matched"), "got: {err}"),
            Ok(_) => panic!("expected error for ambiguous snippet"),
        }
    }

    #[test]
    fn apply_update_errors_on_empty_old() {
        assert!(apply_update("x", "", "y").is_err());
    }

    // -- submit_plan tool -----------------------------------------------------

    #[test]
    fn submit_plan_writes_content_to_file() {
        let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
        let plan = tmp.path().join("plan.md");
        let tool = submit_plan_tool(plan.clone());
        let res = invoke(&tool, json!({"content": "# My Plan\nstep 1"}));
        assert!(res.is_ok(), "got: {res:?}");
        let written = std::fs::read_to_string(&plan).unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(written, "# My Plan\nstep 1");
    }

    #[test]
    fn submit_plan_errors_without_content() {
        let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
        let tool = submit_plan_tool(tmp.path().join("plan.md"));
        assert!(invoke(&tool, json!({})).is_err());
    }

    // -- update_plan tool -----------------------------------------------------

    #[test]
    fn update_plan_edits_existing_file() {
        let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
        let plan = tmp.path().join("plan.md");
        std::fs::write(&plan, "# Plan\nUse JWT.\n").unwrap_or_else(|e| panic!("{e:?}"));
        let tool = update_plan_tool(plan.clone());
        let res = invoke(&tool, json!({"old": "JWT", "new": "sessions"}));
        assert!(res.is_ok(), "got: {res:?}");
        let written = std::fs::read_to_string(&plan).unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(written, "# Plan\nUse sessions.\n");
    }

    #[test]
    fn update_plan_errors_on_stale_old() {
        let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
        let plan = tmp.path().join("plan.md");
        std::fs::write(&plan, "# Plan\n").unwrap_or_else(|e| panic!("{e:?}"));
        let tool = update_plan_tool(plan);
        let res = invoke(&tool, json!({"old": "nope", "new": "x"}));
        assert!(
            res.is_err(),
            "stale old should error so the agent can retry"
        );
    }

    // -- ask_user tool --------------------------------------------------------

    #[test]
    fn ask_user_delegates_to_handler() {
        let ask = Arc::new(ScriptedAskHandler::new(["the answer".to_string()]));
        let tool = ask_user_tool(ask);
        let res = invoke(&tool, json!({"question": "what?"}));
        assert_eq!(res.unwrap_or_else(|e| panic!("{e}")), "the answer");
    }

    #[test]
    fn ask_user_errors_without_question() {
        let ask = Arc::new(ScriptedAskHandler::new(["x".to_string()]));
        let tool = ask_user_tool(ask);
        assert!(invoke(&tool, json!({})).is_err());
    }

    // -- planning_tools set ---------------------------------------------------

    #[test]
    fn planning_tools_interactive_has_three() {
        let ask = Arc::new(ScriptedAskHandler::new(std::iter::empty()));
        let tools = planning_tools(PathBuf::from("/tmp/plan.md"), ask, true);
        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(
            names,
            vec![ASK_USER_TOOL, SUBMIT_PLAN_TOOL, UPDATE_PLAN_TOOL]
        );
    }

    #[test]
    fn planning_tools_noninteractive_has_submit_only() {
        let ask = Arc::new(ScriptedAskHandler::new(std::iter::empty()));
        let tools = planning_tools(PathBuf::from("/tmp/plan.md"), ask, false);
        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(names, vec![SUBMIT_PLAN_TOOL]);
    }
}