1use std::sync::OnceLock;
4use std::time::{Duration, Instant};
5
6use atd_protocol::{
7 BindingProtocol, SafetyLevel, ToolBinding, ToolCapability, ToolDefinition, ToolResources,
8 ToolSafety, ToolTrust, ToolVisibility, TrustLevel,
9};
10
11use crate::shared::{RunError, RunRequest, run};
12use atd_runtime::context::CallContext;
13use atd_runtime::error::ToolCallError;
14use atd_runtime::registry::{CallFuture, Tool};
15
16static DEFINITION: OnceLock<ToolDefinition> = OnceLock::new();
17
18fn definition() -> &'static ToolDefinition {
19 DEFINITION.get_or_init(|| ToolDefinition {
20 id: "ref:shell.exec".into(),
21 name: "Shell Execute".into(),
22 description: "Run a command via `bash -c`. Captures stdout/stderr separately (each capped at ctx.max_output_bytes/2), returns the exit code. Nonzero exit is not a tool error — the agent interprets exit codes itself.".into(),
23 version: "0.1.0".into(),
24 capability: ToolCapability {
25 domain: "shell".into(),
26 actions: vec!["exec".into()],
27 tags: vec!["shell".into(), "bash".into(), "subprocess".into()],
28 intent_examples: vec![
29 "run `ls -la`".into(),
30 "list files matching '*.rs' via shell".into(),
31 ],
32 },
33 input_schema: serde_json::json!({
34 "type": "object",
35 "properties": {
36 "command": { "type": "string", "minLength": 1 },
37 "grace_ms": { "type": "integer", "minimum": 0 }
38 },
39 "required": ["command"]
40 }),
41 output_schema: serde_json::json!({
42 "type": "object",
43 "properties": {
44 "exit_code": { "type": ["integer", "null"] },
45 "stdout": { "type": "string" },
46 "stdout_truncated": { "type": "boolean" },
47 "stderr": { "type": "string" },
48 "stderr_truncated": { "type": "boolean" },
49 "duration_ms": { "type": "integer" }
50 }
51 }),
52 bindings: vec![ToolBinding {
53 protocol: BindingProtocol::Cli,
54 config: serde_json::json!({}),
55 }],
56 safety: ToolSafety {
57 level: SafetyLevel::Destructive,
58 dry_run: true,
59 side_effects: vec!["subprocess".into(), "filesystem".into(), "network".into()],
60 data_sensitivity: Some("depends on command".into()),
61 },
62 resources: ToolResources {
63 timeout_ms: 60_000,
64 max_concurrent: 10,
65 rate_limit_per_min: None,
66 estimated_tokens: Some(500),
67 },
68 trust: ToolTrust {
69 publisher: "atd-ref-server".into(),
70 trust_level: TrustLevel::L2Tested,
71 signature: None,
72 },
73 visibility: ToolVisibility::Dangerous,
74 required_capabilities: vec![],
75 tier: None,
76 errors: vec![],
77 })
78}
79
80pub struct ShellExecTool;
81
82impl ShellExecTool {
83 pub fn new() -> Self {
84 Self
85 }
86}
87
88impl Default for ShellExecTool {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94#[derive(serde::Deserialize)]
95struct ExecArgs {
96 command: String,
97 #[serde(default)]
98 grace_ms: Option<u64>,
99}
100
101impl Tool for ShellExecTool {
102 fn definition(&self) -> &ToolDefinition {
103 definition()
104 }
105
106 fn call<'a>(&'a self, args: serde_json::Value, ctx: &'a CallContext) -> CallFuture<'a> {
107 Box::pin(async move {
108 let args: ExecArgs = serde_json::from_value(args)
109 .map_err(|e| ToolCallError::InvalidArgs(e.to_string()))?;
110 if args.command.trim().is_empty() {
111 return Err(ToolCallError::InvalidArgs(
112 "command is empty or whitespace-only".into(),
113 ));
114 }
115
116 let deadline = ctx.deadline.or_else(|| {
117 Some(Instant::now() + Duration::from_secs(60))
119 });
120
121 let half = ctx.max_output_bytes / 2;
122 let req = RunRequest {
123 program: "bash",
124 args: &["-c", &args.command],
125 cwd: &ctx.cwd,
126 deadline,
127 grace_ms: args.grace_ms.unwrap_or(1000),
128 max_stdout_bytes: half,
129 max_stderr_bytes: half,
130 };
131
132 match run(req).await {
133 Ok(out) => Ok(serde_json::json!({
134 "exit_code": out.exit_code,
135 "stdout": out.stdout,
136 "stdout_truncated": out.stdout_truncated,
137 "stderr": out.stderr,
138 "stderr_truncated": out.stderr_truncated,
139 "duration_ms": out.duration_ms,
140 })),
141 Err(RunError::NotFound { program }) => Err(ToolCallError::ExecutionFailed {
142 code: "NOT_AVAILABLE".into(),
143 message: format!("{program} not on PATH"),
144 retryable: false,
145 }),
146 Err(RunError::TimedOut { after_ms }) => Err(ToolCallError::ExecutionFailed {
147 code: "TIMEOUT".into(),
148 message: format!("command timed out after {after_ms}ms"),
149 retryable: true,
150 }),
151 Err(RunError::SpawnFailed(e)) | Err(RunError::Io(e)) => {
152 Err(ToolCallError::ExecutionFailed {
153 code: "IO".into(),
154 message: format!("io: {e}"),
155 retryable: true,
156 })
157 }
158 }
159 })
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[tokio::test]
168 async fn happy_path_echo() {
169 let t = ShellExecTool::new();
170 let ctx = CallContext::for_test();
171 let r = t
172 .call(serde_json::json!({"command": "echo hi"}), &ctx)
173 .await
174 .unwrap();
175 assert_eq!(r["exit_code"], 0);
176 assert_eq!(r["stdout"], "hi\n");
177 assert_eq!(r["stderr"], "");
178 }
179
180 #[tokio::test]
181 async fn stderr_propagates() {
182 let t = ShellExecTool::new();
183 let ctx = CallContext::for_test();
184 let r = t
185 .call(
186 serde_json::json!({"command": ">&2 echo boom; exit 2"}),
187 &ctx,
188 )
189 .await
190 .unwrap();
191 assert_eq!(r["exit_code"], 2);
192 assert_eq!(r["stderr"], "boom\n");
193 }
194
195 #[tokio::test]
196 async fn nonzero_exit_code_is_not_a_tool_error() {
197 let t = ShellExecTool::new();
198 let ctx = CallContext::for_test();
199 let r = t
200 .call(serde_json::json!({"command": "false"}), &ctx)
201 .await
202 .unwrap();
203 assert_eq!(r["exit_code"], 1);
204 }
205
206 #[tokio::test]
207 async fn timeout_returns_execution_failed() {
208 let t = ShellExecTool::new();
209 let mut ctx = CallContext::for_test();
210 ctx.deadline = Some(Instant::now() + Duration::from_millis(200));
211 let err = t
212 .call(
213 serde_json::json!({"command": "sleep 10", "grace_ms": 50}),
214 &ctx,
215 )
216 .await
217 .unwrap_err();
218 match err {
219 ToolCallError::ExecutionFailed {
220 code, retryable, ..
221 } => {
222 assert_eq!(code, "TIMEOUT");
223 assert!(retryable);
224 }
225 _ => panic!("expected TIMEOUT"),
226 }
227 }
228
229 #[tokio::test]
230 async fn empty_command_is_invalid_args() {
231 let t = ShellExecTool::new();
232 let ctx = CallContext::for_test();
233 let err = t
234 .call(serde_json::json!({"command": " "}), &ctx)
235 .await
236 .unwrap_err();
237 assert!(matches!(err, ToolCallError::InvalidArgs(_)));
238 }
239
240 #[tokio::test]
241 async fn grace_ms_override_respected() {
242 let t = ShellExecTool::new();
247 let mut ctx = CallContext::for_test();
248 ctx.deadline = Some(Instant::now() + Duration::from_millis(150));
249 let start = Instant::now();
250 let _ = t
251 .call(
252 serde_json::json!({"command": "sleep 10", "grace_ms": 200}),
253 &ctx,
254 )
255 .await;
256 let elapsed = start.elapsed();
257 assert!(elapsed < Duration::from_secs(2), "too slow: {elapsed:?}");
259 }
260}