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}