1use std::{borrow::Cow, path::PathBuf};
2
3use aither_core::llm::ToolContext;
4use aither_core::llm::{Tool, ToolResult, tool::json};
5use anyhow::{Context, Result, bail};
6use async_process::Command;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18pub struct CommandArgs {
19 pub program: String,
21 #[serde(default)]
23 pub args: Vec<String>,
24 pub cwd: Option<PathBuf>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct CommandOutput {
30 pub program: String,
31 pub status: i32,
32 pub stdout: String,
33 pub stderr: String,
34}
35
36#[derive(Debug, Clone)]
37pub struct CommandTool {
38 allowed: Option<Vec<String>>,
39 default_cwd: PathBuf,
40 max_output: usize,
41 name: String,
42}
43
44impl CommandTool {
45 pub fn new(default_cwd: impl Into<PathBuf>) -> Self {
46 let default_cwd = default_cwd.into();
47 Self {
48 allowed: None,
49 default_cwd,
50 max_output: 16 * 1024,
51 name: "command".into(),
52 }
53 }
54
55 pub fn restrict_to(mut self, commands: impl IntoIterator<Item = impl Into<String>>) -> Self {
56 self.allowed = Some(commands.into_iter().map(Into::into).collect());
57 self
58 }
59
60 pub fn max_output(mut self, bytes: usize) -> Self {
61 self.max_output = bytes;
62 self
63 }
64
65 pub fn named(mut self, name: impl Into<String>) -> Self {
66 self.name = name.into();
67 self
68 }
69
70 fn ensure_allowed(&self, program: &str) -> Result<()> {
71 if let Some(allowed) = &self.allowed
72 && !allowed.iter().any(|entry| entry == program)
73 {
74 bail!(
75 "Program '{program}' is not allowed. Allowed commands: {}",
76 allowed.join(", ")
77 );
78 }
79 Ok(())
80 }
81
82 fn truncate(&self, text: String) -> String {
83 if text.len() <= self.max_output {
84 return text;
85 }
86
87 let mut truncated = text;
88 truncated.truncate(self.max_output);
89 truncated.push_str("\n...output truncated...");
90 truncated
91 }
92}
93
94impl Tool for CommandTool {
95 fn name(&self) -> Cow<'static, str> {
96 Cow::Owned(self.name.clone())
97 }
98
99 type Arguments = CommandArgs;
100 type Res = ToolResult;
101
102 async fn call(
103 &self,
104 arguments: Self::Arguments,
105 _cx: ToolContext,
106 ) -> aither_core::Result<Self::Res> {
107 self.ensure_allowed(&arguments.program)?;
108
109 let working_dir = arguments.cwd.unwrap_or_else(|| self.default_cwd.clone());
110 let output = Command::new(&arguments.program)
111 .args(&arguments.args)
112 .current_dir(&working_dir)
113 .output()
114 .await
115 .with_context(|| {
116 format!(
117 "failed to execute '{}' in {}",
118 arguments.program,
119 working_dir.display()
120 )
121 })?;
122
123 let response = CommandOutput {
124 program: arguments.program,
125 status: output.status.code().unwrap_or_default(),
126 stdout: self.truncate(String::from_utf8_lossy(&output.stdout).to_string()),
127 stderr: self.truncate(String::from_utf8_lossy(&output.stderr).to_string()),
128 };
129
130 Ok(ToolResult::text(json(&response)?))
131 }
132}