Skip to main content

atd_runtime/
binding.rs

1//! Binding abstraction.
2//!
3//! A `Binding` is *how* a tool's semantics are realized — native in-process
4//! (wrapping a `Tool` impl), CLI subprocess, and later MCP/REST/AppFunction.
5//! Dispatch resolves `tool_id` to a `(Tool, Binding)` pair and invokes
6//! `Binding::call`; `NativeBinding` simply delegates back to the `Tool`, so
7//! all 9 existing tools keep working with zero behavior change.
8
9use 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
20/// Boxed future returned by `Binding::call`. Shape mirrors `registry::CallFuture`
21/// so the two can be freely composed.
22pub type BindingFuture<'a> =
23    Pin<Box<dyn Future<Output = Result<serde_json::Value, ToolCallError>> + Send + 'a>>;
24
25/// A tool's execution binding. `name()` returns a short discriminator
26/// (`"native"`, `"cli"`, `"mcp"`, ...) used by observability hooks and tests.
27pub 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
38/// Default binding: delegate directly to the `Tool::call` implementation.
39/// Assigned to every tool registered via `Registry::register`; the 9 built-in
40/// tools continue to run through it.
41pub 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
66/// Spawn a subprocess to realize the tool. Demonstrates that dispatch can
67/// route a single `tool_id` to an external program without the `Tool` impl
68/// carrying the execution logic.
69///
70/// `args_mapper` is a function pointer (not a closure) so `CliBinding` stays
71/// `Send + Sync` without interior mutability. SP-12 ships one mapper
72/// (`ref:external.uname`); a future refactor can swap this for a trait
73/// object if more than one CLI-backed tool needs configuration.
74pub 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        // Respect the dispatch-provided deadline. Fall back to a 5 s cap if
95        // the CallContext carries none (unusual — the server always attaches
96        // one).
97        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}