megacode-agent-sdk 0.32.0

Drive a MEGA harness agent session from a Rust program.
Documentation
//! A session's options (spec slice-ar R2.2, R8.2): the fields of the options
//! file, plus where and how the session runs.

use std::collections::BTreeMap;
use std::ffi::OsString;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

use serde_json::{Map, Value, json};

use crate::tools::Tool;
use crate::wire::{ApprovalRequest, Decision};

/// The approval callback, its future boxed.
pub(crate) type ApprovalCallback = Arc<
    dyn Fn(ApprovalRequest) -> Pin<Box<dyn Future<Output = Decision> + Send + 'static>>
        + Send
        + Sync,
>;

/// The mode a session starts in (R2.2 `mode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    /// Every gated call asks.
    Manual,
    /// The judge decides what it can.
    Auto,
    /// Read-only planning.
    Plan,
}

/// Where built-in commands run (R2.2 `sandbox`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sandbox {
    /// harness's own sandbox.
    On,
    /// No sandbox.
    Off,
    /// The program provides one around the session.
    External,
}

/// How the program's text forms the system prompt (R2.3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemPrompt {
    /// The text is the prompt, with only the capability blocks the session
    /// enabled.
    Replace(String),
    /// harness's own prompt, then the text.
    Append(String),
}

/// The options a session starts (or resumes) with. Every field left unset
/// takes R2.2's default, which switches off everything personal.
#[derive(Clone, Default)]
pub struct Options {
    model: Option<String>,
    thinking: Option<String>,
    mode: Option<Mode>,
    sandbox: Option<Sandbox>,
    builtin_tools: Option<Vec<String>>,
    system_prompt: Option<SystemPrompt>,
    project_instructions: Option<bool>,
    user_config: Option<bool>,
    skills: Option<bool>,
    agents: Option<bool>,
    hooks: Option<bool>,
    mcp_servers: BTreeMap<String, Value>,
    /// `Some(None)` is `"off"`.
    approval_timeout: Option<Option<Duration>>,
    allow_escapes: Option<bool>,
    max_iterations: Option<u32>,
    pub(crate) cwd: Option<PathBuf>,
    pub(crate) env: Vec<(OsString, OsString)>,
    pub(crate) binary: Option<PathBuf>,
    pub(crate) mock: Option<PathBuf>,
    pub(crate) tools: Vec<Tool>,
    pub(crate) on_approval: Option<ApprovalCallback>,
}

/// The environment's values are left out: they may hold keys.
impl std::fmt::Debug for Options {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let env: Vec<_> = self.env.iter().map(|(k, _)| k).collect();
        f.debug_struct("Options")
            .field("model", &self.model)
            .field("thinking", &self.thinking)
            .field("mode", &self.mode)
            .field("sandbox", &self.sandbox)
            .field("builtin_tools", &self.builtin_tools)
            .field("system_prompt", &self.system_prompt)
            .field("project_instructions", &self.project_instructions)
            .field("user_config", &self.user_config)
            .field("skills", &self.skills)
            .field("agents", &self.agents)
            .field("hooks", &self.hooks)
            .field("mcp_servers", &self.mcp_servers)
            .field("approval_timeout", &self.approval_timeout)
            .field("allow_escapes", &self.allow_escapes)
            .field("max_iterations", &self.max_iterations)
            .field("cwd", &self.cwd)
            .field("env (names only)", &env)
            .field("binary", &self.binary)
            .field("mock", &self.mock)
            .field("tools", &self.tools)
            .field("on_approval", &self.on_approval.is_some())
            .finish()
    }
}

impl Options {
    /// Every field at its default.
    pub fn new() -> Self {
        Self::default()
    }

    /// An agent that is not a coding agent (R4): no built-in tools, and
    /// `system_prompt` in place of harness's own.
    pub fn plain(system_prompt: impl Into<String>) -> Self {
        Self::new()
            .builtin_tools(Vec::<String>::new())
            .system_prompt(SystemPrompt::Replace(system_prompt.into()))
    }

    /// The model, as `--model` takes it.
    pub fn model(mut self, model: impl Into<String>) -> Self {
        self.model = Some(model.into());
        self
    }

    /// The thinking level, as `--thinking` takes it.
    pub fn thinking(mut self, thinking: impl Into<String>) -> Self {
        self.thinking = Some(thinking.into());
        self
    }

    /// The starting mode; it governs the built-in tools.
    pub fn mode(mut self, mode: Mode) -> Self {
        self.mode = Some(mode);
        self
    }

    /// The sandbox for built-in commands.
    pub fn sandbox(mut self, sandbox: Sandbox) -> Self {
        self.sandbox = Some(sandbox);
        self
    }

    /// The most built-in tools the session ever offers (R2.2.3); empty
    /// offers none.
    pub fn builtin_tools<I, S>(mut self, names: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.builtin_tools = Some(names.into_iter().map(Into::into).collect());
        self
    }

    /// The system prompt policy (R2.3).
    pub fn system_prompt(mut self, prompt: SystemPrompt) -> Self {
        self.system_prompt = Some(prompt);
        self
    }

    /// Read the project's `AGENTS.md` / `CLAUDE.md`.
    pub fn project_instructions(mut self, on: bool) -> Self {
        self.project_instructions = Some(on);
        self
    }

    /// Read the user's own setup (config, account, saved model).
    pub fn user_config(mut self, on: bool) -> Self {
        self.user_config = Some(on);
        self
    }

    /// Discover skills.
    pub fn skills(mut self, on: bool) -> Self {
        self.skills = Some(on);
        self
    }

    /// Discover agent definitions.
    pub fn agents(mut self, on: bool) -> Self {
        self.agents = Some(on);
        self
    }

    /// Run hooks from config; needs `user_config(true)`.
    pub fn hooks(mut self, on: bool) -> Self {
        self.hooks = Some(on);
        self
    }

    /// One MCP server for the session, in the `[mcp.servers]` table's shape
    /// as JSON.
    pub fn mcp_server(mut self, name: impl Into<String>, server: Value) -> Self {
        self.mcp_servers.insert(name.into(), server);
        self
    }

    /// How long an approval request waits for an answer; `None` waits for
    /// ever. Sent in whole seconds, rounded up, at least one.
    pub fn approval_timeout(mut self, timeout: Option<Duration>) -> Self {
        self.approval_timeout = Some(timeout);
        self
    }

    /// As `--allow-escapes`.
    pub fn allow_escapes(mut self, on: bool) -> Self {
        self.allow_escapes = Some(on);
        self
    }

    /// As `--max-iterations`.
    pub fn max_iterations(mut self, n: u32) -> Self {
        self.max_iterations = Some(n);
        self
    }

    /// The session's working folder.
    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
        self.cwd = Some(dir.into());
        self
    }

    /// A variable added to the session's environment.
    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
        self.env.push((key.into(), value.into()));
        self
    }

    /// Use this harness binary (R12.1).
    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
        self.binary = Some(path.into());
        self
    }

    /// Answer the model from this mock file, with no key and no network (R5).
    pub fn mock(mut self, path: impl Into<PathBuf>) -> Self {
        self.mock = Some(path.into());
        self
    }

    /// A tool the program runs (R8.4), offered to the model after the
    /// built-in tools, in the order added.
    pub fn tool(mut self, tool: Tool) -> Self {
        self.tools.push(tool);
        self
    }

    /// Answers every approval request with `callback`'s decision (R8.3.2);
    /// the requests are then not yielded as events. A callback that panics,
    /// or returns a decision not among the request's `choices`, answers
    /// `Deny` with the failure as the reason.
    pub fn on_approval<F, Fut>(mut self, callback: F) -> Self
    where
        F: Fn(ApprovalRequest) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Decision> + Send + 'static,
    {
        self.on_approval = Some(Arc::new(move |request| Box::pin(callback(request))));
        self
    }

    /// The options file's object. A resume sends only what R2.5.3 lets a
    /// resume give: `model`, `thinking`, `max_iterations`,
    /// `approval_timeout`, `allow_escapes`, and the session's tools when
    /// there are any (which harness checks against the log). It never sends
    /// `mcp_servers`: harness restores the session's servers from the log.
    pub(crate) fn file_json(&self, resume: bool) -> Value {
        let mut m = Map::new();
        if let Some(v) = &self.model {
            m.insert("model".into(), json!(v));
        }
        if let Some(v) = &self.thinking {
            m.insert("thinking".into(), json!(v));
        }
        if let Some(v) = self.max_iterations {
            m.insert("max_iterations".into(), json!(v));
        }
        if let Some(v) = self.approval_timeout {
            let text = match v {
                None => "off".to_string(),
                Some(d) => format!("{}s", crate::tools::whole_seconds(d)),
            };
            m.insert("approval_timeout".into(), json!(text));
        }
        if let Some(v) = self.allow_escapes {
            m.insert("allow_escapes".into(), json!(v));
        }
        if !self.tools.is_empty() {
            let tools: Vec<Value> = self.tools.iter().map(Tool::definition).collect();
            m.insert("client_tools".into(), Value::Array(tools));
        }
        if resume {
            return Value::Object(m);
        }
        if !self.mcp_servers.is_empty() {
            m.insert("mcp_servers".into(), json!(self.mcp_servers));
        }
        if let Some(v) = self.mode {
            let name = match v {
                Mode::Manual => "manual",
                Mode::Auto => "auto",
                Mode::Plan => "plan",
            };
            m.insert("mode".into(), json!(name));
        }
        if let Some(v) = self.sandbox {
            let name = match v {
                Sandbox::On => "on",
                Sandbox::Off => "off",
                Sandbox::External => "external",
            };
            m.insert("sandbox".into(), json!(name));
        }
        if let Some(v) = &self.builtin_tools {
            m.insert("builtin_tools".into(), json!(v));
        }
        if let Some(v) = &self.system_prompt {
            let v = match v {
                SystemPrompt::Replace(t) => json!({ "replace": t }),
                SystemPrompt::Append(t) => json!({ "append": t }),
            };
            m.insert("system_prompt".into(), v);
        }
        for (name, v) in [
            ("project_instructions", self.project_instructions),
            ("user_config", self.user_config),
            ("skills", self.skills),
            ("agents", self.agents),
            ("hooks", self.hooks),
        ] {
            if let Some(v) = v {
                m.insert(name.into(), json!(v));
            }
        }
        Value::Object(m)
    }
}

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

    #[test]
    fn a_resume_sends_only_what_a_resume_may_give() {
        let o = Options::plain("be brief")
            .mode(Mode::Plan)
            .sandbox(Sandbox::Off)
            .skills(true)
            .model("m")
            .approval_timeout(None)
            .max_iterations(3)
            .mcp_server("files", json!({ "command": ["true"] }));
        assert_eq!(
            o.file_json(true),
            json!({"model": "m", "approval_timeout": "off", "max_iterations": 3})
        );
        assert_eq!(
            o.file_json(false),
            json!({
                "model": "m", "approval_timeout": "off", "max_iterations": 3,
                "mode": "plan", "sandbox": "off", "builtin_tools": [],
                "system_prompt": {"replace": "be brief"}, "skills": true,
                "mcp_servers": {"files": {"command": ["true"]}},
            })
        );
    }

    #[test]
    fn debug_names_the_environment_but_never_its_values() {
        let o = Options::new().env("ANTHROPIC_API_KEY", "sk-secret-value");
        let shown = format!("{o:?}");
        assert!(shown.contains("ANTHROPIC_API_KEY"), "{shown}");
        assert!(!shown.contains("sk-secret-value"), "{shown}");
    }

    #[test]
    fn an_approval_timeout_is_whole_seconds_rounded_up() {
        let o = Options::new().approval_timeout(Some(Duration::from_millis(1500)));
        assert_eq!(o.file_json(false), json!({"approval_timeout": "2s"}));
        let o = Options::new().approval_timeout(Some(Duration::from_millis(10)));
        assert_eq!(o.file_json(false), json!({"approval_timeout": "1s"}));
    }
}