1use 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 #[arg(long, value_name = "NAME")]
42 pub agent: Option<String>,
43
44 #[arg(long, value_name = "NAME")]
47 pub model: Option<String>,
48
49 #[arg(long, value_name = "NAME-OR-LOCAL-REF")]
53 pub image: Option<String>,
54
55 #[arg(long = "session-dir", value_name = "PATH")]
58 pub session_dir: Option<PathBuf>,
59
60 #[arg(long = "max-tool-calls", value_name = "N", value_parser = parse_tool_call_max)]
62 pub max_tool_calls: Option<u32>,
63
64 #[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 #[arg(long = "env", value_name = "KEY=VALUE", action = ArgAction::Append)]
71 pub env: Vec<String>,
72
73 #[arg(long = "network", value_name = "MODE", value_parser = parse_network_mode)]
75 pub network: Option<NetworkMode>,
76
77 #[arg(long = "device", value_name = "DEVICE", value_parser = parse_mistralrs_device)]
79 pub device: Option<MistralrsDeviceSpec>,
80
81 #[arg(long = "volume", value_name = "HOST:CONTAINER[:ro|rw]", action = ArgAction::Append, value_parser = parse_volume)]
84 pub volume: Vec<CliVolume>,
85}
86
87pub 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 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 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 ®istry,
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(all_tools);
257 drop(agent);
258
259 result
260}
261
262async fn run_repl(agent: &llm::RigAgent, tools_summary: String) -> Result<i32> {
263 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 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}