flashmind_types/tool.rs
1//! Tool trait, registry, context, and result types for agent function calling.
2//!
3//! # Key types
4//!
5//! | Type | Role |
6//! |------|------|
7//! | [`Tool`] | Trait for tools callable by the agent (name, description, params, execute) |
8//! | [`ToolRegistry`] | Registry of available tools with aliases |
9//! | [`ToolContext`] | Per-invocation context passed to `execute()` |
10//! | [`ToolResult`] | Result of a tool execution returned to the LLM |
11//! | [`FileDiff`] | A file diff produced by a tool |
12//! | [`ForbiddenCmd`] | Command restriction pattern (regex-based) |
13
14use async_trait::async_trait;
15use serde::de::DeserializeOwned;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::any::Any;
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use tokio_util::sync::CancellationToken;
23
24use crate::event::Source;
25use crate::llm::ToolDefinition;
26use crate::{AgentLlmConfig, LlmProvider};
27
28// ---------------------------------------------------------------------------
29// ForbiddenCmd
30// ---------------------------------------------------------------------------
31
32/// A forbidden command configuration.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ForbiddenCmd {
35 /// Regex pattern to match against the full command line (command + args).
36 pub command: String,
37 /// Reason shown to user when blocked.
38 pub reason: String,
39 /// If true, inject a warning message and let the LLM reconsider instead of rejecting.
40 /// Like tool-aware RAG, this will retry the turn with additional context.
41 #[serde(default)]
42 pub reconsider: bool,
43}
44
45impl ForbiddenCmd {
46 /// Validate the forbidden command config.
47 pub fn validate(&self) -> std::result::Result<(), Vec<String>> {
48 let mut errors = Vec::new();
49 if self.command.is_empty() {
50 errors.push("forbidden command name cannot be empty".to_string());
51 }
52 if regex::Regex::new(&self.command).is_err() {
53 errors.push(format!("invalid regex for name: '{}'", self.command));
54 }
55 if errors.is_empty() {
56 Ok(())
57 } else {
58 Err(errors)
59 }
60 }
61}
62
63// ---------------------------------------------------------------------------
64// Command allow-list
65// ---------------------------------------------------------------------------
66
67/// Trait for checking whether a command is allowed to execute.
68///
69/// Implementations live in the app layer; tools receive this as
70/// `Arc<dyn CommandAllowList>`. When no allow-list is set (e.g. in tests
71/// or desktop), all commands execute freely.
72pub trait CommandAllowList: Send + Sync {
73 /// Check whether a command is pre-approved (permanent config or session).
74 fn is_allowed(&self, command: &str) -> bool;
75
76 /// Record a session-level approval pattern (glob-style).
77 ///
78 /// The `pattern` argument expects a glob-style string (e.g. `"git commit -m '*'"`,
79 /// `"cargo build"`). Patterns are matched against the full command line at execution time.
80 fn add_session_pattern(&self, pattern: &str);
81}
82
83// ---------------------------------------------------------------------------
84// InterruptPayload
85// ---------------------------------------------------------------------------
86
87/// Typed payload for [`ToolResult::Interrupt`].
88///
89/// Tools define concrete structs implementing this trait. The CLI/UI layer
90/// downcasts via [`as_any`](Self::as_any) to handle each type specifically.
91pub trait InterruptPayload: Send + Sync + std::fmt::Debug {
92 /// Downcast support — return `self` as `&dyn Any`.
93 fn as_any(&self) -> &dyn Any;
94
95 /// Human-readable description of the interrupt, used as the tool result
96 /// string in the conversation so the LLM knows what happened.
97 fn display_output(&self) -> String;
98}
99
100// ---------------------------------------------------------------------------
101// FileDiff
102// ---------------------------------------------------------------------------
103
104/// A single line in a unified diff output.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub enum DiffLine {
107 /// A line that was added to the file.
108 Added { line: u64, content: String },
109 /// A line that was removed from the file.
110 Removed { line: u64, content: String },
111 /// An unchanged context line surrounding the actual changes.
112 /// Provides locality so a reviewer can see *where* the edit landed.
113 Context { line: u64, content: String },
114}
115
116/// A file diff produced by a tool.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct FileDiff {
119 /// Absolute or relative path to the modified file.
120 pub path: String,
121 /// List of added/removed lines that make up this diff.
122 pub diff: Vec<DiffLine>,
123}
124
125// ---------------------------------------------------------------------------
126// ToolResult (tool execution result — distinct from message::ToolResult)
127// ---------------------------------------------------------------------------
128
129/// Result of a tool execution, returned by [`Tool::execute`] and fed back to
130/// the LLM as a `"role": "tool"` message.
131///
132/// Three variants cover the full lifecycle of a tool call:
133///
134/// - **`Success`** — The tool ran without error. The `output` field contains
135/// the text payload sent back to the model. Optional `sources` (URLs the
136/// agent can cite) and `diffs` (unified diffs for file-mutating tools) may
137/// be attached.
138/// - **`Failure`** — The tool encountered an error (invalid arguments, I/O
139/// failure, etc.). The `output` field carries a human- and LLM-readable
140/// error description. Unlike `Success`, no sources or diffs are included.
141/// - **`Interrupt`** — The tool needs user interaction before continuing
142/// (e.g. plan approval, choice picker). The agent loop should halt and
143/// surface the `output` (structured as JSON) to the caller for handling.
144///
145/// # Construction helpers
146///
147/// Prefer the associated constructors on the `impl` block rather than building
148/// variants directly:
149///
150/// ```ignore
151/// use flashmind_types::tool::ToolResult;
152///
153/// // Basic success
154/// ToolResult::success(call_id, "file written")
155///
156/// // Failure with error message
157/// ToolResult::failure(call_id, "permission denied")
158///
159/// // Success with file diffs attached
160/// ToolResult::success_with_diffs(call_id, "replaced", vec![diff])
161///
162/// // Interrupt for interactive prompt
163/// ToolResult::interrupt(call_id, Arc::new(MyPayload { ... }))
164///
165/// // Chain .with_sources() onto any Success
166/// ToolResult::success(call_id, results).with_sources(srcs)
167/// ```
168///
169/// # Relation to [`message::ToolResult`](crate::message::ToolResult)
170///
171/// This type lives in the *tool* module and represents the raw outcome of
172/// executing a tool. It is converted into a [`Message`](crate::message::Message)
173/// with `role: "tool"` (via [`AgentEvent::ToolResult`](crate::event::AgentEvent::ToolResult))
174/// before being appended to the conversation history sent to the LLM.
175#[derive(Clone)]
176pub enum ToolResult {
177 /// Tool executed successfully.
178 Success {
179 tool_call_id: String,
180 output: String,
181 sources: Vec<Source>,
182 diffs: Vec<FileDiff>,
183 /// New agent working directory after a successful workspace switch.
184 working_dir: Option<PathBuf>,
185 },
186 /// Tool execution failed.
187 Failure {
188 tool_call_id: String,
189 output: String,
190 },
191 /// The agent loop should stop and return this result to the caller for
192 /// interactive handling. The typed payload is downcast by the caller to
193 /// determine the interrupt kind.
194 Interrupt {
195 tool_call_id: String,
196 payload: Arc<dyn InterruptPayload>,
197 },
198}
199
200impl std::fmt::Debug for ToolResult {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Success {
204 tool_call_id,
205 output,
206 ..
207 } => f
208 .debug_struct("Success")
209 .field("tool_call_id", tool_call_id)
210 .field("output", output)
211 .finish(),
212 Self::Failure {
213 tool_call_id,
214 output,
215 } => f
216 .debug_struct("Failure")
217 .field("tool_call_id", tool_call_id)
218 .field("output", output)
219 .finish(),
220 Self::Interrupt {
221 tool_call_id,
222 payload,
223 } => f
224 .debug_struct("Interrupt")
225 .field("tool_call_id", tool_call_id)
226 .field("output", &payload.display_output())
227 .finish(),
228 }
229 }
230}
231
232impl ToolResult {
233 /// Create a basic success result with no sources or diffs.
234 pub fn success(tool_call_id: &str, output: impl Into<String>) -> Self {
235 Self::Success {
236 tool_call_id: tool_call_id.into(),
237 output: output.into(),
238 sources: Vec::new(),
239 diffs: Vec::new(),
240 working_dir: None,
241 }
242 }
243
244 /// Create a failure result with an error message.
245 pub fn failure(tool_call_id: &str, output: impl Into<String>) -> Self {
246 Self::Failure {
247 tool_call_id: tool_call_id.into(),
248 output: output.into(),
249 }
250 }
251
252 /// Create a success result that includes file diffs for tools that
253 /// mutate content (file write, text replace, etc.).
254 pub fn success_with_diffs(
255 tool_call_id: &str,
256 output: impl Into<String>,
257 diffs: Vec<FileDiff>,
258 ) -> Self {
259 Self::Success {
260 tool_call_id: tool_call_id.into(),
261 output: output.into(),
262 sources: Vec::new(),
263 diffs,
264 working_dir: None,
265 }
266 }
267
268 /// Create an interrupt result that pauses the agent loop for interactive
269 /// user handling (command approval, OAuth, etc.).
270 ///
271 /// NOTE: the agent turn ends without a tool result in the conversation,
272 /// leaving a dangling tool call. Callers must add a `ConversationEntry::tool`
273 /// with the same `tool_call_id` after handling the interrupt.
274 pub fn interrupt(tool_call_id: &str, payload: Arc<dyn InterruptPayload>) -> Self {
275 Self::Interrupt {
276 tool_call_id: tool_call_id.into(),
277 payload,
278 }
279 }
280
281 /// Attach source URLs to a `Success` result. Returns `self` unchanged if
282 /// the variant is not `Success`.
283 ///
284 /// This is designed for method chaining:
285 /// ```ignore
286 /// ToolResult::success(id, "results").with_sources(srcs)
287 /// ```
288 pub fn with_sources(mut self, sources: Vec<Source>) -> Self {
289 if let Self::Success {
290 sources: ref mut s, ..
291 } = self
292 {
293 *s = sources;
294 }
295 self
296 }
297
298 /// Change this agent's working directory after the call succeeds.
299 /// Tools returning this effect must set `concurrent()` to false.
300 pub fn with_working_dir(mut self, path: PathBuf) -> Self {
301 if let Self::Success { working_dir, .. } = &mut self {
302 *working_dir = Some(path);
303 }
304 self
305 }
306
307 /// Working directory selected by a successful tool call.
308 pub fn working_dir(&self) -> Option<&Path> {
309 match self {
310 Self::Success { working_dir, .. } => working_dir.as_deref(),
311 _ => None,
312 }
313 }
314
315 /// Return the tool call ID echoed from the original invocation.
316 pub fn tool_call_id(&self) -> &str {
317 match self {
318 Self::Success { tool_call_id, .. }
319 | Self::Failure { tool_call_id, .. }
320 | Self::Interrupt { tool_call_id, .. } => tool_call_id,
321 }
322 }
323
324 /// Return the text output payload (success result, error message, or
325 /// interrupt display string).
326 pub fn output(&self) -> String {
327 match self {
328 Self::Success { output, .. } | Self::Failure { output, .. } => output.clone(),
329 Self::Interrupt { payload, .. } => payload.display_output(),
330 }
331 }
332
333 /// Return the typed interrupt payload, if this is an Interrupt variant.
334 pub fn payload(&self) -> Option<&Arc<dyn InterruptPayload>> {
335 match self {
336 Self::Interrupt { payload, .. } => Some(payload),
337 _ => None,
338 }
339 }
340
341 /// Check whether this result represents a successful execution.
342 pub fn is_success(&self) -> bool {
343 matches!(self, Self::Success { .. })
344 }
345
346 /// Check whether this result is an interrupt requiring user interaction.
347 pub fn is_interrupt(&self) -> bool {
348 matches!(self, Self::Interrupt { .. })
349 }
350
351 /// Return the attached source references, or an empty slice for
352 /// non-success variants.
353 pub fn sources(&self) -> &[Source] {
354 match self {
355 Self::Success { sources, .. } => sources,
356 _ => &[],
357 }
358 }
359
360 /// Return the attached file diffs, or an empty slice for non-success
361 /// variants.
362 pub fn diffs(&self) -> &[FileDiff] {
363 match self {
364 Self::Success { diffs, .. } => diffs,
365 _ => &[],
366 }
367 }
368
369 /// Truncate output to fit within byte and line limits.
370 /// Appends `\n[truncated]` when the output was cut.
371 pub fn truncate_output(&mut self, max_bytes: usize, max_lines: usize) {
372 let output = match self {
373 Self::Success { output, .. } | Self::Failure { output, .. } => output,
374 Self::Interrupt { .. } => return,
375 };
376
377 if output.len() <= max_bytes && max_lines == usize::MAX {
378 return;
379 }
380
381 let byte_cut = output.floor_char_boundary(max_bytes);
382
383 let line_cut = output
384 .match_indices('\n')
385 .nth(max_lines.saturating_sub(1))
386 .map(|(i, _)| i)
387 .unwrap_or(output.len());
388
389 let cut = byte_cut.min(line_cut);
390 if cut < output.len() {
391 output.truncate(cut);
392 output.push_str("\n[truncated]");
393 }
394 }
395}
396
397// ---------------------------------------------------------------------------
398// parse_args
399// ---------------------------------------------------------------------------
400
401/// Deserialize tool arguments from a serde_json::Value into a typed struct.
402/// Returns a typed error mentioning which tool failed to parse.
403pub fn parse_args<T: DeserializeOwned>(tool_name: &str, args: Value) -> anyhow::Result<T> {
404 serde_json::from_value(args)
405 .map_err(|e| anyhow::anyhow!("Tool '{}': Invalid arguments: {}", tool_name, e))
406}
407
408// ---------------------------------------------------------------------------
409// Tool trait
410// ---------------------------------------------------------------------------
411
412/// Trait for tools that can be invoked by the agent.
413///
414/// Implementations define a name, description, JSON schema parameters, and async execution
415/// logic. Tools are registered in a [`ToolRegistry`] and become available to the LLM during
416/// conversation turns via function calling.
417///
418/// # Required methods
419///
420/// - [`name`](Self::name) — unique identifier used by the LLM to invoke the tool
421/// - [`description`](Self::description) — shown to the LLM to decide when to call the tool
422/// - [`parameters`](Self::parameters) — JSON Schema object describing expected arguments
423/// - [`execute`](Self::execute) — async logic that runs when the tool is called
424///
425/// # Optional overrides
426///
427/// - [`humanize`](Self::humanize) — display-friendly summary (default: comma-separated arg keys)
428/// - [`timeout_secs`](Self::timeout_secs) — per-tool timeout (default: uses global default)
429/// - [`max_output_bytes`](Self::max_output_bytes) — output size limit (default: 256 KiB)
430/// - [`max_output_lines`](Self::max_output_lines) — output line count limit (default: 10,000)
431#[async_trait]
432pub trait Tool: Send + Sync {
433 /// Unique tool name (used for invocation). Must match the name the LLM sees in its function definitions.
434 fn name(&self) -> &str;
435 /// Human-readable description for the LLM. Used to help the model decide when to invoke this tool.
436 fn description(&self) -> &str;
437 /// JSON Schema object describing the tool's parameters.
438 fn parameters(&self) -> Value;
439 /// Execute the tool with given arguments. Returns a [`ToolResult`] on success.
440 async fn execute(&self, ctx: ToolContext<'_>) -> anyhow::Result<ToolResult>;
441 /// Optional per-tool timeout in seconds. Returns `None` to use the global default.
442 fn timeout_secs(&self) -> Option<u64> {
443 None
444 }
445
446 /// Maximum allowed output size in bytes. Tools that produce large payloads
447 /// (e.g. base64-encoded images, arbitrary MCP responses) should return
448 /// `usize::MAX` to bypass the limit.
449 fn max_output_bytes(&self) -> usize {
450 256 * 1024
451 }
452
453 /// Maximum allowed output line count. Tools that produce large payloads
454 /// should return `usize::MAX` to bypass the limit.
455 fn max_output_lines(&self) -> usize {
456 10_000
457 }
458
459 /// Whether this tool may run at the same time as other tools from the
460 /// same model reply, when the agent allows parallel tool calls. Return
461 /// `false` for tools that share state or act on one device, so they run
462 /// alone between concurrent batches.
463 fn concurrent(&self) -> bool {
464 true
465 }
466
467 /// Whether a running call can continue as a managed background task.
468 fn supports_background(&self) -> bool {
469 false
470 }
471
472 /// Generate a human-readable description of what this tool call does,
473 /// suitable for display in a TUI or chat card (e.g. "Reading file Cargo.toml").
474 fn humanize(&self, args: &Value) -> String {
475 let Some(obj) = args.as_object() else {
476 return self.name().to_string();
477 };
478 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
479 if keys.is_empty() {
480 return self.name().to_string();
481 }
482 let summary = keys.join(", ");
483 if summary.len() > 80 {
484 let truncated: String = summary.chars().take(77).collect();
485 format!("{truncated}…")
486 } else {
487 summary
488 }
489 }
490}
491
492// ---------------------------------------------------------------------------
493// ToolContext
494// ---------------------------------------------------------------------------
495
496/// A request to move a running tool call into the background.
497#[derive(Clone, Debug, Default)]
498pub struct BackgroundRequest {
499 requested: CancellationToken,
500}
501
502impl BackgroundRequest {
503 pub fn request(&self) {
504 self.requested.cancel();
505 }
506
507 pub async fn requested(&self) {
508 self.requested.cancelled().await;
509 }
510}
511
512/// Per-invocation context passed to [`Tool::execute`] methods.
513///
514/// Carries everything a tool needs: the call ID for building results, raw JSON arguments,
515/// cancellation support, and the working directory for path resolution.
516///
517/// # Key fields
518///
519/// | Field | Purpose |
520/// |-------|---------|
521/// | [`tool_call_id`](Self::tool_call_id) | Echo into [`ToolResult::tool_call_id`] so results match calls |
522/// | [`args`](Self::args) | Raw JSON arguments sent by the LLM — parse with [`parse_args`](Self::parse_args) |
523/// | [`working_dir`](Self::working_dir) | Chat workspace root; relative paths resolve against this |
524pub struct ToolContext<'a> {
525 /// The unique ID for this tool call. Echo into [`ToolResult::tool_call_id`] when returning.
526 pub tool_call_id: &'a str,
527 /// Raw JSON arguments from the LLM. Use [`parse_args`](Self::parse_args) to deserialize.
528 pub args: Value,
529 /// Current parent configuration, including its cache routing identity.
530 pub llm: Option<&'a AgentLlmConfig>,
531 /// Current provider for tools that delegate model requests.
532 pub provider: Option<Arc<dyn LlmProvider>>,
533 /// Working directory for the current agent (typically chat workspace). Relative paths resolve here.
534 pub working_dir: Option<&'a PathBuf>,
535 /// Cancellation token for cooperative cancellation. Access via [`cancel_token`](Self::cancel_token) or [`child_token`](Self::child_token).
536 cancel_token: &'a CancellationToken,
537 /// Optional channel for streaming incremental output during execution.
538 progress_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
539 background_request: Option<&'a BackgroundRequest>,
540}
541
542impl<'a> ToolContext<'a> {
543 /// Create a new tool execution context.
544 pub fn new(
545 tool_call_id: &'a str,
546 args: Value,
547 working_dir: Option<&'a PathBuf>,
548 cancel_token: &'a CancellationToken,
549 ) -> Self {
550 Self {
551 tool_call_id,
552 args,
553 llm: None,
554 provider: None,
555 working_dir,
556 cancel_token,
557 progress_tx: None,
558 background_request: None,
559 }
560 }
561
562 /// Pass the current agent configuration to the tool.
563 pub fn with_llm(mut self, llm: &'a AgentLlmConfig) -> Self {
564 self.llm = Some(llm);
565 self
566 }
567
568 /// Pass the current provider to tools that delegate model requests.
569 pub fn with_provider(mut self, provider: Arc<dyn LlmProvider>) -> Self {
570 self.provider = Some(provider);
571 self
572 }
573
574 /// Attach a progress channel for streaming incremental output.
575 pub fn with_progress(mut self, tx: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
576 self.progress_tx = Some(tx);
577 self
578 }
579
580 /// Attach the user's control for backgrounding this running call.
581 pub fn with_background_request(mut self, request: &'a BackgroundRequest) -> Self {
582 self.background_request = Some(request);
583 self
584 }
585
586 pub fn background_request(&self) -> Option<&BackgroundRequest> {
587 self.background_request
588 }
589
590 /// Clone the live output destination when execution moves to an owned task.
591 pub fn progress_sender(&self) -> Option<tokio::sync::mpsc::UnboundedSender<String>> {
592 self.progress_tx.clone()
593 }
594
595 /// Send a progress line through the channel (no-op if no channel is set).
596 pub fn progress(&self, line: &str) {
597 if let Some(ref tx) = self.progress_tx {
598 let _ = tx.send(line.to_string());
599 }
600 }
601
602 /// Parse the raw JSON args into a typed struct. Fails with a descriptive error if invalid.
603 pub fn parse_args<T: DeserializeOwned>(&self, tool_name: &str) -> anyhow::Result<T> {
604 T::deserialize(&self.args)
605 .map_err(|e| anyhow::anyhow!("Tool '{}': Invalid arguments: {}", tool_name, e))
606 }
607
608 /// Access the cancellation token for cooperative cancellation checks.
609 ///
610 /// For long-running operations, prefer [`child_token`](Self::child_token) to get
611 /// a derived token that can be passed to async sub-operations.
612 pub fn cancel_token(&self) -> &CancellationToken {
613 self.cancel_token
614 }
615
616 /// Create a child cancellation token. Cancels when the parent turn is cancelled.
617 /// Use this for spawned tasks so they're automatically cancelled when the user aborts.
618 pub fn child_token(&self) -> CancellationToken {
619 self.cancel_token.child_token()
620 }
621}
622
623// ---------------------------------------------------------------------------
624// ToolRegistry
625// ---------------------------------------------------------------------------
626
627/// Registry of tools available to an agent.
628///
629/// Stores tools in a HashMap for O(1) lookup by name and supports aliases
630/// (e.g., `"bash_exec"` → `"exec"`) that are resolved transparently but not
631/// exposed in `definitions()` or `list()`.
632#[derive(Clone)]
633pub struct ToolRegistry {
634 tools: HashMap<String, Arc<dyn Tool>>,
635 aliases: HashMap<String, String>,
636 truncate_on_overflow: bool,
637}
638
639impl ToolRegistry {
640 /// Create an empty tool registry.
641 pub fn new() -> Self {
642 Self {
643 tools: HashMap::new(),
644 aliases: HashMap::new(),
645 truncate_on_overflow: false,
646 }
647 }
648
649 /// When `true`, oversized output is truncated instead of failing the tool
650 /// call. Default: `false` (fail with an error so the agent can retry).
651 pub fn set_truncate_on_overflow(&mut self, enabled: bool) {
652 self.truncate_on_overflow = enabled;
653 }
654
655 /// Register a tool (replaces existing tool with same name).
656 pub fn register(&mut self, tool: Arc<dyn Tool>) {
657 tracing::debug!(tool = tool.name(), "registered tool");
658 self.tools.insert(tool.name().to_string(), tool);
659 }
660
661 /// Remove a tool by name. Returns true if the tool was present.
662 pub fn remove(&mut self, name: &str) -> bool {
663 let removed = self.tools.remove(name).is_some();
664 if removed {
665 let tools = &self.tools;
666 self.aliases.retain(|_, target| tools.contains_key(target));
667 }
668 removed
669 }
670
671 /// Remove every tool whose name starts with one of `prefixes`. Aliases
672 /// pointing at removed tools are also dropped.
673 pub fn strip_prefixes(&mut self, prefixes: &[&str]) {
674 let to_remove: Vec<String> = self
675 .tools
676 .keys()
677 .filter(|name| prefixes.iter().any(|p| name.starts_with(p)))
678 .cloned()
679 .collect();
680 for name in to_remove {
681 self.tools.remove(&name);
682 }
683 let tools = &self.tools;
684 self.aliases.retain(|_, target| tools.contains_key(target));
685 }
686
687 /// Register an alias so that lookups for `alias` resolve to `target`.
688 /// Aliases are not exposed in `definitions()` or `list()`.
689 pub fn alias(&mut self, alias: &str, target: &str) {
690 tracing::debug!(alias, target, "registered tool alias");
691 self.aliases.insert(alias.to_string(), target.to_string());
692 }
693
694 /// Look up a tool by name (checks aliases if direct lookup fails).
695 /// Always resolves any registered tool, even if not in the active set.
696 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
697 let result = self.tools.get(name).or_else(|| {
698 if let Some(target) = self.aliases.get(name) {
699 tracing::debug!(
700 alias = name,
701 target = target.as_str(),
702 "resolved tool alias"
703 );
704 self.tools.get(target)
705 } else {
706 None
707 }
708 });
709
710 if result.is_none() {
711 tracing::warn!(tool = name, "tool not found in registry");
712 }
713
714 result
715 }
716
717 /// Returns true if a tool with the given name is registered (without logging).
718 pub fn contains(&self, name: &str) -> bool {
719 self.resolve(name).is_some()
720 }
721
722 /// Look up a tool directly or through an alias, without logging.
723 fn resolve(&self, name: &str) -> Option<&Arc<dyn Tool>> {
724 self.tools.get(name).or_else(|| {
725 self.aliases
726 .get(name)
727 .and_then(|target| self.tools.get(target))
728 })
729 }
730
731 /// Keep only tools whose name satisfies the predicate.
732 pub fn retain(&mut self, mut f: impl FnMut(&str) -> bool) {
733 self.tools.retain(|name, _| f(name));
734 let tools = &self.tools;
735 self.aliases.retain(|_, target| tools.contains_key(target));
736 }
737
738 /// List all registered tool names (sorted).
739 pub fn list(&self) -> Vec<&str> {
740 let mut names: Vec<&str> = self.tools.keys().map(|s| s.as_str()).collect();
741 names.sort();
742 names
743 }
744
745 /// List all registered tools with their names and descriptions (sorted by name).
746 pub fn list_with_descriptions(&self) -> Vec<(&str, &str)> {
747 let mut items: Vec<(&str, &str)> = self
748 .tools
749 .values()
750 .map(|t| (t.name(), t.description()))
751 .collect();
752 items.sort_by_key(|(name, _)| *name);
753 items
754 }
755
756 /// Execute a tool call from the LLM.
757 ///
758 /// Looks up the tool by name, builds a [`ToolContext`], and runs it. Returns
759 /// a [`ToolResult::Failure`] if the tool is not found or if execution errors.
760 /// If the tool defines [`timeout_secs`](Tool::timeout_secs), the call is
761 /// wrapped in a timeout. Tools without a timeout are cancelled immediately
762 /// when `cancel_token` fires.
763 pub async fn execute(
764 &self,
765 call: &crate::ToolCall,
766 working_dir: Option<&PathBuf>,
767 cancel_token: &CancellationToken,
768 ) -> ToolResult {
769 self.execute_inner(call, working_dir, cancel_token, None)
770 .await
771 }
772
773 /// Like [`Self::execute`] but attaches a progress channel so tools can stream
774 /// incremental output (e.g. stdout lines from bash).
775 pub async fn execute_with_progress(
776 &self,
777 call: &crate::ToolCall,
778 working_dir: Option<&PathBuf>,
779 cancel_token: &CancellationToken,
780 progress_tx: tokio::sync::mpsc::UnboundedSender<String>,
781 ) -> ToolResult {
782 self.execute_inner(call, working_dir, cancel_token, Some(progress_tx))
783 .await
784 }
785
786 async fn execute_inner(
787 &self,
788 call: &crate::ToolCall,
789 working_dir: Option<&PathBuf>,
790 cancel_token: &CancellationToken,
791 progress_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
792 ) -> ToolResult {
793 let mut ctx = ToolContext::new(&call.id, call.arguments.clone(), working_dir, cancel_token);
794 if let Some(tx) = progress_tx {
795 ctx = ctx.with_progress(tx);
796 }
797 self.execute_in_context(call, ctx).await
798 }
799
800 /// Execute with parent configuration and optional progress reporting.
801 pub async fn execute_in_context(
802 &self,
803 call: &crate::ToolCall,
804 ctx: ToolContext<'_>,
805 ) -> ToolResult {
806 let Some(tool) = self.get(&call.name) else {
807 return ToolResult::failure(&call.id, format!("Unknown tool: {}", call.name));
808 };
809 let cancel_token = ctx.cancel_token;
810 let child_token = cancel_token.child_token();
811 let ctx = ToolContext {
812 cancel_token: &child_token,
813 ..ctx
814 };
815 let fut = tool.execute(ctx);
816
817 // Always race against cancel_token so user-initiated cancellation
818 // works regardless of whether the tool defined a hard timeout.
819 let timeout_secs = tool.timeout_secs();
820 let run = async {
821 match timeout_secs {
822 Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), fut)
823 .await
824 .map_err(|_| secs),
825 None => Ok(fut.await),
826 }
827 };
828 let outcome = tokio::select! {
829 biased;
830 _ = cancel_token.cancelled() => {
831 child_token.cancel();
832 return ToolResult::failure(
833 &call.id,
834 format!("Tool '{}' cancelled", call.name),
835 );
836 }
837 r = run => match r {
838 Ok(result) => result,
839 Err(secs) => {
840 child_token.cancel();
841 return ToolResult::failure(
842 &call.id,
843 format!("Tool '{}' timed out after {secs}s", call.name),
844 );
845 }
846 },
847 };
848
849 let result = match outcome {
850 Ok(result) => result,
851 Err(e) => ToolResult::failure(&call.id, format!("Tool error: {e:#}")),
852 };
853
854 enforce_output_limits(
855 &call.id,
856 result,
857 tool.max_output_bytes(),
858 tool.max_output_lines(),
859 self.truncate_on_overflow,
860 )
861 }
862
863 /// Whether `call` may run alongside other calls. Unknown tools fail at
864 /// once, so they count as concurrent.
865 pub fn is_concurrent(&self, call: &crate::ToolCall) -> bool {
866 self.resolve(&call.name)
867 .is_none_or(|tool| tool.concurrent())
868 }
869
870 pub fn supports_background(&self, call: &crate::ToolCall) -> bool {
871 self.resolve(&call.name)
872 .is_some_and(|tool| tool.supports_background())
873 }
874
875 /// Human-readable summary of a tool call's arguments.
876 pub fn humanize(&self, call: &crate::ToolCall) -> String {
877 self.get(&call.name)
878 .map(|t| t.humanize(&call.arguments))
879 .unwrap_or_else(|| call.name.clone())
880 }
881
882 /// Generate LLM tool definitions for all registered tools.
883 pub fn definitions(&self) -> Vec<ToolDefinition> {
884 let mut defs: Vec<ToolDefinition> = self
885 .tools
886 .values()
887 .map(|t| ToolDefinition {
888 name: t.name().to_string(),
889 description: t.description().to_string(),
890 parameters: t.parameters(),
891 })
892 .collect();
893
894 defs.sort_by(|a, b| a.name.cmp(&b.name));
895
896 tracing::debug!(count = defs.len(), "generated tool definitions");
897
898 defs
899 }
900}
901
902impl Default for ToolRegistry {
903 fn default() -> Self {
904 Self::new()
905 }
906}
907
908/// Enforce output size limits on a tool result.
909///
910/// When the tool opts into [`truncate_on_overflow`](Tool::truncate_on_overflow),
911/// oversized output is truncated with a `\n[truncated]` suffix. Otherwise the
912/// result is replaced with a failure so the agent can retry with narrower args.
913/// A failure is always truncated, keeping its start (such as an exit code).
914fn enforce_output_limits(
915 tool_call_id: &str,
916 mut result: ToolResult,
917 max_bytes: usize,
918 max_lines: usize,
919 truncate: bool,
920) -> ToolResult {
921 let output = match &result {
922 ToolResult::Success { output, .. } => output,
923 ToolResult::Failure { .. } => {
924 result.truncate_output(max_bytes, max_lines);
925 return result;
926 }
927 ToolResult::Interrupt { .. } => return result,
928 };
929 let bytes = output.len();
930 let lines = output.lines().count();
931 let over_bytes = bytes > max_bytes;
932 let over_lines = lines > max_lines;
933
934 if !over_bytes && !over_lines {
935 return result;
936 }
937
938 if truncate {
939 result.truncate_output(max_bytes, max_lines);
940 return result;
941 }
942
943 if over_bytes {
944 ToolResult::failure(
945 tool_call_id,
946 format!(
947 "Output too large ({bytes} bytes, limit {max_bytes}). \
948 Use more specific arguments to reduce output size."
949 ),
950 )
951 } else {
952 ToolResult::failure(
953 tool_call_id,
954 format!(
955 "Output too many lines ({lines}, limit {max_lines}). \
956 Use more specific arguments to reduce output size."
957 ),
958 )
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
967 fn failed_output_is_truncated_not_passed_through() {
968 let output = "x\n".repeat(100);
969 for truncate in [false, true] {
970 let result = enforce_output_limits(
971 "call",
972 ToolResult::failure("call", output.clone()),
973 50,
974 usize::MAX,
975 truncate,
976 );
977 let ToolResult::Failure { output, .. } = result else {
978 panic!("a failure stays a failure");
979 };
980 assert!(output.len() < 70, "{output}");
981 assert!(output.ends_with("[truncated]"), "{output}");
982 }
983 }
984
985 struct DummyTool(&'static str);
986
987 #[async_trait]
988 impl Tool for DummyTool {
989 fn name(&self) -> &str {
990 self.0
991 }
992 fn description(&self) -> &str {
993 "dummy"
994 }
995 fn parameters(&self) -> Value {
996 serde_json::json!({})
997 }
998 async fn execute(&self, ctx: ToolContext<'_>) -> anyhow::Result<ToolResult> {
999 Ok(ToolResult::success(ctx.tool_call_id, "ok"))
1000 }
1001 }
1002
1003 #[test]
1004 fn test_registry_empty() {
1005 let registry = ToolRegistry::new();
1006 assert_eq!(registry.list().len(), 0);
1007 }
1008
1009 #[test]
1010 fn test_registry_definitions_empty() {
1011 let registry = ToolRegistry::new();
1012 assert!(registry.definitions().is_empty());
1013 }
1014
1015 #[test]
1016 fn test_registry_lookup() {
1017 let mut registry = ToolRegistry::new();
1018 registry.register(Arc::new(DummyTool("exec")));
1019 registry.register(Arc::new(DummyTool("slack_react")));
1020 registry.register(Arc::new(DummyTool("browser")));
1021
1022 assert_eq!(registry.definitions().len(), 3);
1023
1024 // get() resolves tools by name
1025 assert!(registry.get("slack_react").is_some());
1026 }
1027
1028 #[test]
1029 fn test_registry_alias_contains_and_remove_cleanup() {
1030 let mut registry = ToolRegistry::new();
1031 registry.register(Arc::new(DummyTool("exec")));
1032 registry.alias("bash_exec", "exec");
1033
1034 assert!(registry.contains("exec"));
1035 assert!(registry.contains("bash_exec"));
1036 assert!(registry.get("bash_exec").is_some());
1037
1038 assert!(registry.remove("exec"));
1039 assert!(!registry.contains("exec"));
1040 assert!(!registry.contains("bash_exec"));
1041 assert!(registry.get("bash_exec").is_none());
1042 }
1043
1044 /// Tool that sleeps forever (until cancelled). Declares a long
1045 /// timeout_secs so it takes the "timeout" branch of ToolRegistry::execute.
1046 struct SlowTimeoutTool;
1047
1048 #[async_trait]
1049 impl Tool for SlowTimeoutTool {
1050 fn name(&self) -> &str {
1051 "slow"
1052 }
1053 fn description(&self) -> &str {
1054 "sleeps forever"
1055 }
1056 fn parameters(&self) -> Value {
1057 serde_json::json!({})
1058 }
1059 fn timeout_secs(&self) -> Option<u64> {
1060 Some(600)
1061 }
1062 async fn execute(&self, _ctx: ToolContext<'_>) -> anyhow::Result<ToolResult> {
1063 // Sleep beyond the test budget; cancellation must short-circuit.
1064 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1065 Ok(ToolResult::success("unused", "should not return"))
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn registry_cancel_short_circuits_tool_with_timeout_secs() {
1071 // Regression: a tool that sets timeout_secs() used to bypass the
1072 // cancel-token race entirely. Cancelling now must still abort it.
1073 let mut registry = ToolRegistry::new();
1074 registry.register(Arc::new(SlowTimeoutTool));
1075
1076 let cancel = CancellationToken::new();
1077 let cancel_clone = cancel.clone();
1078
1079 let call = crate::ToolCall {
1080 id: "c1".into(),
1081 name: "slow".into(),
1082 arguments: serde_json::json!({}),
1083 };
1084
1085 let registry = Arc::new(registry);
1086 let registry2 = registry.clone();
1087 let exec = tokio::spawn(async move { registry2.execute(&call, None, &cancel).await });
1088
1089 // Let the tool start, then cancel.
1090 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1091 cancel_clone.cancel();
1092
1093 // Must return within 2s of cancellation, NOT after 600s timeout.
1094 let result = tokio::time::timeout(std::time::Duration::from_secs(2), exec)
1095 .await
1096 .expect("registry did not return within 2s of cancellation")
1097 .unwrap();
1098
1099 assert!(!result.is_success(), "expected failure on cancel");
1100 assert!(
1101 result.output().contains("cancelled"),
1102 "expected 'cancelled' in output, got: {}",
1103 result.output()
1104 );
1105 }
1106}