Skip to main content

outrig_cli/cli/
run.rs

1//! `outrig run` orchestrator: wire every subsystem into a working REPL.
2//!
3//! [`execute`] walks through the order documented in `doc/usage/run.md`:
4//! delegate to [`session_setup::setup`] for config-load through container
5//! bootstrap + session row + log dir, then resolve the agent, connect every
6//! MCP client, build adapters, build the Rig agent, print the banner, and
7//! hand off to the REPL. On exit (clean or error)
8//! [`session_setup::teardown`] runs MCP shutdowns *before* stopping the
9//! container -- the MCP children are `podman exec` processes whose pipes
10//! ride through the container; tearing the container down first races
11//! them.
12
13use std::cell::RefCell;
14use std::fmt::Write as _;
15use std::path::{Path, PathBuf};
16use std::rc::Rc;
17use std::sync::Arc;
18
19use clap::{ArgAction, Parser};
20use rig::completion::Message;
21
22use crate::cli::env_arg::CliEnvEntries;
23use crate::cli::session_setup::{self, ProgressSpan, SessionSetup, SessionSetupArgs, plural};
24use crate::cli::volume_arg::{CliVolume, parse_volume};
25use crate::error::{OutrigError, Result};
26use crate::llm;
27use crate::paths::model_cache_root;
28use crate::repl::Repl;
29use crate::rig_tool::McpToolAdapter;
30use outrig::McpClient;
31use outrig::config::{
32    Config, MistralrsDeviceSpec, NetworkMode, TOOL_CALL_MAX_LIMIT, TOOL_RESULT_MAX_CEILING_BYTES,
33    TOOL_RESULT_MAX_FLOOR_BYTES,
34};
35use outrig::container::Container;
36use outrig::image::ImageTag;
37
38#[derive(Debug, Parser)]
39pub struct RunArgs {
40    /// Pick an `[agents.<name>]` block. Defaults to `default-agent` from config.
41    #[arg(long, value_name = "NAME")]
42    pub agent: Option<String>,
43
44    /// Pick a `[models.<name>]` block for this run. Overrides the agent's
45    /// `model` and the top-level `default-model`.
46    #[arg(long, value_name = "NAME")]
47    pub model: Option<String>,
48
49    /// Pick a `[images.<name>]` block. Overrides the agent's `image` and the
50    /// top-level `default-image`. An explicit value that doesn't match config
51    /// is used as a local Podman image ref, run without pulling.
52    #[arg(long, value_name = "NAME-OR-LOCAL-REF")]
53    pub image: Option<String>,
54
55    /// Write the session into an explicit, already-existing directory. The
56    /// session root gets a symlink at `<root>/<sid>` pointing at this path.
57    #[arg(long = "session-dir", value_name = "PATH")]
58    pub session_dir: Option<PathBuf>,
59
60    /// Override the per-turn tool-call max for this run.
61    #[arg(long = "max-tool-calls", value_name = "N", value_parser = parse_tool_call_max)]
62    pub max_tool_calls: Option<u32>,
63
64    /// Override the per-result truncation max for this run.
65    #[arg(long = "max-tool-result-bytes", value_name = "N", value_parser = parse_tool_result_max)]
66    pub max_tool_result_bytes: Option<u32>,
67
68    /// Add or override env vars for MCP servers. Repeatable.
69    /// `KEY=VALUE` applies to every server; `SERVER:KEY=VALUE` targets one.
70    #[arg(long = "env", value_name = "KEY=VALUE", action = ArgAction::Append)]
71    pub env: Vec<String>,
72
73    /// Override network monitoring for this session.
74    #[arg(long = "network", value_name = "MODE", value_parser = parse_network_mode)]
75    pub network: Option<NetworkMode>,
76
77    /// Override the mistralrs model device for this run.
78    #[arg(long = "device", value_name = "DEVICE", value_parser = parse_mistralrs_device)]
79    pub device: Option<MistralrsDeviceSpec>,
80
81    /// Mount an extra host directory into the container. Repeatable. Format
82    /// `HOST:CONTAINER[:ro|rw]` (default read-only; the host dir must exist).
83    #[arg(long = "volume", value_name = "HOST:CONTAINER[:ro|rw]", action = ArgAction::Append, value_parser = parse_volume)]
84    pub volume: Vec<CliVolume>,
85}
86
87/// Run one `outrig run` invocation end-to-end. Returns the process exit code.
88pub async fn execute(
89    repo_cfg_path: &Path,
90    global_cfg_path: &Path,
91    session_root_flag: Option<&Path>,
92    args: &RunArgs,
93    verbose: u8,
94) -> Result<i32> {
95    let cli_env =
96        CliEnvEntries::parse(&args.env).map_err(|e| OutrigError::Configuration(e.to_string()))?;
97
98    let setup = session_setup::setup(SessionSetupArgs {
99        repo_cfg_path,
100        global_cfg_path,
101        session_root_flag,
102        image_flag: args.image.as_deref(),
103        attach_target: None,
104        agent_flag: args.agent.as_deref(),
105        model_override: args.model.as_deref(),
106        require_agent: true,
107        explicit_session_dir: args.session_dir.as_deref(),
108        network_mode_override: args.network,
109        device_override: args.device,
110        volumes: &args.volume,
111        verbose,
112    })
113    .await?;
114
115    let agent_name = setup
116        .session
117        .agent_name
118        .clone()
119        .expect("outrig run always resolves an agent in setup");
120    let SessionSetup {
121        cfg,
122        image_cfg_name,
123        image_cfg,
124        image_tag,
125        container,
126        sid,
127        log_dir,
128        store,
129        network,
130        attached: _,
131        session: _,
132        session_dir: _,
133    } = setup;
134    let cache_root = model_cache_root(cfg.model_cache_root.as_deref());
135
136    // Validate per-server env entries against the resolved MCP map.
137    let mcp = session_setup::merged_mcp(&container, &image_cfg).await?;
138    for name in cli_env.per_server_names() {
139        if !mcp.contains_key(name) {
140            return Err(OutrigError::Configuration(format!(
141                "--env {name}:...: image '{}' has no MCP server '{name}'",
142                image_cfg_name
143            ))
144            .into());
145        }
146    }
147
148    let mut mcp_arcs: Vec<Arc<McpClient>> = Vec::new();
149    let outcome: Result<i32> = run_inner(
150        &cfg,
151        &agent_name,
152        &image_cfg_name,
153        &image_tag,
154        &container,
155        &log_dir,
156        sid.as_str(),
157        &cache_root,
158        &mut mcp_arcs,
159        args.max_tool_calls,
160        args.max_tool_result_bytes,
161        args.model.as_deref(),
162        args.device,
163        &mcp,
164        &cli_env,
165    )
166    .await;
167
168    let final_exit = outcome.as_ref().copied().unwrap_or(1);
169    session_setup::teardown(mcp_arcs, network, container, &store, &sid, final_exit).await;
170    outcome
171}
172
173fn parse_network_mode(s: &str) -> std::result::Result<NetworkMode, String> {
174    s.parse()
175}
176
177fn parse_mistralrs_device(s: &str) -> std::result::Result<MistralrsDeviceSpec, String> {
178    s.parse::<MistralrsDeviceSpec>().map_err(|e| e.to_string())
179}
180
181#[allow(clippy::too_many_arguments)]
182async fn run_inner(
183    cfg: &Config,
184    agent_name: &str,
185    image_cfg_name: &str,
186    image_tag: &ImageTag,
187    container: &Container,
188    log_dir: &Path,
189    session_id: &str,
190    cache_root: &Path,
191    mcp_arcs: &mut Vec<Arc<McpClient>>,
192    max_tool_calls: Option<u32>,
193    max_tool_result_bytes: Option<u32>,
194    model_override: Option<&str>,
195    device_override: Option<MistralrsDeviceSpec>,
196    mcp: &std::collections::BTreeMap<String, outrig::config::McpServerSpec>,
197    cli_env: &CliEnvEntries,
198) -> Result<i32> {
199    // `setup` already validated presence and used the resolved `.image`
200    // for the image fallback. We re-resolve here for `build_agent` +
201    // banner; cheap (config table lookups, no I/O).
202    let mut resolved =
203        llm::resolve_agent_with_overrides(cfg, agent_name, model_override, device_override)?;
204    apply_tool_call_max_override(&mut resolved, max_tool_calls);
205    apply_tool_result_max_override(&mut resolved, max_tool_result_bytes);
206
207    let connected = session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env).await?;
208    mcp_arcs.extend(connected);
209
210    let mut all_tools: Vec<McpToolAdapter> = Vec::new();
211    let mut per_server_counts: Vec<(String, usize)> = Vec::new();
212    for arc in mcp_arcs.iter() {
213        let span = ProgressSpan::start(format!("MCP {}: listing tools", arc.name()));
214        let adapters =
215            McpToolAdapter::from_client_tools(arc.clone(), resolved.tool_result_max_bytes).await?;
216        let tool_count = adapters.len();
217        let tool_word = plural(tool_count, "tool", "tools");
218        span.done(format!(
219            "MCP {}: tools ready: {tool_count} {tool_word}",
220            arc.name()
221        ));
222        per_server_counts.push((arc.name().to_string(), tool_count));
223        all_tools.extend(adapters);
224    }
225
226    #[cfg(feature = "local-llm")]
227    let registry = llm::LlmRegistry::new();
228
229    let span = ProgressSpan::start("building agent");
230    let agent = llm::build_agent(
231        &resolved,
232        all_tools.clone(),
233        cache_root,
234        #[cfg(feature = "local-llm")]
235        &registry,
236    )
237    .await?;
238    span.done("agent ready");
239
240    print_banner(
241        &resolved,
242        image_cfg_name,
243        image_tag,
244        container.name(),
245        &per_server_counts,
246        &all_tools,
247        session_id,
248    );
249
250    let tools_summary = build_tools_summary(&all_tools);
251    eprintln!("[outrig] entering REPL");
252    let result = run_repl(&agent, tools_summary).await;
253
254    // Drop adapters and the agent before returning so teardown's
255    // `Arc::try_unwrap` on each `mcp_arcs` entry succeeds.
256    drop(all_tools);
257    drop(agent);
258
259    result
260}
261
262async fn run_repl(agent: &llm::RigAgent, tools_summary: String) -> Result<i32> {
263    // Single-task REPL: callbacks run sequentially. RefCell over the shared
264    // history avoids needing Send bounds via Arc<Mutex<_>>; the binary's
265    // tokio runtime is current-thread.
266    let history: Rc<RefCell<Vec<Message>>> = Rc::new(RefCell::new(Vec::new()));
267
268    let history_for_prompt = history.clone();
269    let on_prompt = move |line: String| {
270        let history = history_for_prompt.clone();
271        async move {
272            // Move the vec out so the RefCell isn't borrowed across the
273            // await; restore it on completion. Prompt cancellation may add
274            // partial history to `h`, so it must always be written back.
275            let mut h = std::mem::take(&mut *history.borrow_mut());
276            let result = agent.run_turn(&line, &mut h).await;
277            *history.borrow_mut() = h;
278            result
279        }
280    };
281
282    let on_tools = move || {
283        let summary = tools_summary.clone();
284        async move { summary }
285    };
286
287    let history_for_reset = history.clone();
288    let on_reset = move || {
289        let history = history_for_reset.clone();
290        async move {
291            history.borrow_mut().clear();
292            "[outrig] history cleared".to_string()
293        }
294    };
295
296    Repl::run("", on_prompt, on_tools, on_reset).await?;
297    Ok(0)
298}
299
300fn print_banner(
301    resolved: &llm::ResolvedAgent,
302    container_name: &str,
303    image_tag: &ImageTag,
304    container_pod_name: &str,
305    per_server_counts: &[(String, usize)],
306    all_tools: &[McpToolAdapter],
307    session_id: &str,
308) {
309    let provider_label = match &resolved.provider {
310        llm::ResolvedProvider::OpenAi { .. } => "openai",
311        llm::ResolvedProvider::Mistralrs => "mistralrs",
312    };
313    let mut buf = String::new();
314    let _ = writeln!(
315        buf,
316        "[outrig] agent:             {} (model: {} / provider: {} / {})",
317        resolved.agent_name, resolved.model_name, provider_label, resolved.model_identifier
318    );
319    let _ = writeln!(
320        buf,
321        "[outrig] tool-call max:     {}",
322        resolved.tool_call_max
323    );
324    let _ = writeln!(
325        buf,
326        "[outrig] tool-result max:   {} bytes",
327        resolved.tool_result_max_bytes
328    );
329    if let Some(weights) = &resolved.model_weights {
330        let _ = writeln!(buf, "[outrig] model device:      {}", weights.device);
331    }
332    let _ = writeln!(buf, "[outrig] image-config:  {container_name}");
333    let _ = writeln!(buf, "[outrig] image:             {image_tag}");
334    let _ = writeln!(buf, "[outrig] container started: {container_pod_name}");
335    for (name, count) in per_server_counts {
336        let plural = if *count == 1 { "tool" } else { "tools" };
337        let _ = writeln!(buf, "[outrig] mcp {name}: initialized ({count} {plural})");
338    }
339    let names: Vec<&str> = all_tools.iter().map(|t| t.openai_name.as_str()).collect();
340    let _ = writeln!(buf, "[outrig] tools available: {}", names.join(", "));
341    let _ = writeln!(
342        buf,
343        "[outrig] session id: {session_id}   (Ctrl-D to exit, /help for slash commands)"
344    );
345    eprint!("{buf}");
346}
347
348fn build_tools_summary(tools: &[McpToolAdapter]) -> String {
349    let mut buf = String::new();
350    let _ = writeln!(buf, "[outrig] tools available ({}):", tools.len());
351    let pad = tools.iter().map(|t| t.openai_name.len()).max().unwrap_or(0);
352    for t in tools {
353        let desc = truncate_description(&t.description, 60);
354        let _ = writeln!(buf, "  {:<pad$}   {}", t.openai_name, desc, pad = pad);
355    }
356    buf
357}
358
359fn truncate_description(desc: &str, max: usize) -> String {
360    let cleaned = desc.lines().next().unwrap_or("").trim();
361    if cleaned.len() <= max {
362        cleaned.to_string()
363    } else {
364        let cut = cleaned
365            .char_indices()
366            .nth(max)
367            .map(|(i, _)| i)
368            .unwrap_or(cleaned.len());
369        format!("{}...", &cleaned[..cut])
370    }
371}
372
373fn apply_tool_call_max_override(resolved: &mut llm::ResolvedAgent, max_tool_calls: Option<u32>) {
374    if let Some(max_tool_calls) = max_tool_calls {
375        resolved.tool_call_max = max_tool_calls as usize;
376    }
377}
378
379fn apply_tool_result_max_override(
380    resolved: &mut llm::ResolvedAgent,
381    max_tool_result_bytes: Option<u32>,
382) {
383    if let Some(max_tool_result_bytes) = max_tool_result_bytes {
384        resolved.tool_result_max_bytes = max_tool_result_bytes as usize;
385    }
386}
387
388fn parse_tool_call_max(s: &str) -> std::result::Result<u32, String> {
389    let value = s
390        .parse::<u32>()
391        .map_err(|_| format!("must be an integer between 1 and {TOOL_CALL_MAX_LIMIT}"))?;
392    if !(1..=TOOL_CALL_MAX_LIMIT).contains(&value) {
393        return Err(format!(
394            "must be between 1 and {TOOL_CALL_MAX_LIMIT}; got {value}"
395        ));
396    }
397    Ok(value)
398}
399
400fn parse_tool_result_max(s: &str) -> std::result::Result<u32, String> {
401    let value = s.parse::<u32>().map_err(|_| {
402        format!(
403            "must be an integer between {TOOL_RESULT_MAX_FLOOR_BYTES} and \
404             {TOOL_RESULT_MAX_CEILING_BYTES}"
405        )
406    })?;
407    if !(TOOL_RESULT_MAX_FLOOR_BYTES..=TOOL_RESULT_MAX_CEILING_BYTES).contains(&value) {
408        return Err(format!(
409            "must be between {TOOL_RESULT_MAX_FLOOR_BYTES} and \
410             {TOOL_RESULT_MAX_CEILING_BYTES}; got {value}"
411        ));
412    }
413    Ok(value)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn max_tool_calls_arg_accepts_in_range_value() {
422        let args = RunArgs::try_parse_from(["run", "--max-tool-calls", "200"]).expect("arg parses");
423        assert_eq!(args.max_tool_calls, Some(200));
424    }
425
426    #[test]
427    fn max_tool_calls_arg_rejects_out_of_range_value() {
428        let err =
429            RunArgs::try_parse_from(["run", "--max-tool-calls", "0"]).expect_err("zero is invalid");
430        let msg = err.to_string();
431        assert!(
432            msg.contains("must be between 1 and 2000"),
433            "unexpected clap error: {msg}",
434        );
435    }
436
437    #[test]
438    fn max_tool_result_bytes_arg_accepts_in_range_value() {
439        let args = RunArgs::try_parse_from(["run", "--max-tool-result-bytes", "65536"])
440            .expect("arg parses");
441        assert_eq!(args.max_tool_result_bytes, Some(65536));
442    }
443
444    #[test]
445    fn max_tool_result_bytes_arg_rejects_out_of_range_value() {
446        let err = RunArgs::try_parse_from(["run", "--max-tool-result-bytes", "0"])
447            .expect_err("zero is invalid");
448        let msg = err.to_string();
449        assert!(
450            msg.contains("must be between 1024 and 16777216"),
451            "unexpected clap error: {msg}",
452        );
453    }
454
455    #[test]
456    fn device_arg_accepts_mistralrs_device_forms() {
457        let args = RunArgs::try_parse_from(["run", "--device", "cuda:2"]).expect("arg parses");
458        assert_eq!(args.device, Some(MistralrsDeviceSpec::Cuda(2)));
459    }
460
461    #[test]
462    fn device_arg_rejects_unknown_device() {
463        let err = RunArgs::try_parse_from(["run", "--device", "gpu"])
464            .expect_err("unknown device is invalid");
465        let msg = err.to_string();
466        assert!(
467            msg.contains(MistralrsDeviceSpec::EXPECTED),
468            "unexpected clap error: {msg}",
469        );
470    }
471
472    #[test]
473    fn model_arg_accepts_name() {
474        let args = RunArgs::try_parse_from(["run", "--model", "smart"]).expect("arg parses");
475        assert_eq!(args.model.as_deref(), Some("smart"));
476    }
477
478    #[test]
479    fn cli_override_replaces_resolved_tool_call_max() {
480        let mut resolved = llm::ResolvedAgent {
481            agent_name: "coding".to_string(),
482            model_name: "fast".to_string(),
483            model_identifier: "gpt-4o-mini".to_string(),
484            provider_name: "local".to_string(),
485            provider: llm::ResolvedProvider::Mistralrs,
486            model_weights: None,
487            preamble: "test".to_string(),
488            temperature: None,
489            max_tokens: None,
490            tool_call_max: 100,
491            tool_result_max_bytes: llm::DEFAULT_TOOL_RESULT_MAX_BYTES,
492            image: None,
493        };
494
495        apply_tool_call_max_override(&mut resolved, Some(50));
496
497        assert_eq!(resolved.tool_call_max, 50);
498    }
499
500    #[test]
501    fn cli_override_replaces_resolved_tool_result_max() {
502        let mut resolved = llm::ResolvedAgent {
503            agent_name: "coding".to_string(),
504            model_name: "fast".to_string(),
505            model_identifier: "gpt-4o-mini".to_string(),
506            provider_name: "local".to_string(),
507            provider: llm::ResolvedProvider::Mistralrs,
508            model_weights: None,
509            preamble: "test".to_string(),
510            temperature: None,
511            max_tokens: None,
512            tool_call_max: 100,
513            tool_result_max_bytes: 262_144,
514            image: None,
515        };
516
517        apply_tool_result_max_override(&mut resolved, Some(65_536));
518
519        assert_eq!(resolved.tool_result_max_bytes, 65_536);
520    }
521
522    #[test]
523    fn env_flag_collects_multiple_values() {
524        let args = RunArgs::try_parse_from(["run", "--env", "FOO=bar", "--env", "BAZ=quux"])
525            .expect("arg parses");
526        assert_eq!(args.env, vec!["FOO=bar", "BAZ=quux"]);
527    }
528
529    #[test]
530    fn env_flag_absent_yields_empty_vec() {
531        let args = RunArgs::try_parse_from(["run"]).expect("arg parses");
532        assert!(args.env.is_empty());
533    }
534
535    #[test]
536    fn volume_flag_collects_multiple_values() {
537        let args =
538            RunArgs::try_parse_from(["run", "--volume", "/h1:/c1", "--volume", "/h2:/c2:rw"])
539                .expect("arg parses");
540        assert_eq!(args.volume.len(), 2);
541        assert_eq!(args.volume[0].container, std::path::PathBuf::from("/c1"));
542    }
543
544    #[test]
545    fn volume_flag_rejects_bad_value() {
546        let err = RunArgs::try_parse_from(["run", "--volume", "/h:/c:bogus"])
547            .expect_err("bad access should fail");
548        assert!(
549            err.to_string().contains("ro` or `rw"),
550            "unexpected error: {err}"
551        );
552    }
553}