Skip to main content

thndrs_lib/core/tools/
registry.rs

1//! Built-in tool registry contract.
2//!
3//! The registry is a thin typed boundary around the current tool
4//! implementations. Each entry has one stable model-visible name, one schema,
5//! one executor, and one valid example input. Built-in tools own their schemas,
6//! parsing, execution, and side-effect metadata in their tool modules.
7//!
8//! Provider schemas are derived from [`ToolDefinition`] through
9//! [`provider_tool_catalog_schemas`]. Structured side effects flow through
10//! [`ToolExecution`], so the session layer consumes file-write and shell audits
11//! without knowing which tool produced them.
12
13use std::collections::HashSet;
14use std::path::Path;
15
16use thiserror::Error;
17
18use super::{ToolDefinition, ToolOutput, ToolUseRequest, WriteResult, shell};
19use crate::search::SearchConfig;
20
21const BUILTIN_TOOLS: &[ToolEntry] = &[
22    ToolEntry {
23        name: "find_files",
24        definition: super::find_files::definition,
25        execute: super::find_files::execute_request,
26        example_input: r#"{"pattern":"Cargo.toml"}"#,
27    },
28    ToolEntry {
29        name: "list_searchable_files",
30        definition: super::list_searchable_files::definition,
31        execute: super::list_searchable_files::execute_request,
32        example_input: r#"{"glob":"src/**/*.rs"}"#,
33    },
34    ToolEntry {
35        name: "search_text",
36        definition: super::search_text::definition,
37        execute: super::search_text::execute_request,
38        example_input: r#"{"pattern":"fn main","glob":"src/**/*.rs"}"#,
39    },
40    ToolEntry {
41        name: "read_file_range",
42        definition: super::read_file_range::definition,
43        execute: super::read_file_range::execute_request,
44        example_input: r#"{"path":"Cargo.toml","start_line":1,"end_line":3}"#,
45    },
46    ToolEntry {
47        name: "sawk",
48        definition: super::sawk::definition,
49        execute: super::sawk::execute_request,
50        example_input: r#"{"action":"sed_print","path":"Cargo.toml","start_line":1,"end_line":3}"#,
51    },
52    ToolEntry {
53        name: "web_search",
54        definition: super::web_search::definition,
55        execute: super::web_search::execute_request,
56        example_input: r#"{"query":"Rust serde documentation","max_results":3}"#,
57    },
58    ToolEntry {
59        name: "read_url",
60        definition: super::read_url::definition,
61        execute: super::read_url::execute_request,
62        example_input: r#"{"url":"https://example.com"}"#,
63    },
64    ToolEntry {
65        name: "create_file",
66        definition: super::create_file::definition,
67        execute: super::create_file::execute_request,
68        example_input: r#"{"path":"notes.txt","content":"hello\n"}"#,
69    },
70    ToolEntry {
71        name: "replace_range",
72        definition: super::replace_range::definition,
73        execute: super::replace_range::execute_request,
74        example_input: r#"{"path":"notes.txt","old_string":"hello","new_string":"hi"}"#,
75    },
76    ToolEntry {
77        name: "write_patch",
78        definition: super::write_patch::definition,
79        execute: super::write_patch::execute_request,
80        example_input: r#"{"patches":[{"op":"edit","path":"notes.txt","old_string":"hello","new_string":"hi"}]}"#,
81    },
82    ToolEntry {
83        name: "run_shell",
84        definition: super::shell::definition,
85        execute: super::shell::execute_request,
86        example_input: r#"{"argv":["cargo","test","tools"]}"#,
87    },
88];
89
90/// Provider-specific schema shape for model-visible tool definitions.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum ProviderSchemaFormat {
93    /// Anthropic-compatible Messages API format.
94    Anthropic,
95    /// OpenAI-compatible function-tool format.
96    #[allow(dead_code)]
97    OpenAiFunction,
98}
99
100/// Runtime context shared by tool executors.
101#[derive(Clone, Debug)]
102pub struct ToolContext<'a> {
103    /// Workspace root used for containment and relative paths.
104    pub root: &'a Path,
105    /// Application-owned web-search configuration.
106    pub search: SearchConfig,
107    /// Shared owner for background shell children, when running inside the
108    /// interactive application.
109    pub process_registry: Option<shell::ProcessRegistry>,
110}
111
112impl<'a> ToolContext<'a> {
113    /// Create a tool context for a workspace root.
114    pub fn new(root: &'a Path) -> Self {
115        Self { root, search: SearchConfig::default(), process_registry: None }
116    }
117
118    /// Create a tool context with the active application-owned search backend.
119    pub fn with_search(root: &'a Path, search: &SearchConfig) -> Self {
120        Self { root, search: search.clone(), process_registry: None }
121    }
122
123    /// Attach an application-owned process registry to this context.
124    pub fn with_process_registry(mut self, process_registry: shell::ProcessRegistry) -> Self {
125        self.process_registry = Some(process_registry);
126        self
127    }
128}
129
130/// Evidence metadata, projections, and side-effect audits from a tool execution.
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ToolExecution {
133    /// Tool evidence metadata plus independent display/model projections.
134    pub output: ToolOutput,
135    /// Optional file-write audit metadata for session persistence.
136    pub write_result: Option<WriteResult>,
137    /// Optional shell process audit metadata for session persistence.
138    pub shell_result: Option<shell::ProcessResult>,
139}
140
141impl ToolExecution {
142    /// Create a tool execution with only display/model output.
143    pub fn output(output: ToolOutput) -> Self {
144        Self { output, write_result: None, shell_result: None }
145    }
146
147    /// Create a tool execution with all structured side effects.
148    pub fn full(
149        output: ToolOutput, write_result: Option<WriteResult>, shell_result: Option<shell::ProcessResult>,
150    ) -> Self {
151        Self { output, write_result, shell_result }
152    }
153}
154
155/// Registry-level errors for lookup, validation, and argument parsing.
156#[derive(Clone, Debug, Error, Eq, PartialEq)]
157pub enum ToolError {
158    /// The requested tool is not registered.
159    #[error("unknown tool: {0}")]
160    UnknownTool(String),
161    /// A registry invariant failed.
162    #[error("invalid tool registry: {0}")]
163    InvalidRegistry(String),
164    /// The provider supplied malformed or invalid arguments.
165    #[error("invalid tool arguments: {0}")]
166    InvalidArguments(String),
167}
168
169/// One executable registry entry.
170#[derive(Clone, Copy)]
171pub struct ToolEntry {
172    /// Stable provider/model-visible tool name.
173    pub name: &'static str,
174    /// Provider-visible tool definition.
175    pub definition: fn() -> ToolDefinition,
176    /// Execute the tool and return output plus structured side effects.
177    pub execute: fn(&ToolUseRequest, &ToolContext<'_>) -> ToolExecution,
178    /// Stable valid JSON input used by registry tests and future docs.
179    pub example_input: &'static str,
180}
181
182/// Return the static built-in tool registry.
183pub fn builtins() -> &'static [ToolEntry] {
184    BUILTIN_TOOLS
185}
186
187/// Look up a built-in tool by stable name.
188pub fn get(name: &str) -> Option<&'static ToolEntry> {
189    builtins().iter().find(|entry| entry.name == name)
190}
191
192/// Validate registry invariants that must hold before provider catalog use.
193pub fn validate() -> Result<(), ToolError> {
194    let mut names = HashSet::new();
195    for entry in builtins() {
196        if !names.insert(entry.name) {
197            return Err(ToolError::InvalidRegistry(format!(
198                "duplicate tool name: {}",
199                entry.name
200            )));
201        }
202
203        let definition = (entry.definition)();
204        if definition.name != entry.name {
205            return Err(ToolError::InvalidRegistry(format!(
206                "entry `{}` returned definition `{}`",
207                entry.name, definition.name
208            )));
209        }
210
211        if !definition.input_schema.is_object() {
212            return Err(ToolError::InvalidRegistry(format!(
213                "entry `{}` schema is not a JSON object",
214                entry.name
215            )));
216        }
217
218        let example = serde_json::from_str::<serde_json::Value>(entry.example_input).map_err(|err| {
219            ToolError::InvalidArguments(format!("{} example input is invalid JSON: {err}", entry.name))
220        })?;
221        if !example.is_object() {
222            return Err(ToolError::InvalidArguments(format!(
223                "{} example input is not a JSON object",
224                entry.name
225            )));
226        }
227    }
228
229    Ok(())
230}
231
232/// Return the provider-visible tool catalog from the registry.
233pub fn tool_definitions() -> Vec<ToolDefinition> {
234    validate().expect("built-in tool registry should be valid");
235    builtins().iter().map(|entry| (entry.definition)()).collect()
236}
237
238/// Execute a registered tool, or return a stable failed output for unknown names.
239pub fn execute(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
240    match get(&request.name) {
241        Some(entry) => (entry.execute)(request, ctx),
242        None => ToolExecution::output(ToolOutput::failed(
243            &request.name,
244            ToolError::UnknownTool(request.name.clone()).to_string(),
245        )),
246    }
247}
248
249/// Convert the tool catalog into a provider-specific JSON schema array.
250pub fn provider_tool_catalog_schemas(defs: &[ToolDefinition], format: ProviderSchemaFormat) -> serde_json::Value {
251    match format {
252        ProviderSchemaFormat::Anthropic => serde_json::Value::Array(
253            defs.iter()
254                .map(|tool| {
255                    serde_json::json!({
256                        "name": tool.name,
257                        "description": tool.description,
258                        "input_schema": tool.input_schema,
259                    })
260                })
261                .collect(),
262        ),
263        ProviderSchemaFormat::OpenAiFunction => serde_json::Value::Array(
264            defs.iter()
265                .map(|tool| {
266                    serde_json::json!({
267                        "type": "function",
268                        "function": {
269                            "name": tool.name,
270                            "description": tool.description,
271                            "parameters": tool.input_schema,
272                        }
273                    })
274                })
275                .collect(),
276        ),
277    }
278}