Skip to main content

roba_core/
engine.rs

1//! The internal config-and-run seam (#407).
2//!
3//! [`run`] is roba's resolve-free, side-effect-free core: a [`Config`] in, a
4//! [`claude_wrapper::types::QueryResult`] out. No clap, no stdout/stderr, no
5//! `process::exit`, no TTY, no interactive prompts. The CLI's `run_ask`
6//! resolves flags/profiles/prompt and renders the result around this; a
7//! programmatic caller (or, later, `serve` -- see #142) builds a `Config`
8//! directly.
9//!
10//! `Config` covers the full run-shaping surface that [`apply_session`]
11//! consumes -- model/fallback/effort/agent, the permission posture +
12//! `--permission-mode`, session continuity, worktree, caps, timeout, the
13//! system-prompt overrides + the agent notice, retry/persistence/bare/safe
14//! modes, `--add-dir`, and MCP config. It feeds the proven `apply_session`
15//! mapper directly (`apply_session` reads `&Config`), so there is one
16//! flag->command mapper and no second copy to drift; a bare `Config` maps to
17//! roba's safe defaults (read-only, a fresh session, no caps, and the built-in
18//! agent notice injected). The CLI's `run_ask` builds a `Config` from its
19//! resolved `AskArgs` (`build_config`) and routes through this same seam.
20//!
21//! Out of scope for v1 (stays in the CLI layer): prompt composition
22//! (attach/git/prepend/vars -- `Config` takes the already-composed prompt),
23//! profile/env layering, live streaming display, output formatting, and
24//! exit-code classification.
25
26use anyhow::Result;
27use claude_wrapper::types::QueryResult;
28use claude_wrapper::{Claude, Effort, PermissionMode, QueryCommand};
29
30use crate::session::{apply_session, derive_session_name};
31
32/// What to do about session continuity, mirroring the CLI's `-c` / `--resume`
33/// / `--session-id` / `--fresh` selectors as one closed choice.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub enum Session {
36    /// Start a new session (roba's default).
37    #[default]
38    Fresh,
39    /// Continue the most recent session in the working directory.
40    Continue,
41    /// Resume a specific existing session by id.
42    Resume(String),
43    /// Start a new session with a caller-chosen id (for later re-attachment).
44    WithId(String),
45}
46
47/// The permission posture for a run. Mirrors roba's safe-by-default model:
48/// the default is read-only (Read/Glob/Grep), and the variants open it up.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub enum Permissions {
51    /// Read-only: Read, Glob, Grep only (roba's default).
52    #[default]
53    ReadOnly,
54    /// Read-only plus Edit + Write.
55    Writable,
56    /// Bypass all permission checks (sandbox / unattended-worker use only).
57    FullAuto,
58}
59
60/// A config-and-run request: everything [`run`] needs to send one prompt
61/// through claude and hand back the typed result. The `prompt` is the final,
62/// already-composed text (the CLI does attach/git/var composition before
63/// building this).
64#[derive(Debug, Clone, Default)]
65pub struct Config {
66    /// The fully-composed prompt to send.
67    pub prompt: String,
68    /// Override the model for this run (`None` = claude's default).
69    pub model: Option<String>,
70    /// Fallback model when the primary is overloaded (`--fallback-model`).
71    pub fallback_model: Option<String>,
72    /// Reasoning-effort level (`None` = claude's default). The CLI maps its
73    /// `--effort` value to this claude-wrapper type in `build_config`.
74    pub effort: Option<Effort>,
75    /// Run under a named subagent definition (`--agent`).
76    pub agent: Option<String>,
77    /// Permission posture (default read-only).
78    pub permissions: Permissions,
79    /// A specific claude permission mode composed on top of the posture. `None`
80    /// leaves claude's default. Ignored under [`Permissions::FullAuto`], which
81    /// bypasses all checks before the mode is read. The CLI maps its
82    /// `--permission-mode` value to this claude-wrapper type in `build_config`.
83    pub permission_mode: Option<PermissionMode>,
84    /// Extra allowed tool patterns layered on top of the posture.
85    pub allow_tools: Vec<String>,
86    /// Tool patterns to block (ignored under [`Permissions::FullAuto`]).
87    pub deny_tools: Vec<String>,
88    /// Session continuity (default a fresh session).
89    pub session: Session,
90    /// Branch the resumed session instead of continuing it in place. Only
91    /// meaningful with [`Session::Resume`].
92    pub fork: bool,
93    /// Run in a git worktree: `None` = no worktree, `Some(None)` = a fresh
94    /// anonymous worktree, `Some(Some(name))` = a named worktree.
95    pub worktree: Option<Option<String>>,
96    /// Cap the agentic turn count (`None` = uncapped).
97    pub max_turns: Option<u32>,
98    /// Cap total spend in USD (`None` = uncapped).
99    pub max_budget_usd: Option<f64>,
100    /// Wall-clock deadline in seconds (`None` or `0` = no deadline).
101    pub timeout_secs: Option<u64>,
102    /// Constrain output to a JSON Schema. The value is the inline schema
103    /// JSON (not a path -- the CLI's path-reading sugar is its own concern).
104    pub json_schema: Option<String>,
105    /// Replace claude's system prompt (`--system-prompt`). `None` keeps it.
106    pub system_prompt: Option<String>,
107    /// Append to the system prompt (`--append-system-prompt`); composed with
108    /// the agent notice below into one appended block.
109    pub append_system_prompt: Option<String>,
110    /// Override the built-in single-turn agent-notice text (`--agent-notice`).
111    /// `Some("")` disables the notice (as does `no_agent_notice`); `None` uses
112    /// the built-in text.
113    pub agent_notice: Option<String>,
114    /// Suppress the built-in single-turn agent notice (`--no-agent-notice`).
115    /// Default `false` -- the notice is injected, faithful to the CLI default.
116    pub no_agent_notice: bool,
117    /// Fail fast on a transient error instead of retrying (`--no-retry`): a
118    /// single attempt, no backoff.
119    pub no_retry: bool,
120    /// Do not persist the session to claude's history
121    /// (`--no-session-persistence`).
122    pub no_session_persistence: bool,
123    /// Bare mode (`--bare`): the minimal claude invocation.
124    pub bare: bool,
125    /// Safe mode (`--safe-mode`): claude's extra guardrails.
126    pub safe_mode: bool,
127    /// Extra tool-access directories (`--add-dir`), forwarded verbatim.
128    pub add_dir: Vec<String>,
129    /// MCP server config files (`--mcp-config`), forwarded verbatim.
130    pub mcp_config: Vec<String>,
131    /// Use only the servers in `mcp_config`, ignoring other MCP sources
132    /// (`--strict-mcp-config`).
133    pub strict_mcp_config: bool,
134}
135
136impl Config {
137    /// Construct a `Config` for a plain prompt; chain the setters for the rest.
138    pub fn new(prompt: impl Into<String>) -> Self {
139        Self {
140            prompt: prompt.into(),
141            ..Self::default()
142        }
143    }
144}
145
146/// Run one prompt through claude under `config` and return the typed result.
147///
148/// Side-effect-free: no printing, no exit, no TTY. Builds the `Claude` client
149/// (honouring `timeout_secs`), maps `config` onto a `QueryCommand` via the
150/// shared [`apply_session`], executes, and surfaces schema-constrained output
151/// the same way the CLI does. Errors propagate as `anyhow` (the CLI maps them
152/// to typed exit codes; a programmatic caller inspects them directly).
153pub async fn run(config: &Config) -> Result<QueryResult> {
154    let mut builder = Claude::builder();
155    if let Some(secs) = config.timeout_secs
156        && secs > 0
157    {
158        builder = builder.timeout_secs(secs);
159    }
160    let claude = builder.build()?;
161
162    let mut result = execute(config, &claude).await?;
163
164    // Parity with run_ask: with a schema active, surface the structured answer
165    // onto `structured_output` / an unfenced `result` (see #317).
166    if config.json_schema.is_some() {
167        surface_structured_output(&mut result);
168    }
169    Ok(result)
170}
171
172/// Build the `QueryCommand` for `config` -- through the one proven
173/// [`apply_session`] mapper -- and run it non-streaming, returning the typed
174/// result. The single build+execute path shared by [`run`] and the CLI's
175/// non-streaming branch (`run_ask`), which both hand this the same [`Config`],
176/// so there is no parallel copy to drift.
177///
178/// Schema surfacing is the caller's job -- the CLI shares it with its `--trace`
179/// path, and [`run`] does it after this returns.
180pub async fn execute(config: &Config, claude: &Claude) -> Result<QueryResult> {
181    let name = derive_session_name(&config.prompt);
182    let result = apply_session(
183        QueryCommand::new(config.prompt.clone())
184            .name(name)
185            .prompt_via_stdin(true),
186        config,
187    )
188    .execute_json(claude)
189    .await?;
190    Ok(result)
191}
192
193/// Surface `--json-schema` structured output cleanly (#317).
194///
195/// In practice claude delivers the schema-constrained answer as a fenced
196/// ```` ```json ```` block inside `result.result` and does NOT populate a
197/// `structured_output` field. This normalizes that: when the body is a
198/// single fenced block whose contents parse as JSON, the parsed value is
199/// inserted under `result.extra["structured_output"]` (so `--json`
200/// consumers read `.result.structured_output` directly) and
201/// `result.result` is replaced with the unfenced text (so
202/// `.result.result` and the default text body are clean too).
203///
204/// No-ops when `structured_output` is already present and non-null (a
205/// future wrapper/claude version may surface it natively), or when the
206/// body is not a single parseable fenced JSON block. The caller gates
207/// this on `--json-schema` so a normal answer containing a JSON code
208/// block is never reinterpreted as structured output.
209pub fn surface_structured_output(result: &mut QueryResult) {
210    if result
211        .extra
212        .get("structured_output")
213        .is_some_and(|v| !v.is_null())
214    {
215        return;
216    }
217    let Some(inner) = unfence_single_block(&result.result) else {
218        return;
219    };
220    let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner) else {
221        return;
222    };
223    result.extra.insert("structured_output".to_string(), value);
224    result.result = inner;
225}
226
227/// If `text` is a single fenced code block -- it opens with a line
228/// starting ```` ``` ```` (optionally a language tag) and closes with a
229/// bare ```` ``` ```` line, with nothing outside the fence -- return the
230/// inner content (trimmed). Returns `None` otherwise, so a normal answer
231/// that merely contains a code block mid-paragraph is left untouched.
232fn unfence_single_block(text: &str) -> Option<String> {
233    let trimmed = text.trim();
234    let mut lines = trimmed.lines();
235    let first = lines.next()?;
236    if !first.starts_with("```") {
237        return None;
238    }
239    let rest: Vec<&str> = lines.collect();
240    let (last, body) = rest.split_last()?;
241    if last.trim() != "```" {
242        return None;
243    }
244    Some(body.join("\n").trim().to_string())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn config_new_defaults_are_safe() {
253        // A bare Config is roba's safe default: read-only, a fresh session, no
254        // caps, the agent notice on. The Config -> QueryCommand mapping is
255        // exercised via apply_session (session.rs); the AskArgs -> Config
256        // mapping via build_config (lib.rs).
257        let c = Config::new("hi");
258        assert_eq!(c.prompt, "hi");
259        assert!(matches!(c.permissions, Permissions::ReadOnly));
260        assert!(matches!(c.session, Session::Fresh));
261        assert!(!c.fork && c.worktree.is_none());
262        assert!(c.max_turns.is_none() && c.max_budget_usd.is_none());
263        assert!(!c.no_agent_notice && c.agent_notice.is_none());
264    }
265
266    // -- schema output surfacing (#317) ------------------------------------
267
268    fn query_result_body(body: &str) -> QueryResult {
269        serde_json::from_value(serde_json::json!({
270            "result": body,
271            "session_id": "s1",
272            "is_error": false,
273        }))
274        .expect("build QueryResult fixture")
275    }
276
277    #[test]
278    fn surface_structured_output_unfences_json_block() {
279        // The real --json-schema shape (#317): a fenced ```json block in
280        // `result.result`, no `structured_output` key.
281        let mut result = query_result_body("```json\n{\"answer\": \"Paris\"}\n```");
282        surface_structured_output(&mut result);
283        assert_eq!(
284            result.extra.get("structured_output").unwrap()["answer"],
285            "Paris"
286        );
287        // result.result is unfenced so `.result.result` reads cleanly.
288        assert_eq!(result.result, "{\"answer\": \"Paris\"}");
289    }
290
291    #[test]
292    fn surface_structured_output_handles_untagged_fence() {
293        let mut result = query_result_body("```\n[1, 2, 3]\n```");
294        surface_structured_output(&mut result);
295        assert_eq!(
296            result.extra.get("structured_output").unwrap(),
297            &serde_json::json!([1, 2, 3])
298        );
299    }
300
301    #[test]
302    fn surface_structured_output_noop_when_already_present() {
303        // A native structured_output is never clobbered by the body.
304        let mut result: QueryResult = serde_json::from_value(serde_json::json!({
305            "result": "```json\n{\"from\": \"body\"}\n```",
306            "session_id": "s1",
307            "is_error": false,
308            "structured_output": {"from": "native"},
309        }))
310        .expect("build QueryResult fixture");
311        surface_structured_output(&mut result);
312        assert_eq!(
313            result.extra.get("structured_output").unwrap()["from"],
314            "native"
315        );
316        // The native path leaves the body unchanged.
317        assert!(result.result.contains("```"));
318    }
319
320    #[test]
321    fn surface_structured_output_noop_on_non_fenced_text() {
322        let mut result = query_result_body("The answer is 42.");
323        surface_structured_output(&mut result);
324        assert!(!result.extra.contains_key("structured_output"));
325        assert_eq!(result.result, "The answer is 42.");
326    }
327
328    #[test]
329    fn surface_structured_output_noop_when_fence_is_not_json() {
330        // A fenced block that does not parse as JSON is left untouched.
331        let mut result = query_result_body("```rust\nfn main() {}\n```");
332        surface_structured_output(&mut result);
333        assert!(!result.extra.contains_key("structured_output"));
334        assert!(result.result.contains("fn main"));
335    }
336
337    #[test]
338    fn surface_structured_output_noop_on_prose_around_block() {
339        // A real answer that merely contains a JSON block mid-text is not a
340        // single fenced block, so it is not reinterpreted as structured output.
341        let mut result =
342            query_result_body("Here you go:\n```json\n{\"a\": 1}\n```\nHope that helps.");
343        surface_structured_output(&mut result);
344        assert!(!result.extra.contains_key("structured_output"));
345    }
346}