1use 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 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
60pub 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 pub fn tool(mut self, tool: ToolImpl) -> Self {
78 self.tools.push(RegisteredTool::from_tool_impl(tool));
79 self
80 }
81
82 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 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 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 pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
127 self.sanitize_errors = sanitize;
128 self
129 }
130
131 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
151pub struct ToolDefExtension {
160 registry: ToolRegistry,
161 invocation_log: InvocationLog,
162}
163
164#[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 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 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 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 pub fn invocation_trace(&self) -> ToolDefInvocationTrace {
264 ToolDefInvocationTrace {
265 invocation_log: Arc::clone(&self.invocation_log),
266 }
267 }
268
269 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
327struct 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 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}