Skip to main content

leviath_cli/commands/run/
mod.rs

1//! `lev run` - run an agent in the shared-world daemon.
2//!
3//! `run` resolves the blueprint + task locally and asks the running daemon (auto-
4//! started if needed) to create the agent in the one shared ECS world. The
5//! request-building + daemon exchange live in [`crate::daemon::client`]; this
6//! module keeps the manifest/session/tool-source helpers still shared across the
7//! CLI, and the `RunArgs` the binary wires into that path.
8
9pub mod manifest;
10pub mod session;
11pub mod task;
12
13use std::collections::HashMap;
14
15use clap::Args;
16
17// Re-export the provider-registry builders used by the daemon setup.
18pub use session::{
19    ProviderCreds, build_provider_registry, build_provider_registry_from_config,
20    provider_creds_from_config,
21};
22
23/// Arguments for `lev run`.
24#[derive(Args, Debug, Clone, Default)]
25pub struct RunArgs {
26    /// Path to the agent (a manifest file, its directory, or an installed name).
27    #[arg(value_name = "PATH")]
28    pub path: Option<String>,
29
30    /// Task prompt, or the path of a file holding it. Left off, your editor
31    /// opens on a template for you to write it in.
32    #[arg(short, long, value_name = "TEXT|FILE")]
33    pub task: Option<String>,
34
35    /// Model override (`provider/model` or a bare model name).
36    #[arg(short, long)]
37    pub model: Option<String>,
38
39    /// Run unattended: approve every tool call, and answer the agent's own
40    /// prompts (ask_user_*, interaction points) instead of waiting for a person.
41    ///
42    /// One exception, and it is the one that looks like a hang: an interaction
43    /// point declaring `unattended = "ask"` still holds for a person. The
44    /// bundled coder's plan approval can, deliberately, because
45    /// everything after it writes code. Such a run parks in `Waiting` until
46    /// `[limits] interaction_timeout_secs` (default 3600) releases it. Lower
47    /// that to bound the wait.
48    #[arg(long)]
49    pub yolo: bool,
50
51    /// Allow a tool outright (repeatable).
52    #[arg(long)]
53    pub allow: Vec<String>,
54
55    /// Override the blueprint's max sub-agent tree depth.
56    #[arg(long)]
57    pub max_depth: Option<usize>,
58
59    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
60    /// shell command at spawn - before the first inference, and so before any
61    /// approval prompt. See `lev validate <path>` to inspect them first.
62    #[arg(long)]
63    pub no_seed_commands: bool,
64
65    /// Working directory for the run (default: the directory `lev run` is
66    /// invoked from). The agent's file tools are confined to it, and relative
67    /// `[read_paths]` entries resolve against it.
68    #[arg(long, value_name = "DIR")]
69    pub workdir: Option<std::path::PathBuf>,
70
71    /// Print the spawned run as JSON instead of a sentence, for a caller that
72    /// has to parse the run id back out and poll `lev ps --json`. With
73    /// `--count` above 1 the JSON is an array, one object per run.
74    #[arg(long)]
75    pub json: bool,
76
77    /// Start this many runs of the same agent and task, each under its own run
78    /// id, from one invocation. One process launch and one socket dial per run
79    /// caps a shell loop near 60 spawns/second; the daemon itself has no run
80    /// cap, and a single invocation carrying the batch spawns as fast as the
81    /// daemon accepts.
82    #[arg(long, value_name = "N", default_value_t = 1)]
83    pub count: usize,
84
85    /// Ask for the final output in a particular shape, overriding whatever the
86    /// blueprint declares. Any label works - `markdown`, `json`, `xml`, `a2ui`,
87    /// a media type, a house format - because nothing converts between shapes:
88    /// the label and any instructions are handed to the model, which produces
89    /// the bytes. Read the answer back with `lev result <run-id>`.
90    ///
91    /// Naming a format without `--output-schema` drops a schema the blueprint
92    /// declared, since a check written for one shape says nothing about another.
93    #[arg(long, value_name = "LABEL")]
94    pub output_format: Option<String>,
95
96    /// Extra guidance about the shape, passed to the model alongside
97    /// `--output-format`. This is how an unusual format gets explained.
98    #[arg(long, value_name = "TEXT")]
99    pub output_instructions: Option<String>,
100
101    /// A JSON Schema (inline, or `@path` to a file) the final output must
102    /// satisfy. The only thing that ever inspects the answer's contents, and it
103    /// only happens because you asked: a submission that fails is refused back
104    /// to the agent to correct.
105    #[arg(long, value_name = "JSON|@FILE")]
106    pub output_schema: Option<String>,
107
108    /// Dynamic per-region seed flags (`--<region> <text|@file>`), collected by an
109    /// argv pre-scan in the binary since region names are blueprint-defined.
110    /// clap skips this field; it is populated after parsing.
111    #[arg(skip)]
112    pub regions: HashMap<String, String>,
113}
114
115/// The `run` subcommand's own long flags - everything NOT in this set is treated
116/// as a dynamic `--<region>` seed flag by [`extract_region_flags`].
117const KNOWN_RUN_FLAGS: &[&str] = &[
118    "task",
119    "model",
120    "yolo",
121    "allow",
122    "max-depth",
123    "no-seed-commands",
124    "workdir",
125    "output-format",
126    "output-instructions",
127    "output-schema",
128    // Every flag `run` owns must be listed here. One that is missing is not a
129    // parse error: the pre-scan silently reads it as a `--<region>` seed and
130    // swallows the token after it.
131    "json",
132    "count",
133    "verbose",
134    "help",
135    "version",
136];
137
138/// Build the caller's requested output shape from the `--output-*` flags, or
139/// `None` when none were given (leaving whatever the blueprint declares).
140///
141/// The format label is passed through untouched and never matched against a
142/// known set, which is what lets `--output-format a2ui` work without a line of
143/// a2ui-specific code. Only `--output-schema` is interpreted, and only as JSON,
144/// because it is the one thing the runtime will actually check.
145pub fn output_request(
146    format: Option<String>,
147    instructions: Option<String>,
148    schema: Option<String>,
149) -> anyhow::Result<Option<leviath_core::output::OutputSpec>> {
150    if format.is_none() && instructions.is_none() && schema.is_none() {
151        return Ok(None);
152    }
153    let schema = match schema {
154        Some(raw) => {
155            let text = task::read_region_value(&raw)?;
156            Some(
157                serde_json::from_str(&text)
158                    .map_err(|e| anyhow::anyhow!("--output-schema is not valid JSON: {e}"))?,
159            )
160        }
161        None => None,
162    };
163    Ok(Some(leviath_core::output::OutputSpec {
164        format,
165        instructions,
166        example: None,
167        schema,
168        validator: None,
169    }))
170}
171
172/// The run's effective working directory: the `--workdir` flag when given
173/// (canonicalized, and refused early when it does not exist - a bad workdir
174/// would otherwise spawn an agent whose every tool call fails), else `cwd`
175/// (the directory the command was invoked from, resolved by the caller).
176pub fn effective_workdir(
177    flag: Option<std::path::PathBuf>,
178    cwd: std::path::PathBuf,
179) -> anyhow::Result<String> {
180    let dir = match flag {
181        Some(dir) => {
182            let canonical = std::fs::canonicalize(&dir).map_err(|e| {
183                anyhow::anyhow!(
184                    "--workdir '{}' is not a usable directory: {e}",
185                    dir.display()
186                )
187            })?;
188            if !canonical.is_dir() {
189                anyhow::bail!("--workdir '{}' is not a directory", dir.display());
190            }
191            canonical
192        }
193        None => cwd,
194    };
195    Ok(dir.to_string_lossy().to_string())
196}
197
198/// Pre-scan a full argv (program name first) for dynamic `--<region>` flags on
199/// the `run` subcommand, since region names are blueprint-defined and clap can't
200/// declare them. Returns `(argv_for_clap, region_flags)`: a `--<name>` (or
201/// `--<name>=<value>`) whose `<name>` is not a known `run` flag is pulled out
202/// (with its value) into the map; every other token passes through untouched.
203///
204/// A no-`=` region flag consumes the following token as its value. If argv has
205/// no `run` subcommand token, nothing is extracted (the returned argv equals the
206/// input). Pure - no environment or I/O - so it is unit-testable in isolation.
207pub fn extract_region_flags(argv: Vec<String>) -> (Vec<String>, HashMap<String, String>) {
208    // Locate the subcommand: the first bareword (non-`-`) token after the program
209    // name. Only activate when it is `run`.
210    let sub_pos = argv
211        .iter()
212        .enumerate()
213        .skip(1)
214        .find(|(_, t)| !t.starts_with('-'))
215        .map(|(i, _)| i);
216    let Some(sub_pos) = sub_pos else {
217        return (argv, HashMap::new());
218    };
219    if argv[sub_pos] != "run" {
220        return (argv, HashMap::new());
221    }
222
223    let mut out: Vec<String> = argv[..=sub_pos].to_vec();
224    let mut regions = HashMap::new();
225    let mut i = sub_pos + 1;
226    while i < argv.len() {
227        let token = &argv[i];
228        if let Some(name) = token.strip_prefix("--") {
229            // Split an `=`-joined value if present.
230            let (name, inline) = match name.split_once('=') {
231                Some((n, v)) => (n, Some(v.to_string())),
232                None => (name, None),
233            };
234            if !name.is_empty() && !KNOWN_RUN_FLAGS.contains(&name) {
235                let value = match inline {
236                    Some(v) => v,
237                    None => {
238                        // Consume the next token as the value, if any.
239                        i += 1;
240                        argv.get(i).cloned().unwrap_or_default()
241                    }
242                };
243                regions.insert(name.to_string(), value);
244                i += 1;
245                continue;
246            }
247        }
248        out.push(token.clone());
249        i += 1;
250    }
251    (out, regions)
252}
253
254#[cfg(test)]
255mod tests {
256
257    /// The format label is passed through untouched and never matched against a
258    /// known set, which is what lets `--output-format a2ui` work with no
259    /// a2ui-specific code anywhere.
260    #[test]
261    fn an_output_request_carries_an_unrecognized_format_through() {
262        let spec = output_request(
263            Some("a2ui".to_string()),
264            Some("One card per finding.".to_string()),
265            None,
266        )
267        .expect("no schema to parse")
268        .expect("something was asked for");
269
270        assert_eq!(spec.format.as_deref(), Some("a2ui"));
271        assert_eq!(spec.instructions.as_deref(), Some("One card per finding."));
272        assert!(spec.schema.is_none());
273    }
274
275    /// `--output-schema @path` reads the schema from a file, which is how a
276    /// schema of any real size gets onto a command line at all. A path that is
277    /// not there fails here rather than at the end of the run.
278    #[test]
279    fn an_output_schema_can_be_read_from_a_file() {
280        let dir = tempfile::tempdir().expect("temp dir");
281        let path = dir.path().join("schema.json");
282        std::fs::write(&path, r#"{"type":"object","required":["summary"]}"#).expect("write");
283
284        let spec = output_request(None, None, Some(format!("@{}", path.display())))
285            .expect("the file parses")
286            .expect("something was asked for");
287        assert_eq!(
288            spec.schema,
289            Some(serde_json::json!({"type": "object", "required": ["summary"]}))
290        );
291
292        let err = output_request(
293            None,
294            None,
295            Some(format!("@{}", dir.path().join("gone.json").display())),
296        )
297        .expect_err("a file that is not there");
298        assert!(
299            err.to_string().contains("Failed to read region file"),
300            "{err}"
301        );
302    }
303
304    /// Nothing asked for is nothing requested, so the blueprint's own declared
305    /// shape is what applies.
306    #[test]
307    fn no_output_flags_request_nothing() {
308        assert!(
309            output_request(None, None, None)
310                .expect("nothing to parse")
311                .is_none()
312        );
313    }
314
315    /// The schema is the one flag that is interpreted, because it is the one
316    /// thing the runtime will actually check. Bad JSON has to fail here, at the
317    /// command line, rather than at the end of a long run.
318    #[test]
319    fn an_output_schema_is_parsed_and_a_broken_one_is_refused() {
320        let spec = output_request(None, None, Some(r#"{"type":"object"}"#.to_string()))
321            .expect("valid JSON")
322            .expect("something was asked for");
323        assert_eq!(spec.schema, Some(serde_json::json!({"type": "object"})));
324
325        let err = output_request(None, None, Some("{not json".to_string()))
326            .expect_err("broken JSON is refused");
327        assert!(err.to_string().contains("not valid JSON"), "{err}");
328    }
329    use super::*;
330
331    fn argv(parts: &[&str]) -> Vec<String> {
332        parts.iter().map(|s| s.to_string()).collect()
333    }
334
335    #[test]
336    fn every_flag_run_declares_is_a_known_run_flag() {
337        // A flag clap owns but this list omits is not a parse error. The
338        // pre-scan reads it as a `--<region>` seed, eats the token after it, and
339        // the run starts with a region nobody asked for. Ask clap for the list
340        // rather than repeating it, so a new flag is covered when it is added.
341        let command = <RunArgs as clap::Args>::augment_args(clap::Command::new("run"));
342        for arg in command.get_arguments() {
343            if let Some(long) = arg.get_long() {
344                assert!(
345                    KNOWN_RUN_FLAGS.contains(&long),
346                    "`--{long}` is missing from KNOWN_RUN_FLAGS",
347                );
348            }
349        }
350    }
351
352    #[test]
353    fn extracts_dynamic_region_flags_and_preserves_known_ones() {
354        let (out, regions) = extract_region_flags(argv(&[
355            "lev",
356            "run",
357            "agents/reviewer",
358            "--task",
359            "review it",
360            "--files",
361            "@src/main.rs",
362            "--review-criteria",
363            "@policy.md",
364            "--yolo",
365        ]));
366        // Known flags + positional pass through to clap.
367        assert_eq!(
368            out,
369            argv(&[
370                "lev",
371                "run",
372                "agents/reviewer",
373                "--task",
374                "review it",
375                "--yolo",
376            ])
377        );
378        assert_eq!(
379            regions.get("files").map(String::as_str),
380            Some("@src/main.rs")
381        );
382        assert_eq!(
383            regions.get("review-criteria").map(String::as_str),
384            Some("@policy.md")
385        );
386    }
387
388    #[test]
389    fn extracts_equals_joined_region_flag() {
390        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--criteria=be safe"]));
391        assert_eq!(out, argv(&["lev", "run", "a"]));
392        assert_eq!(regions.get("criteria").map(String::as_str), Some("be safe"));
393    }
394
395    #[test]
396    fn no_region_flags_leaves_argv_unchanged() {
397        let input = argv(&["lev", "run", "a", "--task", "t"]);
398        let (out, regions) = extract_region_flags(input.clone());
399        assert_eq!(out, input);
400        assert!(regions.is_empty());
401    }
402
403    #[test]
404    fn non_run_subcommand_is_untouched() {
405        // A dynamic-looking flag on another subcommand is left for clap to reject.
406        let input = argv(&["lev", "ps", "--weird", "x"]);
407        let (out, regions) = extract_region_flags(input.clone());
408        assert_eq!(out, input);
409        assert!(regions.is_empty());
410    }
411
412    #[test]
413    fn no_subcommand_token_is_untouched() {
414        // Only flags, no bareword subcommand → nothing extracted.
415        let input = argv(&["lev", "--verbose"]);
416        let (out, regions) = extract_region_flags(input.clone());
417        assert_eq!(out, input);
418        assert!(regions.is_empty());
419    }
420
421    #[test]
422    fn trailing_region_flag_without_value_maps_to_empty() {
423        // A dynamic flag at the very end with no following value → empty string.
424        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--spec"]));
425        assert_eq!(out, argv(&["lev", "run", "a"]));
426        assert_eq!(regions.get("spec").map(String::as_str), Some(""));
427    }
428
429    #[test]
430    fn global_verbose_before_run_still_activates() {
431        let (_out, regions) = extract_region_flags(argv(&["lev", "-v", "run", "a", "--spec", "x"]));
432        assert_eq!(regions.get("spec").map(String::as_str), Some("x"));
433    }
434
435    /// `--workdir` is a real run flag: the pre-scan must pass it through to
436    /// clap, not swallow it as a region seed named "workdir".
437    #[test]
438    fn workdir_flag_is_not_eaten_as_a_region() {
439        let input = argv(&["lev", "run", "a", "--workdir", "/elsewhere", "--task", "t"]);
440        let (out, regions) = extract_region_flags(input.clone());
441        assert_eq!(out, input);
442        assert!(regions.is_empty());
443    }
444
445    #[test]
446    fn effective_workdir_uses_the_flag_canonicalized() {
447        let dir = tempfile::tempdir().unwrap();
448        let got = effective_workdir(
449            Some(dir.path().to_path_buf()),
450            std::path::PathBuf::from("/unused"),
451        )
452        .unwrap();
453        assert_eq!(
454            got,
455            std::fs::canonicalize(dir.path())
456                .unwrap()
457                .to_string_lossy()
458                .to_string()
459        );
460    }
461
462    #[test]
463    fn effective_workdir_defaults_to_the_supplied_cwd() {
464        let cwd = tempfile::tempdir().unwrap();
465        let got = effective_workdir(None, cwd.path().to_path_buf()).unwrap();
466        assert_eq!(got, cwd.path().to_string_lossy().to_string());
467    }
468
469    /// A bad `--workdir` fails before the daemon is contacted - otherwise it
470    /// spawns an agent whose every tool call fails.
471    #[test]
472    fn effective_workdir_refuses_a_missing_or_non_directory_path() {
473        let cwd = std::path::PathBuf::from("/unused");
474        let err = effective_workdir(
475            Some(std::path::PathBuf::from("/definitely/not/a/real/dir")),
476            cwd.clone(),
477        )
478        .unwrap_err();
479        assert!(err.to_string().contains("not a usable directory"), "{err}");
480
481        let dir = tempfile::tempdir().unwrap();
482        let file = dir.path().join("f.txt");
483        std::fs::write(&file, "x").unwrap();
484        let err = effective_workdir(Some(file), cwd).unwrap_err();
485        assert!(err.to_string().contains("not a directory"), "{err}");
486    }
487}