Skip to main content

megacode_agent_sdk/
options.rs

1//! A session's options (spec slice-ar R2.2, R8.2): the fields of the options
2//! file, plus where and how the session runs.
3
4use std::collections::BTreeMap;
5use std::ffi::OsString;
6use std::future::Future;
7use std::path::PathBuf;
8use std::pin::Pin;
9use std::sync::Arc;
10use std::time::Duration;
11
12use serde_json::{Map, Value, json};
13
14use crate::tools::Tool;
15use crate::wire::{ApprovalRequest, Decision};
16
17/// The approval callback, its future boxed.
18pub(crate) type ApprovalCallback = Arc<
19    dyn Fn(ApprovalRequest) -> Pin<Box<dyn Future<Output = Decision> + Send + 'static>>
20        + Send
21        + Sync,
22>;
23
24/// The mode a session starts in (R2.2 `mode`).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Mode {
27    /// Every gated call asks.
28    Manual,
29    /// The judge decides what it can.
30    Auto,
31    /// Read-only planning.
32    Plan,
33}
34
35/// Where built-in commands run (R2.2 `sandbox`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Sandbox {
38    /// harness's own sandbox.
39    On,
40    /// No sandbox.
41    Off,
42    /// The program provides one around the session.
43    External,
44}
45
46/// How the program's text forms the system prompt (R2.3).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum SystemPrompt {
49    /// The text is the prompt, with only the capability blocks the session
50    /// enabled.
51    Replace(String),
52    /// harness's own prompt, then the text.
53    Append(String),
54}
55
56/// The options a session starts (or resumes) with. Every field left unset
57/// takes R2.2's default, which switches off everything personal.
58#[derive(Clone, Default)]
59pub struct Options {
60    model: Option<String>,
61    thinking: Option<String>,
62    mode: Option<Mode>,
63    sandbox: Option<Sandbox>,
64    builtin_tools: Option<Vec<String>>,
65    system_prompt: Option<SystemPrompt>,
66    project_instructions: Option<bool>,
67    user_config: Option<bool>,
68    skills: Option<bool>,
69    agents: Option<bool>,
70    hooks: Option<bool>,
71    mcp_servers: BTreeMap<String, Value>,
72    /// `Some(None)` is `"off"`.
73    approval_timeout: Option<Option<Duration>>,
74    allow_escapes: Option<bool>,
75    max_iterations: Option<u32>,
76    pub(crate) cwd: Option<PathBuf>,
77    pub(crate) env: Vec<(OsString, OsString)>,
78    pub(crate) binary: Option<PathBuf>,
79    pub(crate) mock: Option<PathBuf>,
80    pub(crate) tools: Vec<Tool>,
81    pub(crate) on_approval: Option<ApprovalCallback>,
82}
83
84/// The environment's values are left out: they may hold keys.
85impl std::fmt::Debug for Options {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        let env: Vec<_> = self.env.iter().map(|(k, _)| k).collect();
88        f.debug_struct("Options")
89            .field("model", &self.model)
90            .field("thinking", &self.thinking)
91            .field("mode", &self.mode)
92            .field("sandbox", &self.sandbox)
93            .field("builtin_tools", &self.builtin_tools)
94            .field("system_prompt", &self.system_prompt)
95            .field("project_instructions", &self.project_instructions)
96            .field("user_config", &self.user_config)
97            .field("skills", &self.skills)
98            .field("agents", &self.agents)
99            .field("hooks", &self.hooks)
100            .field("mcp_servers", &self.mcp_servers)
101            .field("approval_timeout", &self.approval_timeout)
102            .field("allow_escapes", &self.allow_escapes)
103            .field("max_iterations", &self.max_iterations)
104            .field("cwd", &self.cwd)
105            .field("env (names only)", &env)
106            .field("binary", &self.binary)
107            .field("mock", &self.mock)
108            .field("tools", &self.tools)
109            .field("on_approval", &self.on_approval.is_some())
110            .finish()
111    }
112}
113
114impl Options {
115    /// Every field at its default.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// An agent that is not a coding agent (R4): no built-in tools, and
121    /// `system_prompt` in place of harness's own.
122    pub fn plain(system_prompt: impl Into<String>) -> Self {
123        Self::new()
124            .builtin_tools(Vec::<String>::new())
125            .system_prompt(SystemPrompt::Replace(system_prompt.into()))
126    }
127
128    /// The model, as `--model` takes it.
129    pub fn model(mut self, model: impl Into<String>) -> Self {
130        self.model = Some(model.into());
131        self
132    }
133
134    /// The thinking level, as `--thinking` takes it.
135    pub fn thinking(mut self, thinking: impl Into<String>) -> Self {
136        self.thinking = Some(thinking.into());
137        self
138    }
139
140    /// The starting mode; it governs the built-in tools.
141    pub fn mode(mut self, mode: Mode) -> Self {
142        self.mode = Some(mode);
143        self
144    }
145
146    /// The sandbox for built-in commands.
147    pub fn sandbox(mut self, sandbox: Sandbox) -> Self {
148        self.sandbox = Some(sandbox);
149        self
150    }
151
152    /// The most built-in tools the session ever offers (R2.2.3); empty
153    /// offers none.
154    pub fn builtin_tools<I, S>(mut self, names: I) -> Self
155    where
156        I: IntoIterator<Item = S>,
157        S: Into<String>,
158    {
159        self.builtin_tools = Some(names.into_iter().map(Into::into).collect());
160        self
161    }
162
163    /// The system prompt policy (R2.3).
164    pub fn system_prompt(mut self, prompt: SystemPrompt) -> Self {
165        self.system_prompt = Some(prompt);
166        self
167    }
168
169    /// Read the project's `AGENTS.md` / `CLAUDE.md`.
170    pub fn project_instructions(mut self, on: bool) -> Self {
171        self.project_instructions = Some(on);
172        self
173    }
174
175    /// Read the user's own setup (config, account, saved model).
176    pub fn user_config(mut self, on: bool) -> Self {
177        self.user_config = Some(on);
178        self
179    }
180
181    /// Discover skills.
182    pub fn skills(mut self, on: bool) -> Self {
183        self.skills = Some(on);
184        self
185    }
186
187    /// Discover agent definitions.
188    pub fn agents(mut self, on: bool) -> Self {
189        self.agents = Some(on);
190        self
191    }
192
193    /// Run hooks from config; needs `user_config(true)`.
194    pub fn hooks(mut self, on: bool) -> Self {
195        self.hooks = Some(on);
196        self
197    }
198
199    /// One MCP server for the session, in the `[mcp.servers]` table's shape
200    /// as JSON.
201    pub fn mcp_server(mut self, name: impl Into<String>, server: Value) -> Self {
202        self.mcp_servers.insert(name.into(), server);
203        self
204    }
205
206    /// How long an approval request waits for an answer; `None` waits for
207    /// ever. Sent in whole seconds, rounded up, at least one.
208    pub fn approval_timeout(mut self, timeout: Option<Duration>) -> Self {
209        self.approval_timeout = Some(timeout);
210        self
211    }
212
213    /// As `--allow-escapes`.
214    pub fn allow_escapes(mut self, on: bool) -> Self {
215        self.allow_escapes = Some(on);
216        self
217    }
218
219    /// As `--max-iterations`.
220    pub fn max_iterations(mut self, n: u32) -> Self {
221        self.max_iterations = Some(n);
222        self
223    }
224
225    /// The session's working folder.
226    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
227        self.cwd = Some(dir.into());
228        self
229    }
230
231    /// A variable added to the session's environment.
232    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
233        self.env.push((key.into(), value.into()));
234        self
235    }
236
237    /// Use this harness binary (R12.1).
238    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
239        self.binary = Some(path.into());
240        self
241    }
242
243    /// Answer the model from this mock file, with no key and no network (R5).
244    pub fn mock(mut self, path: impl Into<PathBuf>) -> Self {
245        self.mock = Some(path.into());
246        self
247    }
248
249    /// A tool the program runs (R8.4), offered to the model after the
250    /// built-in tools, in the order added.
251    pub fn tool(mut self, tool: Tool) -> Self {
252        self.tools.push(tool);
253        self
254    }
255
256    /// Answers every approval request with `callback`'s decision (R8.3.2);
257    /// the requests are then not yielded as events. A callback that panics,
258    /// or returns a decision not among the request's `choices`, answers
259    /// `Deny` with the failure as the reason.
260    pub fn on_approval<F, Fut>(mut self, callback: F) -> Self
261    where
262        F: Fn(ApprovalRequest) -> Fut + Send + Sync + 'static,
263        Fut: Future<Output = Decision> + Send + 'static,
264    {
265        self.on_approval = Some(Arc::new(move |request| Box::pin(callback(request))));
266        self
267    }
268
269    /// The options file's object. A resume sends only what R2.5.3 lets a
270    /// resume give: `model`, `thinking`, `max_iterations`,
271    /// `approval_timeout`, `allow_escapes`, and the session's tools when
272    /// there are any (which harness checks against the log). It never sends
273    /// `mcp_servers`: harness restores the session's servers from the log.
274    pub(crate) fn file_json(&self, resume: bool) -> Value {
275        let mut m = Map::new();
276        if let Some(v) = &self.model {
277            m.insert("model".into(), json!(v));
278        }
279        if let Some(v) = &self.thinking {
280            m.insert("thinking".into(), json!(v));
281        }
282        if let Some(v) = self.max_iterations {
283            m.insert("max_iterations".into(), json!(v));
284        }
285        if let Some(v) = self.approval_timeout {
286            let text = match v {
287                None => "off".to_string(),
288                Some(d) => format!("{}s", crate::tools::whole_seconds(d)),
289            };
290            m.insert("approval_timeout".into(), json!(text));
291        }
292        if let Some(v) = self.allow_escapes {
293            m.insert("allow_escapes".into(), json!(v));
294        }
295        if !self.tools.is_empty() {
296            let tools: Vec<Value> = self.tools.iter().map(Tool::definition).collect();
297            m.insert("client_tools".into(), Value::Array(tools));
298        }
299        if resume {
300            return Value::Object(m);
301        }
302        if !self.mcp_servers.is_empty() {
303            m.insert("mcp_servers".into(), json!(self.mcp_servers));
304        }
305        if let Some(v) = self.mode {
306            let name = match v {
307                Mode::Manual => "manual",
308                Mode::Auto => "auto",
309                Mode::Plan => "plan",
310            };
311            m.insert("mode".into(), json!(name));
312        }
313        if let Some(v) = self.sandbox {
314            let name = match v {
315                Sandbox::On => "on",
316                Sandbox::Off => "off",
317                Sandbox::External => "external",
318            };
319            m.insert("sandbox".into(), json!(name));
320        }
321        if let Some(v) = &self.builtin_tools {
322            m.insert("builtin_tools".into(), json!(v));
323        }
324        if let Some(v) = &self.system_prompt {
325            let v = match v {
326                SystemPrompt::Replace(t) => json!({ "replace": t }),
327                SystemPrompt::Append(t) => json!({ "append": t }),
328            };
329            m.insert("system_prompt".into(), v);
330        }
331        for (name, v) in [
332            ("project_instructions", self.project_instructions),
333            ("user_config", self.user_config),
334            ("skills", self.skills),
335            ("agents", self.agents),
336            ("hooks", self.hooks),
337        ] {
338            if let Some(v) = v {
339                m.insert(name.into(), json!(v));
340            }
341        }
342        Value::Object(m)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn a_resume_sends_only_what_a_resume_may_give() {
352        let o = Options::plain("be brief")
353            .mode(Mode::Plan)
354            .sandbox(Sandbox::Off)
355            .skills(true)
356            .model("m")
357            .approval_timeout(None)
358            .max_iterations(3)
359            .mcp_server("files", json!({ "command": ["true"] }));
360        assert_eq!(
361            o.file_json(true),
362            json!({"model": "m", "approval_timeout": "off", "max_iterations": 3})
363        );
364        assert_eq!(
365            o.file_json(false),
366            json!({
367                "model": "m", "approval_timeout": "off", "max_iterations": 3,
368                "mode": "plan", "sandbox": "off", "builtin_tools": [],
369                "system_prompt": {"replace": "be brief"}, "skills": true,
370                "mcp_servers": {"files": {"command": ["true"]}},
371            })
372        );
373    }
374
375    #[test]
376    fn debug_names_the_environment_but_never_its_values() {
377        let o = Options::new().env("ANTHROPIC_API_KEY", "sk-secret-value");
378        let shown = format!("{o:?}");
379        assert!(shown.contains("ANTHROPIC_API_KEY"), "{shown}");
380        assert!(!shown.contains("sk-secret-value"), "{shown}");
381    }
382
383    #[test]
384    fn an_approval_timeout_is_whole_seconds_rounded_up() {
385        let o = Options::new().approval_timeout(Some(Duration::from_millis(1500)));
386        assert_eq!(o.file_json(false), json!({"approval_timeout": "2s"}));
387        let o = Options::new().approval_timeout(Some(Duration::from_millis(10)));
388        assert_eq!(o.file_json(false), json!({"approval_timeout": "1s"}));
389    }
390}