Skip to main content

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}