1use std::future::Future;
10use std::path::PathBuf;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use atd_protocol::ToolDefinition;
15
16use crate::context::CallContext;
17use crate::error::ToolCallError;
18use crate::registry::Tool;
19
20pub type BindingFuture<'a> =
23 Pin<Box<dyn Future<Output = Result<serde_json::Value, ToolCallError>> + Send + 'a>>;
24
25pub trait Binding: Send + Sync {
28 fn name(&self) -> &'static str;
29
30 fn call<'a>(
31 &'a self,
32 tool_def: &'a ToolDefinition,
33 args: serde_json::Value,
34 ctx: &'a CallContext,
35 ) -> BindingFuture<'a>;
36}
37
38pub struct NativeBinding {
42 tool: Arc<dyn Tool>,
43}
44
45impl NativeBinding {
46 pub fn new(tool: Arc<dyn Tool>) -> Self {
47 Self { tool }
48 }
49}
50
51impl Binding for NativeBinding {
52 fn name(&self) -> &'static str {
53 "native"
54 }
55
56 fn call<'a>(
57 &'a self,
58 _tool_def: &'a ToolDefinition,
59 args: serde_json::Value,
60 ctx: &'a CallContext,
61 ) -> BindingFuture<'a> {
62 self.tool.call(args, ctx)
63 }
64}
65
66pub struct CliBinding {
75 pub program: PathBuf,
76 pub base_args: Vec<String>,
77 pub args_mapper: fn(&serde_json::Value) -> Vec<String>,
78}
79
80impl Binding for CliBinding {
81 fn name(&self) -> &'static str {
82 "cli"
83 }
84
85 fn call<'a>(
86 &'a self,
87 _tool_def: &'a ToolDefinition,
88 args: serde_json::Value,
89 ctx: &'a CallContext,
90 ) -> BindingFuture<'a> {
91 let program = self.program.clone();
92 let base = self.base_args.clone();
93 let mapper = self.args_mapper;
94 let budget = ctx
98 .remaining_time()
99 .unwrap_or(std::time::Duration::from_secs(5));
100 Box::pin(async move {
101 let mut argv = base;
102 argv.extend(mapper(&args));
103 let fut = tokio::process::Command::new(&program).args(&argv).output();
104 let output = match tokio::time::timeout(budget, fut).await {
105 Ok(Ok(o)) => o,
106 Ok(Err(e)) => {
107 return Err(ToolCallError::InternalError(format!(
108 "cli binding failed to spawn {:?}: {e}",
109 program
110 )));
111 }
112 Err(_) => {
113 return Err(ToolCallError::ExecutionFailed {
114 code: "TIMEOUT".into(),
115 message: "cli binding deadline exceeded".into(),
116 retryable: false,
117 });
118 }
119 };
120 if !output.status.success() {
121 return Err(ToolCallError::ExecutionFailed {
122 code: format!("EXIT_{}", output.status.code().unwrap_or(-1)),
123 message: String::from_utf8_lossy(&output.stderr).into_owned(),
124 retryable: false,
125 });
126 }
127 Ok(serde_json::json!({
128 "stdout": String::from_utf8_lossy(&output.stdout).into_owned(),
129 "exit_code": output.status.code().unwrap_or(0),
130 }))
131 })
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::registry::CallFuture;
139
140 struct PassthroughTool {
141 def: ToolDefinition,
142 }
143 impl PassthroughTool {
144 fn new() -> Self {
145 use atd_protocol::{
146 BindingProtocol, SafetyLevel, ToolBinding, ToolCapability, ToolResources,
147 ToolSafety, ToolTrust, ToolVisibility, TrustLevel,
148 };
149 Self {
150 def: ToolDefinition {
151 id: "test:passthrough".into(),
152 name: "passthrough".into(),
153 description: "echoes native-binding marker".into(),
154 version: "0.0.0".into(),
155 capability: ToolCapability {
156 domain: "test".into(),
157 actions: vec![],
158 tags: vec![],
159 intent_examples: vec![],
160 },
161 input_schema: serde_json::json!({}),
162 output_schema: serde_json::json!({}),
163 bindings: vec![ToolBinding {
164 protocol: BindingProtocol::Cli,
165 config: serde_json::json!({}),
166 }],
167 safety: ToolSafety {
168 level: SafetyLevel::Read,
169 dry_run: false,
170 side_effects: vec![],
171 data_sensitivity: None,
172 },
173 resources: ToolResources {
174 timeout_ms: 1000,
175 max_concurrent: 1,
176 rate_limit_per_min: None,
177 estimated_tokens: None,
178 },
179 trust: ToolTrust {
180 publisher: "test".into(),
181 trust_level: TrustLevel::L0Unverified,
182 signature: None,
183 },
184 visibility: ToolVisibility::Read,
185 required_capabilities: vec![],
186 tier: None,
187 errors: vec![],
188 },
189 }
190 }
191 }
192 impl Tool for PassthroughTool {
193 fn definition(&self) -> &ToolDefinition {
194 &self.def
195 }
196 fn call<'a>(&'a self, _args: serde_json::Value, _ctx: &'a CallContext) -> CallFuture<'a> {
197 Box::pin(async { Ok(serde_json::json!({"native": true})) })
198 }
199 }
200
201 #[tokio::test]
202 async fn native_binding_delegates_to_tool_call() {
203 let tool = Arc::new(PassthroughTool::new());
204 let binding = NativeBinding::new(tool.clone());
205 assert_eq!(binding.name(), "native");
206 let ctx = CallContext::for_test();
207 let r = binding
208 .call(tool.definition(), serde_json::json!({}), &ctx)
209 .await
210 .unwrap();
211 assert_eq!(r["native"], true);
212 }
213
214 #[cfg(unix)]
215 #[tokio::test]
216 async fn cli_binding_runs_true_program_succeeds() {
217 let tool_def = PassthroughTool::new().def;
218 let binding = CliBinding {
219 program: PathBuf::from("/bin/true"),
220 base_args: vec![],
221 args_mapper: |_| vec![],
222 };
223 assert_eq!(binding.name(), "cli");
224 let ctx = CallContext::for_test();
225 let r = binding
226 .call(&tool_def, serde_json::json!({}), &ctx)
227 .await
228 .unwrap();
229 assert_eq!(r["exit_code"], 0);
230 assert_eq!(r["stdout"], "");
231 }
232
233 #[cfg(unix)]
234 #[tokio::test]
235 async fn cli_binding_surfaces_nonzero_exit_as_execution_failed() {
236 let tool_def = PassthroughTool::new().def;
237 let binding = CliBinding {
238 program: PathBuf::from("/bin/false"),
239 base_args: vec![],
240 args_mapper: |_| vec![],
241 };
242 let ctx = CallContext::for_test();
243 let err = binding
244 .call(&tool_def, serde_json::json!({}), &ctx)
245 .await
246 .unwrap_err();
247 match err {
248 ToolCallError::ExecutionFailed {
249 code, retryable, ..
250 } => {
251 assert!(code.starts_with("EXIT_"));
252 assert!(!retryable);
253 }
254 other => panic!("expected ExecutionFailed, got {other:?}"),
255 }
256 }
257
258 #[cfg(unix)]
259 #[tokio::test]
260 async fn cli_binding_times_out_when_sleep_exceeds_deadline() {
261 let tool_def = PassthroughTool::new().def;
262 let binding = CliBinding {
263 program: PathBuf::from("/bin/sleep"),
264 base_args: vec!["5".into()],
265 args_mapper: |_| vec![],
266 };
267 let mut ctx = CallContext::for_test();
268 ctx.deadline = Some(std::time::Instant::now() + std::time::Duration::from_millis(100));
269 let err = binding
270 .call(&tool_def, serde_json::json!({}), &ctx)
271 .await
272 .unwrap_err();
273 match err {
274 ToolCallError::ExecutionFailed { code, .. } => assert_eq!(code, "TIMEOUT"),
275 other => panic!("expected TIMEOUT, got {other:?}"),
276 }
277 }
278
279 #[cfg(unix)]
280 #[tokio::test]
281 async fn cli_binding_args_mapper_propagates_flags() {
282 let tool_def = PassthroughTool::new().def;
283 let binding = CliBinding {
284 program: PathBuf::from("/bin/echo"),
285 base_args: vec![],
286 args_mapper: |args| {
287 let mut out = vec!["-n".to_string()];
288 if let Some(s) = args.get("msg").and_then(|v| v.as_str()) {
289 out.push(s.to_string());
290 }
291 out
292 },
293 };
294 let ctx = CallContext::for_test();
295 let r = binding
296 .call(&tool_def, serde_json::json!({"msg": "hi"}), &ctx)
297 .await
298 .unwrap();
299 assert_eq!(r["stdout"], "hi");
300 }
301}