Skip to main content

bashkit_scripted_tool/scripted_tool/
extension.rs

1// ToolDefExtension centralizes ToolDef-backed builtins so plain Bash and
2// ScriptedTool share the same command, help, discover, dry-run, and trace behavior.
3
4use super::{
5    CallbackKind, RegisteredTool, ScriptedCommandInvocation, ScriptedCommandKind, ToolArgs,
6    ToolDef, ToolImpl,
7};
8use crate::tool_def::{parse_flags, usage_from_schema};
9use crate::{ToolCallSurface, ToolRegistry};
10use async_trait::async_trait;
11use bashkit::ExecResult;
12use bashkit::Result;
13use bashkit::{Builtin, BuiltinContext as Context, Extension};
14use std::collections::VecDeque;
15use std::future::Future;
16use std::sync::{Arc, Mutex};
17
18pub(crate) type InvocationLog = Arc<Mutex<VecDeque<ScriptedCommandInvocation>>>;
19const MAX_LOG_ENTRIES: usize = 256;
20const MAX_LOG_ARG_BYTES: usize = 1024;
21
22fn push_invocation(
23    log: &InvocationLog,
24    name: &str,
25    kind: ScriptedCommandKind,
26    args: &[String],
27    exit_code: i32,
28) {
29    let args = truncate_args(args);
30    let mut invocations = log.lock().expect("tool-def invocation log poisoned");
31    if invocations.len() == MAX_LOG_ENTRIES {
32        invocations.pop_front();
33    }
34    invocations.push_back(ScriptedCommandInvocation {
35        name: name.to_string(),
36        kind,
37        args,
38        exit_code,
39    });
40}
41
42fn truncate_args(args: &[String]) -> Vec<String> {
43    args.iter().map(|arg| truncate_arg(arg)).collect()
44}
45
46fn truncate_arg(arg: &str) -> String {
47    if arg.len() <= MAX_LOG_ARG_BYTES {
48        return arg.to_string();
49    }
50    // Byte-aware truncation that respects UTF-8 char boundaries.
51    let cut = arg
52        .char_indices()
53        .map(|(i, c)| i + c.len_utf8())
54        .take_while(|&end| end <= MAX_LOG_ARG_BYTES)
55        .last()
56        .unwrap_or(0);
57    arg[..cut].to_string()
58}
59
60/// Builder for [`ToolDefExtension`].
61pub struct ToolDefExtensionBuilder {
62    tools: Vec<RegisteredTool>,
63    sanitize_errors: bool,
64}
65
66impl Default for ToolDefExtensionBuilder {
67    fn default() -> Self {
68        Self {
69            tools: Vec::new(),
70            sanitize_errors: true,
71        }
72    }
73}
74
75impl ToolDefExtensionBuilder {
76    /// Register a [`ToolImpl`] (definition + exec functions).
77    pub fn tool(mut self, tool: ToolImpl) -> Self {
78        self.tools.push(RegisteredTool::from_tool_impl(tool));
79        self
80    }
81
82    /// Register a tool with its definition and synchronous exec function.
83    pub fn tool_fn(
84        mut self,
85        def: ToolDef,
86        exec: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
87    ) -> Self {
88        self.tools.push(RegisteredTool {
89            def,
90            callback: CallbackKind::Sync(Arc::new(exec)),
91            dry_run: None,
92        });
93        self
94    }
95
96    /// Register a sync tool plus a custom `--dry-run` handler.
97    pub fn tool_with_dry_run(
98        mut self,
99        def: ToolDef,
100        exec: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
101        dry_run: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
102    ) -> Self {
103        self.tools.push(RegisteredTool {
104            def,
105            callback: CallbackKind::Sync(Arc::new(exec)),
106            dry_run: Some(CallbackKind::Sync(Arc::new(dry_run))),
107        });
108        self
109    }
110
111    /// Register a tool with its definition and async exec function.
112    pub fn async_tool_fn<F, Fut>(mut self, def: ToolDef, exec: F) -> Self
113    where
114        F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
115        Fut: Future<Output = std::result::Result<String, String>> + Send + 'static,
116    {
117        self.tools.push(RegisteredTool {
118            def,
119            callback: CallbackKind::Async(Arc::new(move |args| Box::pin(exec(args)))),
120            dry_run: None,
121        });
122        self
123    }
124
125    /// Replace callback errors with generic messages before exposing them to scripts.
126    pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
127        self.sanitize_errors = sanitize;
128        self
129    }
130
131    /// Build the extension.
132    ///
133    /// Each call mints a fresh, isolated invocation log. Clones of the
134    /// returned extension also get a fresh log so cloned configuration can be
135    /// reused across tenants without sharing traces. Use
136    /// [`ToolDefExtension::invocation_trace`] before registration when a caller
137    /// intentionally wants a handle for this extension's trace.
138    pub fn build(&self) -> ToolDefExtension {
139        let trace = ToolDefInvocationTrace::new();
140        ToolDefExtension {
141            registry: ToolRegistry::from_registered(
142                self.tools.clone(),
143                self.sanitize_errors,
144                trace.clone(),
145            ),
146            invocation_log: trace.invocation_log,
147        }
148    }
149}
150
151/// Bash extension that registers ToolDef-backed commands plus `help` and `discover`.
152///
153/// Each [`ToolDefExtensionBuilder::build`] mints a fresh invocation log, so
154/// distinct builds (e.g. per tenant) never share traces. Cloning also creates
155/// a fresh log, allowing cloned configuration to be installed into separate
156/// `Bash` instances without cross-tenant trace leakage. Use
157/// [`ToolDefExtension::invocation_trace`] to intentionally retain a trace
158/// handle before registering an extension.
159pub struct ToolDefExtension {
160    registry: ToolRegistry,
161    invocation_log: InvocationLog,
162}
163
164/// Explicit handle for reading and clearing one extension's invocation trace.
165///
166/// Cloning this handle intentionally shares trace state with the source
167/// extension. Clone [`ToolDefExtension`] itself only for reusable command
168/// configuration; extension clones receive fresh, isolated logs.
169#[derive(Clone)]
170pub struct ToolDefInvocationTrace {
171    invocation_log: InvocationLog,
172}
173
174impl ToolDefInvocationTrace {
175    pub(crate) fn new() -> Self {
176        Self {
177            invocation_log: Arc::new(Mutex::new(VecDeque::new())),
178        }
179    }
180
181    pub(crate) fn push(
182        &self,
183        name: &str,
184        kind: ScriptedCommandKind,
185        args: Vec<String>,
186        exit_code: i32,
187    ) {
188        push_invocation(&self.invocation_log, name, kind, &args, exit_code);
189    }
190
191    /// Return and clear accumulated command invocation trace entries.
192    pub fn take_invocations(&self) -> Vec<ScriptedCommandInvocation> {
193        let mut invocations = self
194            .invocation_log
195            .lock()
196            .expect("tool-def invocation log poisoned");
197        std::mem::take(&mut *invocations).into()
198    }
199}
200
201impl Clone for ToolDefExtension {
202    fn clone(&self) -> Self {
203        let registry = self.registry.with_fresh_default_trace();
204        let invocation_log = registry.default_trace().invocation_log;
205        Self {
206            registry,
207            invocation_log,
208        }
209    }
210}
211
212impl ToolDefExtension {
213    /// Create an empty builder.
214    pub fn builder() -> ToolDefExtensionBuilder {
215        ToolDefExtensionBuilder::default()
216    }
217
218    pub(crate) fn from_registered_tools(tools: Vec<RegisteredTool>) -> Self {
219        let trace = ToolDefInvocationTrace::new();
220        Self {
221            registry: ToolRegistry::from_registered(tools, true, trace.clone()),
222            invocation_log: trace.invocation_log,
223        }
224    }
225
226    pub(crate) fn from_registry(registry: ToolRegistry) -> Self {
227        let invocation_log = registry.default_trace().invocation_log;
228        Self {
229            registry,
230            invocation_log,
231        }
232    }
233
234    pub(crate) fn with_invocation_log(mut self, log: InvocationLog) -> Self {
235        self.invocation_log = log;
236        self.registry = ToolRegistry::from_registered(
237            self.registry.tools().to_vec(),
238            self.registry.sanitize_errors(),
239            ToolDefInvocationTrace {
240                invocation_log: Arc::clone(&self.invocation_log),
241            },
242        );
243        self
244    }
245
246    /// Control whether callback errors are sanitized.
247    pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
248        self.registry = ToolRegistry::from_registered(
249            self.registry.tools().to_vec(),
250            sanitize,
251            ToolDefInvocationTrace {
252                invocation_log: Arc::clone(&self.invocation_log),
253            },
254        );
255        self
256    }
257
258    /// Return an explicit handle for this extension's invocation trace.
259    ///
260    /// The handle intentionally shares trace state with this extension and can
261    /// be retained after moving the extension into a `Bash` builder. Prefer a
262    /// distinct extension/handle pair per tenant.
263    pub fn invocation_trace(&self) -> ToolDefInvocationTrace {
264        ToolDefInvocationTrace {
265            invocation_log: Arc::clone(&self.invocation_log),
266        }
267    }
268
269    /// Return and clear accumulated command invocation trace entries.
270    pub fn take_invocations(&self) -> Vec<ScriptedCommandInvocation> {
271        self.invocation_trace().take_invocations()
272    }
273
274    fn snapshots(&self) -> Vec<ToolDefSnapshot> {
275        self.registry
276            .tools()
277            .iter()
278            .map(|t| ToolDefSnapshot {
279                name: t.def.name.clone(),
280                description: t.def.description.clone(),
281                input_schema: t.def.input_schema.clone(),
282                tags: t.def.tags.clone(),
283                category: t.def.category.clone(),
284            })
285            .collect()
286    }
287}
288
289impl Extension for ToolDefExtension {
290    fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
291        let mut builtins: Vec<(String, Box<dyn Builtin>)> = Vec::new();
292        for tool in self.registry.tools() {
293            let name = tool.def.name.clone();
294            builtins.push((
295                name.clone(),
296                Box::new(ToolBuiltinAdapter {
297                    name,
298                    description: tool.def.description.clone(),
299                    registry: self.registry.clone(),
300                    schema: tool.def.input_schema.clone(),
301                    log: Arc::clone(&self.invocation_log),
302                    sanitize_errors: self.registry.sanitize_errors(),
303                    dry_run: tool.dry_run.clone(),
304                }),
305            ));
306        }
307
308        let snapshots = self.snapshots();
309        builtins.push((
310            "help".to_string(),
311            Box::new(HelpBuiltin {
312                tools: snapshots.clone(),
313                log: Arc::clone(&self.invocation_log),
314            }),
315        ));
316        builtins.push((
317            "discover".to_string(),
318            Box::new(DiscoverBuiltin {
319                tools: snapshots,
320                log: Arc::clone(&self.invocation_log),
321            }),
322        ));
323        builtins
324    }
325}
326
327/// Adapts a [`CallbackKind`] into a [`Builtin`] so the interpreter can execute it.
328struct ToolBuiltinAdapter {
329    name: String,
330    description: String,
331    registry: ToolRegistry,
332    schema: serde_json::Value,
333    log: InvocationLog,
334    sanitize_errors: bool,
335    dry_run: Option<CallbackKind>,
336}
337
338impl ToolBuiltinAdapter {
339    fn help_text(&self) -> String {
340        let mut out = format!("{} - {}\n", self.name, self.description);
341        if let Some(usage) = usage_from_schema(&self.schema) {
342            out.push_str(&format!("Usage: {} {}\n", self.name, usage));
343        }
344        out
345    }
346}
347
348#[async_trait]
349impl Builtin for ToolBuiltinAdapter {
350    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
351        if ctx.args.iter().any(|a| a == "--help") {
352            let result = ExecResult::ok(self.help_text());
353            push_invocation(
354                &self.log,
355                &self.name,
356                ScriptedCommandKind::Help,
357                ctx.args,
358                result.exit_code,
359            );
360            return Ok(result);
361        }
362
363        if ctx.args.iter().any(|a| a == "--dry-run") {
364            let stripped: Vec<String> = ctx
365                .args
366                .iter()
367                .filter(|a| a.as_str() != "--dry-run")
368                .cloned()
369                .collect();
370            let exit_result = match parse_flags(&stripped, &self.schema) {
371                Ok(params) => {
372                    if let Some(ref dr) = self.dry_run {
373                        let tenant_id = ctx
374                            .execution_extension::<crate::ToolCallRequest>()
375                            .and_then(|request| {
376                                request
377                                    .try_with(|request| request.tenant_id().to_string())
378                                    .ok()
379                            });
380                        let context = crate::tool_def::ToolArgsContextData::new(
381                            tenant_id,
382                            ToolCallSurface::Shell,
383                        );
384                        let capability = ctx
385                            .execution_capability(context)
386                            .expect("tool callback executes inside an execution scope");
387                        let tool_args =
388                            ToolArgs::with_context(params, ctx.stdin.map(String::from), capability);
389                        let cb_result = match dr {
390                            CallbackKind::Sync(cb) => (cb)(&tool_args),
391                            CallbackKind::Async(cb) => (cb)(tool_args).await,
392                        };
393                        match cb_result {
394                            Ok(stdout) => ExecResult::ok(stdout),
395                            Err(_msg) if self.sanitize_errors => {
396                                #[cfg(feature = "tracing")]
397                                tracing::debug!(
398                                    tool = %self.name,
399                                    error = %_msg,
400                                    "tool dry-run callback error (sanitized)"
401                                );
402                                ExecResult::err(format!("{}: callback failed\n", self.name), 1)
403                            }
404                            Err(msg) => ExecResult::err(msg, 1),
405                        }
406                    } else {
407                        let obj = serde_json::json!({
408                            "dry_run": true,
409                            "valid": true,
410                            "tool": self.name,
411                            "params": params,
412                        });
413                        ExecResult::ok(format!(
414                            "{}\n",
415                            serde_json::to_string(&obj).unwrap_or_default()
416                        ))
417                    }
418                }
419                Err(err) => {
420                    let obj = serde_json::json!({
421                        "dry_run": true,
422                        "valid": false,
423                        "tool": self.name,
424                        "error": err,
425                    });
426                    let json = serde_json::to_string(&obj).unwrap_or_default();
427                    ExecResult::err(format!("{json}\n"), 1)
428                }
429            };
430            push_invocation(
431                &self.log,
432                &self.name,
433                ScriptedCommandKind::Tool,
434                ctx.args,
435                exit_result.exit_code,
436            );
437            return Ok(exit_result);
438        }
439
440        let exit_result = match parse_flags(ctx.args, &self.schema) {
441            Ok(params) => {
442                let output = self
443                    .registry
444                    .invoke_from_context(
445                        &self.name,
446                        params,
447                        ctx.stdin.map(|stdin| stdin.text_lossy().into_owned()),
448                        ToolCallSurface::Shell,
449                        &ctx,
450                    )
451                    .await;
452                // A cancelled/closed request must not turn into a normal tool
453                // exit merely because the registry uses a script-level result.
454                if let Some(budget) = ctx.execution_budget() {
455                    budget
456                        .try_with(|budget| budget.check())
457                        .map_err(|_| bashkit::Error::Cancelled)??;
458                }
459                ExecResult {
460                    stdout: output.stdout.into(),
461                    stderr: output.stderr.into(),
462                    exit_code: output.exit_code,
463                    ..Default::default()
464                }
465            }
466            Err(msg) => ExecResult::err(msg, 2),
467        };
468        Ok(exit_result)
469    }
470}
471
472#[derive(Clone)]
473struct ToolDefSnapshot {
474    name: String,
475    description: String,
476    input_schema: serde_json::Value,
477    tags: Vec<String>,
478    category: Option<String>,
479}
480
481struct HelpBuiltin {
482    tools: Vec<ToolDefSnapshot>,
483    log: InvocationLog,
484}
485
486#[async_trait]
487impl Builtin for HelpBuiltin {
488    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
489        let args = ctx.args;
490        let result = if args.is_empty() || (args.len() == 1 && args[0] == "--list") {
491            let mut out = String::new();
492            for t in &self.tools {
493                out.push_str(&format!("{:<20} {}\n", t.name, t.description));
494            }
495            ExecResult::ok(out)
496        } else {
497            let tool_name = args.iter().find(|a| !a.starts_with("--"));
498            let json_mode = args.iter().any(|a| a == "--json");
499
500            let Some(tool_name) = tool_name else {
501                let result =
502                    ExecResult::err("usage: help [--list] [<tool>] [--json]".to_string(), 1);
503                push_invocation(
504                    &self.log,
505                    "help",
506                    ScriptedCommandKind::Help,
507                    args,
508                    result.exit_code,
509                );
510                return Ok(result);
511            };
512
513            let Some(tool) = self.tools.iter().find(|t| t.name == *tool_name) else {
514                let result = ExecResult::err(format!("help: unknown tool: {tool_name}"), 1);
515                push_invocation(
516                    &self.log,
517                    "help",
518                    ScriptedCommandKind::Help,
519                    args,
520                    result.exit_code,
521                );
522                return Ok(result);
523            };
524
525            if json_mode {
526                let obj = serde_json::json!({
527                    "name": tool.name,
528                    "description": tool.description,
529                    "input_schema": tool.input_schema,
530                });
531                let json_str = serde_json::to_string_pretty(&obj).unwrap_or_default();
532                ExecResult::ok(format!("{json_str}\n"))
533            } else {
534                let mut out = format!("{} - {}\n", tool.name, tool.description);
535                if let Some(usage) = usage_from_schema(&tool.input_schema) {
536                    out.push_str(&format!("Usage: {} {}\n", tool.name, usage));
537                }
538                ExecResult::ok(out)
539            }
540        };
541
542        push_invocation(
543            &self.log,
544            "help",
545            ScriptedCommandKind::Help,
546            args,
547            result.exit_code,
548        );
549        Ok(result)
550    }
551}
552
553struct DiscoverBuiltin {
554    tools: Vec<ToolDefSnapshot>,
555    log: InvocationLog,
556}
557
558impl DiscoverBuiltin {
559    fn filter_tools(&self, args: &[String]) -> Vec<&ToolDefSnapshot> {
560        if let Some(pos) = args.iter().position(|a| a == "--category") {
561            let cat = args.get(pos + 1).map(|s| s.as_str()).unwrap_or("");
562            return self
563                .tools
564                .iter()
565                .filter(|t| t.category.as_deref() == Some(cat))
566                .collect();
567        }
568
569        if let Some(pos) = args.iter().position(|a| a == "--tag") {
570            let tag = args.get(pos + 1).map(|s| s.as_str()).unwrap_or("");
571            return self
572                .tools
573                .iter()
574                .filter(|t| t.tags.iter().any(|tg| tg == tag))
575                .collect();
576        }
577
578        if let Some(pos) = args.iter().position(|a| a == "--search") {
579            let keyword = args
580                .get(pos + 1)
581                .map(|s| s.to_lowercase())
582                .unwrap_or_default();
583            return self
584                .tools
585                .iter()
586                .filter(|t| {
587                    t.name.to_lowercase().contains(&keyword)
588                        || t.description.to_lowercase().contains(&keyword)
589                })
590                .collect();
591        }
592
593        self.tools.iter().collect()
594    }
595}
596
597#[async_trait]
598impl Builtin for DiscoverBuiltin {
599    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
600        let args = ctx.args;
601        let result = if args.is_empty() {
602            ExecResult::err(
603                "usage: discover --categories | --category <name> | --tag <tag> | --search <keyword> [--json]".to_string(),
604                1,
605            )
606        } else {
607            let json_mode = args.iter().any(|a| a == "--json");
608
609            if args.iter().any(|a| a == "--categories") {
610                let mut cats: std::collections::BTreeMap<String, usize> =
611                    std::collections::BTreeMap::new();
612                for t in &self.tools {
613                    if let Some(ref cat) = t.category {
614                        *cats.entry(cat.clone()).or_insert(0) += 1;
615                    }
616                }
617                if json_mode {
618                    let arr: Vec<serde_json::Value> = cats
619                        .iter()
620                        .map(|(name, count)| serde_json::json!({"category": name, "count": count}))
621                        .collect();
622                    let json_str =
623                        serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string());
624                    ExecResult::ok(format!("{json_str}\n"))
625                } else {
626                    let mut out = String::new();
627                    for (name, count) in &cats {
628                        let plural = if *count == 1 { "tool" } else { "tools" };
629                        out.push_str(&format!("{name} ({count} {plural})\n"));
630                    }
631                    ExecResult::ok(out)
632                }
633            } else {
634                let filtered = self.filter_tools(args);
635                if json_mode {
636                    let arr: Vec<serde_json::Value> = filtered
637                        .iter()
638                        .map(|t| {
639                            let mut obj = serde_json::json!({
640                                "name": t.name,
641                                "description": t.description,
642                            });
643                            if !t.tags.is_empty() {
644                                obj["tags"] = serde_json::json!(t.tags);
645                            }
646                            if let Some(ref cat) = t.category {
647                                obj["category"] = serde_json::json!(cat);
648                            }
649                            obj
650                        })
651                        .collect();
652                    let json_str =
653                        serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string());
654                    ExecResult::ok(format!("{json_str}\n"))
655                } else {
656                    let mut out = String::new();
657                    for t in &filtered {
658                        out.push_str(&format!("{:<20} {}\n", t.name, t.description));
659                    }
660                    ExecResult::ok(out)
661                }
662            }
663        };
664
665        push_invocation(
666            &self.log,
667            "discover",
668            ScriptedCommandKind::Discover,
669            args,
670            result.exit_code,
671        );
672        Ok(result)
673    }
674}