Skip to main content

talos_core/
tool.rs

1//! Agent tool abstraction layer.
2//!
3//! This module defines the [`AgentTool`] trait for implementing pluggable tools,
4//! a [`ToolRegistry`] for dynamic tool registration and lookup, and associated
5//! types for tool execution results and errors.
6
7use std::collections::HashMap;
8use std::collections::HashSet;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use serde_json::Value;
13use thiserror::Error;
14
15/// Errors that can occur during tool registration, lookup, or execution.
16#[derive(Debug, Error)]
17pub enum ToolError {
18    /// The requested tool is not registered in the registry.
19    #[error("tool not found: {0}")]
20    ToolNotFound(String),
21
22    /// The input provided to a tool does not match its expected parameters.
23    #[error("invalid input for tool: {0}")]
24    InvalidInput(String),
25
26    /// An error occurred during tool execution.
27    #[error("tool execution error: {0}")]
28    ExecutionError(String),
29}
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34/// Provenance of a registered tool.
35#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum ToolProvenance {
38    /// A native tool registered within the main process.
39    #[default]
40    Native,
41    /// A tool provided by a remote MCP server.
42    McpRemote { server: String },
43}
44
45/// The result of executing a tool.
46#[derive(Debug, Clone)]
47pub struct ToolResult {
48    /// The output content produced by the tool.
49    pub content: String,
50    /// Whether the execution resulted in an error.
51    pub is_error: bool,
52}
53
54impl ToolResult {
55    /// Creates a successful tool result with the given content.
56    pub fn success(content: impl Into<String>) -> Self {
57        Self {
58            content: content.into(),
59            is_error: false,
60        }
61    }
62
63    /// Creates an error tool result with the given error message.
64    pub fn error(content: impl Into<String>) -> Self {
65        Self {
66            content: content.into(),
67            is_error: true,
68        }
69    }
70}
71
72/// Categorizes a tool by its operational nature for permission decisions.
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74pub enum ToolNature {
75    /// Read-only: inspects files/code without side effects.
76    #[default]
77    Read,
78    /// Writes or modifies files.
79    Write,
80    /// Executes external processes or commands.
81    Execute,
82    /// Makes network requests (HTTP, API calls).
83    Network,
84}
85
86/// Stable presentation family for a tool.
87///
88/// Families are model-presentation metadata, not execution registration. The
89/// registry remains the source of executable tools; presentation policy decides
90/// which registered tools are shown to the provider for a turn/session.
91#[derive(
92    Debug,
93    Clone,
94    Copy,
95    Default,
96    PartialEq,
97    Eq,
98    Hash,
99    PartialOrd,
100    Ord,
101    Serialize,
102    Deserialize,
103    JsonSchema,
104)]
105#[serde(rename_all = "snake_case")]
106pub enum ToolFamily {
107    /// File and directory operations.
108    #[default]
109    File,
110    /// Text search and file inspection operations.
111    Search,
112    /// AST/code-structure tools.
113    CodeIntelligence,
114    /// Git repository tools.
115    Git,
116    /// Network, web, and URL tools.
117    Network,
118    /// Shell or command execution tools.
119    Shell,
120    /// Tools supplied by extensions, MCP, or unknown sources.
121    Extension,
122}
123
124/// Policy for selecting model-visible tool families.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126pub struct ToolPresentationPolicy {
127    /// If true, every registered tool is presented.
128    pub include_all: bool,
129    /// If true, the always-on baseline is presented even when not in `families`.
130    pub include_always_on: bool,
131    /// Additional families to present.
132    #[serde(default)]
133    pub families: Vec<ToolFamily>,
134}
135
136impl ToolPresentationPolicy {
137    /// Presents every registered tool. This preserves pre-TOOL-012 behavior.
138    #[must_use]
139    pub fn full() -> Self {
140        Self {
141            include_all: true,
142            include_always_on: true,
143            families: Vec::new(),
144        }
145    }
146
147    /// Presents the always-on baseline only.
148    #[must_use]
149    pub fn always_on() -> Self {
150        Self {
151            include_all: false,
152            include_always_on: true,
153            families: Vec::new(),
154        }
155    }
156
157    /// Presents the always-on baseline plus specific families.
158    #[must_use]
159    pub fn with_families(families: impl IntoIterator<Item = ToolFamily>) -> Self {
160        Self {
161            include_all: false,
162            include_always_on: true,
163            families: families.into_iter().collect(),
164        }
165    }
166
167    /// Returns true when this policy presents the given tool.
168    #[must_use]
169    pub fn allows_tool(&self, tool: &dyn AgentTool) -> bool {
170        self.include_all
171            || (self.include_always_on && tool.is_always_on())
172            || self.families.contains(&tool.family())
173    }
174
175    /// Returns the family set explicitly enabled by this policy.
176    #[must_use]
177    pub fn family_set(&self) -> HashSet<ToolFamily> {
178        self.families.iter().copied().collect()
179    }
180}
181
182impl Default for ToolPresentationPolicy {
183    fn default() -> Self {
184        Self::full()
185    }
186}
187
188/// Identifies how a permission resource string should be interpreted.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(rename_all = "lowercase")]
191pub enum ToolResourceKind {
192    /// File or directory path resource.
193    Path,
194    /// URL host or domain resource.
195    Domain,
196    /// External command or executable resource.
197    Command,
198    /// Named remote resource, such as a Git remote.
199    Remote,
200}
201
202/// One permission facet touched by a tool invocation.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204pub struct ToolPermissionFacet {
205    /// Risk nature for this facet.
206    pub nature: ToolNature,
207    /// Optional concrete resource touched by this facet.
208    #[serde(default)]
209    pub resource: Option<String>,
210    /// Optional interpretation hint for [`resource`](Self::resource).
211    #[serde(default)]
212    pub resource_kind: Option<ToolResourceKind>,
213    /// Optional human-readable detail used in approval or diagnostics.
214    #[serde(default)]
215    pub description: Option<String>,
216}
217
218impl ToolPermissionFacet {
219    /// Creates a facet with no concrete resource.
220    pub fn new(nature: ToolNature) -> Self {
221        Self {
222            nature,
223            resource: None,
224            resource_kind: None,
225            description: None,
226        }
227    }
228
229    /// Creates a facet with a concrete resource.
230    pub fn with_resource(
231        nature: ToolNature,
232        resource: impl Into<String>,
233        resource_kind: ToolResourceKind,
234    ) -> Self {
235        Self {
236            nature,
237            resource: Some(resource.into()),
238            resource_kind: Some(resource_kind),
239            description: None,
240        }
241    }
242
243    /// Adds display-oriented detail to this facet.
244    pub fn with_description(mut self, description: impl Into<String>) -> Self {
245        self.description = Some(description.into());
246        self
247    }
248}
249
250/// A pluggable agent tool that can be registered and invoked dynamically.
251///
252/// Implementors must provide a name, description, parameter schema, and
253/// execution logic. The trait is object-safe and can be used as
254/// `dyn AgentTool` behind an `Arc`.
255#[async_trait]
256pub trait AgentTool: Send + Sync {
257    /// Returns the unique name of this tool.
258    fn name(&self) -> &str;
259
260    /// Returns a human-readable description of what this tool does.
261    fn description(&self) -> &str;
262
263    /// Returns the JSON Schema describing the expected input parameters.
264    ///
265    /// The default implementation uses `schemars` to generate a schema from
266    /// the associated `Parameters` type. Override this method to provide a
267    /// custom schema.
268    fn parameters(&self) -> Value;
269
270    /// Executes the tool with the given input and returns a result.
271    ///
272    /// The `input` is expected to conform to the schema returned by
273    /// [`parameters`](Self::parameters).
274    async fn execute(&self, input: Value) -> ToolResult;
275
276    /// Returns whether this tool is read-only (does not modify external state).
277    ///
278    /// The default implementation returns `false`. Override for tools that
279    /// only read data (e.g., file readers, web fetchers).
280    fn is_read_only(&self) -> bool {
281        false
282    }
283
284    fn nature(&self) -> ToolNature {
285        if self.is_read_only() {
286            ToolNature::Read
287        } else {
288            ToolNature::Write
289        }
290    }
291
292    /// Returns the stable presentation family for this tool.
293    fn family(&self) -> ToolFamily {
294        ToolFamily::Extension
295    }
296
297    /// Returns whether this tool belongs to the always-on presentation set.
298    fn is_always_on(&self) -> bool {
299        false
300    }
301
302    /// Returns the permission facets touched by this concrete invocation.
303    ///
304    /// Tools that only touch one risk surface can rely on the default
305    /// single-facet profile derived from [`nature`](Self::nature). Hybrid tools
306    /// should override this to expose every relevant risk surface.
307    fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
308        vec![ToolPermissionFacet::new(self.nature())]
309    }
310
311    fn summary_fields(&self) -> &'static [&'static str] {
312        &[]
313    }
314
315    /// Returns the provenance of this tool.
316    ///
317    /// The default implementation returns [`ToolProvenance::Native`].
318    /// Override for tools that live in another process or behind a
319    /// network boundary (e.g., MCP remote tools) so consumers can
320    /// render an origin marker in the UI.
321    fn provenance(&self) -> ToolProvenance {
322        ToolProvenance::Native
323    }
324}
325
326/// A registry for dynamically managing agent tools.
327///
328/// Tools are registered under their [`AgentTool::name`] and can be retrieved,
329/// listed, or have their inputs validated against their parameter schemas.
330#[derive(Default)]
331pub struct ToolRegistry {
332    tools: HashMap<String, Arc<dyn AgentTool>>,
333}
334
335impl ToolRegistry {
336    /// Creates a new empty tool registry.
337    pub fn new() -> Self {
338        Self::default()
339    }
340
341    /// Registers a tool in the registry, replacing any existing tool with the
342    /// same name.
343    pub fn register(&mut self, tool: Arc<dyn AgentTool>) {
344        self.tools.insert(tool.name().to_owned(), tool);
345    }
346
347    /// Retrieves a tool by name, or `None` if not registered.
348    pub fn get(&self, name: &str) -> Option<&dyn AgentTool> {
349        self.tools.get(name).map(|t| t.as_ref())
350    }
351
352    /// Returns a list of all registered tools.
353    pub fn list(&self) -> Vec<&dyn AgentTool> {
354        self.tools.values().map(|t| t.as_ref()).collect()
355    }
356
357    /// Validates that the given input conforms to the tool's parameter schema.
358    ///
359    /// Returns `Ok(())` if the tool exists and the input is an object, or
360    /// `Err(ToolError)` if the tool is not found or the input is invalid.
361    ///
362    /// This performs a basic structural check (input must be a JSON object).
363    /// Full JSON Schema validation can be added later via the `jsonschema` crate.
364    pub fn validate_input(&self, name: &str, input: &Value) -> Result<(), ToolError> {
365        let tool = self
366            .get(name)
367            .ok_or_else(|| ToolError::ToolNotFound(name.to_owned()))?;
368
369        let params = tool.parameters();
370
371        // Basic validation: input must be an object
372        if !input.is_object() {
373            return Err(ToolError::InvalidInput(format!(
374                "expected object for tool '{name}', got {}",
375                input_type_name(input)
376            )));
377        }
378
379        // Check required fields if the schema specifies them
380        if let Some(schema_obj) = params.as_object()
381            && let Some(Value::Array(required)) = schema_obj.get("required")
382            && let Some(input_obj) = input.as_object()
383        {
384            for req in required {
385                if let Some(req_key) = req.as_str()
386                    && !input_obj.contains_key(req_key)
387                {
388                    return Err(ToolError::InvalidInput(format!(
389                        "missing required field '{req_key}' for tool '{name}'"
390                    )));
391                }
392            }
393        }
394
395        Ok(())
396    }
397}
398
399/// Returns a human-readable type name for a JSON value.
400fn input_type_name(value: &Value) -> &'static str {
401    match value {
402        Value::Null => "null",
403        Value::Bool(_) => "boolean",
404        Value::Number(_) => "number",
405        Value::String(_) => "string",
406        Value::Array(_) => "array",
407        Value::Object(_) => "object",
408    }
409}
410
411/// Helper macro to generate a JSON Schema value from a type that implements
412/// `schemars::JsonSchema`.
413#[macro_export]
414macro_rules! tool_parameters {
415    ($type:ty) => {{
416        let schema = schemars::schema_for!($type);
417        serde_json::to_value(schema).unwrap_or(serde_json::Value::Object(Default::default()))
418    }};
419}
420
421#[cfg(test)]
422#[allow(warnings)]
423#[allow(warnings)]
424#[allow(warnings)]
425#[allow(warnings)]
426mod tests {
427    use super::*;
428    use schemars::JsonSchema;
429    use serde::Deserialize;
430
431    /// Mock tool for testing.
432    struct MockTool {
433        tool_name: String,
434        tool_description: String,
435        read_only: bool,
436        family: ToolFamily,
437        always_on: bool,
438    }
439
440    impl MockTool {
441        fn new(name: &str, description: &str) -> Self {
442            Self {
443                tool_name: name.to_owned(),
444                tool_description: description.to_owned(),
445                read_only: true,
446                family: ToolFamily::Extension,
447                always_on: false,
448            }
449        }
450
451        fn with_family(mut self, family: ToolFamily) -> Self {
452            self.family = family;
453            self
454        }
455
456        fn always_on(mut self) -> Self {
457            self.always_on = true;
458            self
459        }
460    }
461
462    #[async_trait]
463    impl AgentTool for MockTool {
464        fn name(&self) -> &str {
465            &self.tool_name
466        }
467
468        fn description(&self) -> &str {
469            &self.tool_description
470        }
471
472        fn parameters(&self) -> Value {
473            serde_json::json!({
474                "type": "object",
475                "properties": {
476                    "message": {
477                        "type": "string",
478                        "description": "A message to echo"
479                    }
480                },
481                "required": ["message"]
482            })
483        }
484
485        async fn execute(&self, input: Value) -> ToolResult {
486            if let Some(msg) = input.get("message").and_then(Value::as_str) {
487                ToolResult::success(format!("echo: {msg}"))
488            } else {
489                ToolResult::error("missing 'message' field".to_owned())
490            }
491        }
492
493        fn is_read_only(&self) -> bool {
494            self.read_only
495        }
496
497        fn family(&self) -> ToolFamily {
498            self.family
499        }
500
501        fn is_always_on(&self) -> bool {
502            self.always_on
503        }
504    }
505
506    /// Mock tool with typed parameters for schema generation testing.
507    #[derive(JsonSchema, Deserialize)]
508    #[allow(dead_code)]
509    struct GreetParams {
510        /// The name to greet.
511        name: String,
512        /// Whether to use formal greeting.
513        #[serde(default)]
514        formal: bool,
515    }
516
517    #[allow(dead_code)]
518    struct TypedMockTool;
519
520    #[async_trait]
521    impl AgentTool for TypedMockTool {
522        fn name(&self) -> &str {
523            "greet"
524        }
525
526        fn description(&self) -> &str {
527            "Greet someone by name"
528        }
529
530        fn parameters(&self) -> Value {
531            tool_parameters!(GreetParams)
532        }
533
534        async fn execute(&self, input: Value) -> ToolResult {
535            let name = input.get("name").and_then(Value::as_str).unwrap_or("World");
536            ToolResult::success(format!("Hello, {name}!"))
537        }
538    }
539
540    #[test]
541    fn test_register_and_get_tool() {
542        let mut registry = ToolRegistry::new();
543        let tool = Arc::new(MockTool::new("echo", "Echoes a message"));
544        registry.register(tool);
545
546        let retrieved = registry.get("echo");
547        assert!(retrieved.is_some());
548        assert_eq!(retrieved.unwrap().name(), "echo");
549    }
550
551    #[test]
552    fn test_tool_not_found() {
553        let registry = ToolRegistry::new();
554        assert!(registry.get("nonexistent").is_none());
555
556        let result = registry.validate_input("nonexistent", &serde_json::json!({}));
557        assert!(matches!(result, Err(ToolError::ToolNotFound(_))));
558    }
559
560    #[test]
561    fn test_list_tools() {
562        let mut registry = ToolRegistry::new();
563        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
564        registry.register(Arc::new(MockTool::new("reverse", "Reverses a string")));
565
566        let tools = registry.list();
567        assert_eq!(tools.len(), 2);
568    }
569
570    #[test]
571    fn test_validate_input_valid() {
572        let mut registry = ToolRegistry::new();
573        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
574
575        let input = serde_json::json!({ "message": "hello" });
576        assert!(registry.validate_input("echo", &input).is_ok());
577    }
578
579    #[test]
580    fn test_validate_input_missing_required() {
581        let mut registry = ToolRegistry::new();
582        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
583
584        let input = serde_json::json!({});
585        let result = registry.validate_input("echo", &input);
586        assert!(matches!(result, Err(ToolError::InvalidInput(_))));
587        assert!(
588            result
589                .unwrap_err()
590                .to_string()
591                .contains("missing required field 'message'")
592        );
593    }
594
595    #[test]
596    fn test_validate_input_not_object() {
597        let mut registry = ToolRegistry::new();
598        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
599
600        let input = serde_json::json!("not an object");
601        let result = registry.validate_input("echo", &input);
602        assert!(matches!(result, Err(ToolError::InvalidInput(_))));
603    }
604
605    #[tokio::test]
606    async fn test_tool_execute() {
607        let mut registry = ToolRegistry::new();
608        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
609
610        let tool = registry.get("echo").unwrap();
611        let result = tool
612            .execute(serde_json::json!({ "message": "hello" }))
613            .await;
614        assert!(!result.is_error);
615        assert_eq!(result.content, "echo: hello");
616    }
617
618    #[tokio::test]
619    async fn test_tool_execute_error() {
620        let mut registry = ToolRegistry::new();
621        registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
622
623        let tool = registry.get("echo").unwrap();
624        let result = tool.execute(serde_json::json!({})).await;
625        assert!(result.is_error);
626    }
627
628    #[test]
629    fn test_tool_is_read_only() {
630        let tool = MockTool::new("echo", "Echoes a message");
631        assert!(tool.is_read_only());
632    }
633
634    #[test]
635    fn test_tool_parameters_macro() {
636        let schema = tool_parameters!(GreetParams);
637        assert!(schema.is_object());
638        let obj = schema.as_object().unwrap();
639        assert!(obj.contains_key("properties"));
640    }
641
642    #[test]
643    fn test_register_replaces_existing() {
644        let mut registry = ToolRegistry::new();
645        registry.register(Arc::new(MockTool::new("echo", "Original")));
646        registry.register(Arc::new(MockTool::new("echo", "Replacement")));
647
648        let tool = registry.get("echo").unwrap();
649        assert_eq!(tool.description(), "Replacement");
650    }
651
652    #[test]
653    fn test_tool_result_helpers() {
654        let success = ToolResult::success("ok");
655        assert!(!success.is_error);
656        assert_eq!(success.content, "ok");
657
658        let error = ToolResult::error("failed");
659        assert!(error.is_error);
660        assert_eq!(error.content, "failed");
661    }
662
663    #[test]
664    fn test_tool_presentation_policy_selects_always_on_baseline() {
665        let baseline = MockTool::new("read", "Read file").always_on();
666        let shell = MockTool::new("bash", "Run command").with_family(ToolFamily::Shell);
667
668        let policy = ToolPresentationPolicy::always_on();
669
670        assert!(policy.allows_tool(&baseline));
671        assert!(!policy.allows_tool(&shell));
672    }
673
674    #[test]
675    fn test_tool_presentation_policy_selects_explicit_family() {
676        let git = MockTool::new("git_status", "Git status").with_family(ToolFamily::Git);
677        let network = MockTool::new("web_search", "Search web").with_family(ToolFamily::Network);
678
679        let policy = ToolPresentationPolicy::with_families([ToolFamily::Git]);
680
681        assert!(policy.allows_tool(&git));
682        assert!(!policy.allows_tool(&network));
683        assert!(policy.family_set().contains(&ToolFamily::Git));
684    }
685}
686
687#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
688#[serde(rename_all = "kebab-case")]
689pub enum ToolProtocol {
690    #[default]
691    Native,
692    TalosStrict,
693    Compat,
694}
695
696impl ToolProtocol {
697    pub fn parse(s: &str) -> Option<Self> {
698        match s {
699            "native" => Some(ToolProtocol::Native),
700            "talos-strict" | "talos_xml_json_strict" => Some(ToolProtocol::TalosStrict),
701            "compat" | "compatibility" => Some(ToolProtocol::Compat),
702            _ => None,
703        }
704    }
705}
706
707#[derive(Debug, Clone, Default)]
708pub struct ToolProtocolConfig {
709    pub protocol: ToolProtocol,
710    pub strict_prompt: bool,
711    pub stream_filter: bool,
712    pub schema_validate: bool,
713}
714
715impl ToolProtocolConfig {
716    pub fn for_protocol(protocol: ToolProtocol) -> Self {
717        match protocol {
718            ToolProtocol::Native => ToolProtocolConfig {
719                protocol,
720                strict_prompt: false,
721                stream_filter: false,
722                schema_validate: false,
723            },
724            ToolProtocol::TalosStrict => ToolProtocolConfig {
725                protocol,
726                strict_prompt: true,
727                stream_filter: true,
728                schema_validate: true,
729            },
730            ToolProtocol::Compat => ToolProtocolConfig {
731                protocol,
732                strict_prompt: false,
733                stream_filter: true,
734                schema_validate: false,
735            },
736        }
737    }
738}