Skip to main content

vtcode_llm/provider/
tool.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value, json};
3
4// Re-export from vtcode-config as the canonical definition
5pub use vtcode_config::ToolSearchAlgorithm;
6
7/// A namespace grouping for deferred tools. When a tool has a namespace and
8/// `defer_loading` is true, only the namespace metadata (name + description)
9/// is sent upfront. The full tool definitions are loaded when the model
10/// searches for a tool in that namespace.
11///
12/// This is the infrastructure for the article's description: "when deferred
13/// functions are grouped into a namespace, only the namespace's name and
14/// description" are sent to the model.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ToolNamespace {
17    /// The namespace name (e.g., "file_operations", "code_search")
18    pub name: String,
19    /// A short description of what tools in this namespace do
20    pub description: String,
21}
22
23/// Universal tool definition that matches OpenAI/Anthropic/Gemini specifications
24/// Based on official API documentation from Context7
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ToolDefinition {
27    /// The type of tool: "function", "apply_patch" (GPT-5.1), "shell" (GPT-5.1), or "custom" (GPT-5 freeform)
28    /// Also supports provider-native and hosted tool types like:
29    /// - "tool_search" (OpenAI hosted tool search)
30    /// - "file_search" and "mcp" (OpenAI Responses hosted tools)
31    /// - Anthropic tool search revisions:
32    /// - "tool_search_tool_regex_20251119", "tool_search_tool_bm25_20251119"
33    /// - "web_search_20260209" (and other web_search_* revisions)
34    /// - "code_execution_20250825" (and other code_execution_* revisions)
35    /// - "memory_20250818" (and other memory_* revisions)
36    #[serde(rename = "type")]
37    pub tool_type: String,
38
39    /// Function definition containing name, description, and parameters
40    /// Used for "function", "apply_patch", and "custom" types
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub function: Option<FunctionDefinition>,
43
44    /// Restricts which Anthropic callers can invoke this tool programmatically.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub(crate) allowed_callers: Option<Vec<String>>,
47
48    /// Anthropic tool use examples used to teach complex tool behavior.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub(crate) input_examples: Option<Vec<Value>>,
51
52    /// Provider-native web search configuration payload (e.g. Z.AI `web_search` tool).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub web_search: Option<Value>,
55
56    /// Provider-hosted Responses tool configuration for tool types like
57    /// `file_search` and `mcp`.
58    #[serde(skip, default)]
59    pub(crate) hosted_tool_config: Option<Value>,
60
61    /// Shell tool configuration (GPT-5.1 specific)
62    /// Describes shell command capabilities and constraints
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub(crate) shell: Option<ShellToolDefinition>,
65
66    /// Grammar definition for context-free grammar constraints (GPT-5 specific)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub(crate) grammar: Option<GrammarDefinition>,
69
70    /// When true and using Anthropic, mark the tool as strict for structured tool use validation
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub(crate) strict: Option<bool>,
73
74    /// When true, the tool is deferred and only loaded when discovered via tool search (Anthropic advanced-tool-use beta)
75    /// This enables dynamic tool discovery for large tool catalogs (10k+ tools)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub defer_loading: Option<bool>,
78
79    /// Optional namespace grouping for deferred tools. When present and
80    /// `defer_loading` is true, only this namespace metadata is sent upfront
81    /// instead of the full tool definition.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub namespace: Option<ToolNamespace>,
84
85    /// Anthropic server-side advisor tool configuration. Carries the advisor's
86    /// options (model, max_uses, max_tokens, caching) so that inbound proxy
87    /// requests can round-trip through `ToolDefinition` without losing data.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub(crate) advisor: Option<Value>,
90}
91
92/// Shell tool definition for GPT-5.1 shell tool type
93/// Allows controlled command-line interface interactions
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ShellToolDefinition {
96    /// Description of shell tool capabilities
97    description: String,
98
99    /// List of allowed commands (whitelist for safety)
100    allowed_commands: Vec<String>,
101
102    /// List of forbidden commands (blacklist for safety)
103    forbidden_patterns: Vec<String>,
104
105    /// Maximum command timeout in seconds
106    timeout_seconds: u32,
107}
108
109/// Grammar definition for GPT-5 context-free grammar (CFG) constraints
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct GrammarDefinition {
112    /// The syntax of the grammar: "lark" or "regex"
113    pub(crate) syntax: String,
114
115    /// The grammar definition in the specified syntax
116    pub(crate) definition: String,
117}
118
119impl Default for GrammarDefinition {
120    fn default() -> Self {
121        Self { syntax: "lark".into(), definition: String::new() }
122    }
123}
124
125impl Default for ShellToolDefinition {
126    fn default() -> Self {
127        Self {
128            description: "Execute shell commands in the workspace".into(),
129            allowed_commands: vec![
130                "ls".into(),
131                "find".into(),
132                "grep".into(),
133                "cargo".into(),
134                "git".into(),
135                "python".into(),
136                "node".into(),
137            ],
138            forbidden_patterns: vec!["rm -rf".into(), "sudo".into(), "passwd".into()],
139            timeout_seconds: 30,
140        }
141    }
142}
143
144/// Function definition within a tool
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct FunctionDefinition {
147    /// The name of the function to be called
148    pub name: String,
149
150    /// A description of what the function does
151    pub description: String,
152
153    /// The parameters the function accepts, described as a JSON Schema object
154    pub parameters: Value,
155}
156
157pub(crate) fn sanitize_tool_description(description: &str) -> String {
158    let mut result = String::with_capacity(description.len());
159    let mut first = true;
160    for line in description.lines() {
161        if !first {
162            result.push('\n');
163        }
164        result.push_str(line.trim_end());
165        first = false;
166    }
167    result.trim().to_owned()
168}
169
170impl ToolDefinition {
171    fn empty(tool_type: impl Into<String>) -> Self {
172        Self {
173            tool_type: tool_type.into(),
174            function: None,
175            allowed_callers: None,
176            input_examples: None,
177            web_search: None,
178            hosted_tool_config: None,
179            shell: None,
180            grammar: None,
181            strict: None,
182            defer_loading: None,
183            namespace: None,
184            advisor: None,
185        }
186    }
187
188    /// Create a new tool definition with function type
189    pub fn function(name: String, description: String, parameters: Value) -> Self {
190        let sanitized_description = sanitize_tool_description(&description);
191        let mut tool = Self::empty("function");
192        tool.function = Some(FunctionDefinition {
193            name,
194            description: sanitized_description,
195            parameters,
196        });
197        tool
198    }
199
200    /// Set whether the tool should be considered strict (Anthropic structured tool use)
201    pub fn with_strict(mut self, strict: bool) -> Self {
202        self.strict = Some(strict);
203        self
204    }
205
206    /// Restrict which Anthropic callers can invoke this tool programmatically.
207    pub(crate) fn with_allowed_callers(mut self, allowed_callers: Vec<String>) -> Self {
208        self.allowed_callers = Some(allowed_callers);
209        self
210    }
211
212    /// Attach Anthropic tool use examples for better tool selection and argument shaping.
213    pub(crate) fn with_input_examples(mut self, input_examples: Vec<Value>) -> Self {
214        self.input_examples = Some(input_examples);
215        self
216    }
217
218    /// Set whether the tool should be deferred (Anthropic tool search)
219    pub fn with_defer_loading(mut self, defer: bool) -> Self {
220        self.defer_loading = Some(defer);
221        self
222    }
223
224    /// Assign this tool to a namespace for grouped deferred loading.
225    /// When both `defer_loading` and `namespace` are set, only the namespace
226    /// metadata is sent upfront; the full definition is loaded on search.
227    pub fn with_namespace(mut self, namespace: ToolNamespace) -> Self {
228        self.namespace = Some(namespace);
229        self
230    }
231
232    /// Create a tool search tool definition for Anthropic's advanced-tool-use beta
233    /// Supports regex and bm25 search algorithms
234    pub fn tool_search(algorithm: ToolSearchAlgorithm) -> Self {
235        let (tool_type, name) = match algorithm {
236            ToolSearchAlgorithm::Regex => ("tool_search_tool_regex_20251119", "tool_search_tool_regex"),
237            ToolSearchAlgorithm::Bm25 => ("tool_search_tool_bm25_20251119", "tool_search_tool_bm25"),
238            // Unknown algorithms default to regex
239            ToolSearchAlgorithm::Unknown => ("tool_search_tool_regex_20251119", "tool_search_tool_regex"),
240        };
241
242        let mut tool = Self::empty(tool_type);
243        tool.function = Some(FunctionDefinition {
244            name: name.to_owned(),
245            description: "Search for tools by name, description, or parameters".to_owned(),
246            parameters: json!({
247                "type": "object",
248                "properties": {
249                    "query": {
250                        "type": "string",
251                        "description": "Search query (regex pattern for regex variant, natural language for bm25)"
252                    }
253                },
254                "required": ["query"]
255            }),
256        });
257        tool
258    }
259
260    /// Create an OpenAI hosted tool search definition.
261    pub fn hosted_tool_search() -> Self {
262        Self::empty("tool_search")
263    }
264
265    /// Create an Anthropic native memory tool definition.
266    pub fn anthropic_memory() -> Self {
267        Self::empty("memory_20250818")
268    }
269
270    /// Create a new apply_patch tool definition (GPT-5.1 specific)
271    /// The apply_patch tool lets models create, update, and delete files using VT Code structured diffs
272    pub fn apply_patch(description: String) -> Self {
273        let sanitized_description = sanitize_tool_description(&description);
274        let mut tool = Self::empty("apply_patch");
275        tool.function = Some(FunctionDefinition {
276            name: "apply_patch".to_owned(),
277            description: sanitized_description,
278            parameters: apply_patch_schema(vtcode_utility_tool_specs::DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION),
279        });
280        tool
281    }
282
283    /// Create a new custom tool definition for freeform function calling (GPT-5 specific)
284    /// Allows raw text payloads without JSON wrapping
285    pub fn custom(name: String, description: String) -> Self {
286        let sanitized_description = sanitize_tool_description(&description);
287        let mut tool = Self::empty("custom");
288        tool.function = Some(FunctionDefinition {
289            name,
290            description: sanitized_description,
291            parameters: json!({}), // Custom tools may not need parameters
292        });
293        tool
294    }
295
296    /// Create a new grammar tool definition for context-free grammar constraints (GPT-5 specific)
297    /// Ensures model output matches predefined syntax
298    pub fn grammar(syntax: String, definition: String) -> Self {
299        let mut tool = Self::empty("grammar");
300        tool.grammar = Some(GrammarDefinition { syntax, definition });
301        tool
302    }
303
304    /// Create a provider-native web search tool definition.
305    pub fn web_search(config: Value) -> Self {
306        let mut tool = Self::empty("web_search");
307        tool.web_search = Some(config);
308        tool
309    }
310
311    /// Create a Gemini Google Maps grounding tool definition.
312    pub fn google_maps(config: Value) -> Self {
313        let mut tool = Self::empty("google_maps");
314        tool.hosted_tool_config = Some(config);
315        tool
316    }
317
318    /// Create a Gemini URL Context tool definition.
319    pub fn url_context(config: Value) -> Self {
320        let mut tool = Self::empty("url_context");
321        tool.hosted_tool_config = Some(config);
322        tool
323    }
324
325    /// Create an OpenAI Responses file search tool definition.
326    pub(crate) fn file_search(config: Value) -> Self {
327        let mut tool = Self::empty("file_search");
328        tool.hosted_tool_config = Some(config);
329        tool
330    }
331
332    /// Create a Gemini Code Execution tool definition.
333    pub(crate) fn code_execution(config: Value) -> Self {
334        let mut tool = Self::empty("code_execution");
335        tool.hosted_tool_config = Some(config);
336        tool
337    }
338
339    /// Create an OpenAI Responses remote MCP tool definition.
340    pub(crate) fn mcp(config: Value) -> Self {
341        let mut tool = Self::empty("mcp");
342        tool.hosted_tool_config = Some(config);
343        tool
344    }
345
346    /// Get the function name for easy access
347    pub fn function_name(&self) -> &str {
348        if self.is_anthropic_memory_tool() {
349            "memory"
350        } else if let Some(func) = &self.function {
351            &func.name
352        } else {
353            &self.tool_type
354        }
355    }
356
357    /// Get the description for easy access
358    pub fn description(&self) -> &str {
359        if let Some(func) = &self.function {
360            &func.description
361        } else if let Some(shell) = &self.shell {
362            &shell.description
363        } else {
364            ""
365        }
366    }
367
368    /// Validate that this tool definition is properly formed
369    pub fn validate(&self) -> Result<(), String> {
370        match self.tool_type.as_str() {
371            "function" => self.validate_function(),
372            "apply_patch" => self.validate_apply_patch(),
373            "shell" => self.validate_shell(),
374            "custom" => self.validate_custom(),
375            "grammar" => self.validate_grammar(),
376            "web_search" => self.validate_web_search(),
377            "google_maps" | "url_context" | "file_search" | "mcp" | "code_execution" => {
378                self.validate_hosted_tool_config()
379            }
380            "tool_search" => Ok(()),
381            "tool_search_tool_regex_20251119" | "tool_search_tool_bm25_20251119" => self.validate_function(),
382            other if other.starts_with("web_search_") => self.validate_anthropic_web_search(),
383            other if other.starts_with("code_execution_") => Ok(()),
384            other if other.starts_with("memory_") => Ok(()),
385            other => Err(format!(
386                "Unsupported tool type: {other}. Supported types: function, apply_patch, shell, custom, grammar, web_search, google_maps, url_context, file_search, mcp, code_execution, tool_search, tool_search_tool_*, web_search_*, code_execution_*, memory_*"
387            )),
388        }
389    }
390
391    /// Returns true if this is a tool search tool type
392    pub fn is_tool_search(&self) -> bool {
393        matches!(
394            self.tool_type.as_str(),
395            "tool_search" | "tool_search_tool_regex_20251119" | "tool_search_tool_bm25_20251119"
396        )
397    }
398
399    /// Returns true when the tool is an Anthropic native web search tool revision.
400    pub fn is_anthropic_web_search(&self) -> bool {
401        self.tool_type.starts_with("web_search_")
402    }
403
404    /// Returns true when the tool is an Anthropic native code execution tool revision.
405    pub(crate) fn is_anthropic_code_execution(&self) -> bool {
406        self.tool_type.starts_with("code_execution_")
407    }
408
409    /// Returns true when the tool is an Anthropic native memory tool revision.
410    pub(crate) fn is_anthropic_memory_tool(&self) -> bool {
411        self.tool_type.starts_with("memory_")
412    }
413
414    fn validate_function(&self) -> Result<(), String> {
415        if let Some(func) = &self.function {
416            if func.name.is_empty() {
417                return Err("Function name cannot be empty".to_owned());
418            }
419            if func.description.is_empty() {
420                return Err("Function description cannot be empty".to_owned());
421            }
422            if !func.parameters.is_object() {
423                return Err("Function parameters must be a JSON object".to_owned());
424            }
425            Ok(())
426        } else {
427            Err("Function tool missing function definition".to_owned())
428        }
429    }
430
431    fn validate_apply_patch(&self) -> Result<(), String> {
432        if let Some(func) = &self.function {
433            if func.name != "apply_patch" {
434                return Err(format!("apply_patch tool must have name 'apply_patch', got: {}", func.name));
435            }
436            if func.description.is_empty() {
437                return Err("apply_patch description cannot be empty".to_owned());
438            }
439            Ok(())
440        } else {
441            Err("apply_patch tool missing function definition".to_owned())
442        }
443    }
444
445    fn validate_shell(&self) -> Result<(), String> {
446        if let Some(shell) = &self.shell {
447            if shell.description.is_empty() {
448                return Err("Shell tool description cannot be empty".to_owned());
449            }
450            if shell.timeout_seconds == 0 {
451                return Err("Shell tool timeout must be greater than 0".to_owned());
452            }
453            Ok(())
454        } else {
455            Err("Shell tool missing shell definition".to_owned())
456        }
457    }
458
459    fn validate_custom(&self) -> Result<(), String> {
460        if let Some(func) = &self.function {
461            if func.name.is_empty() {
462                return Err("Custom tool name cannot be empty".to_owned());
463            }
464            if func.description.is_empty() {
465                return Err("Custom tool description cannot be empty".to_owned());
466            }
467            Ok(())
468        } else {
469            Err("Custom tool missing function definition".to_owned())
470        }
471    }
472
473    fn validate_grammar(&self) -> Result<(), String> {
474        if let Some(grammar) = &self.grammar {
475            if !["lark", "regex"].contains(&grammar.syntax.as_str()) {
476                return Err("Grammar syntax must be 'lark' or 'regex'".to_owned());
477            }
478            if grammar.definition.is_empty() {
479                return Err("Grammar definition cannot be empty".to_owned());
480            }
481            Ok(())
482        } else {
483            Err("Grammar tool missing grammar definition".to_owned())
484        }
485    }
486
487    fn validate_web_search(&self) -> Result<(), String> {
488        self.web_search_config_object(true).map(|_| ())
489    }
490
491    fn validate_anthropic_web_search(&self) -> Result<(), String> {
492        let Some(config) = self.web_search_config_object(false)? else {
493            return Ok(());
494        };
495
496        if config.contains_key("allowed_domains") && config.contains_key("blocked_domains") {
497            return Err("anthropic web_search tools cannot set both allowed_domains and blocked_domains".to_owned());
498        }
499
500        Ok(())
501    }
502
503    fn validate_hosted_tool_config(&self) -> Result<(), String> {
504        match self.hosted_tool_config.as_ref() {
505            Some(Value::Object(_)) => Ok(()),
506            Some(_) => Err(format!("{} tool configuration must be a JSON object", self.tool_type)),
507            None => Err(format!("{} tool missing configuration", self.tool_type)),
508        }
509    }
510
511    fn web_search_config_object(&self, required: bool) -> Result<Option<&Map<String, Value>>, String> {
512        match self.web_search.as_ref() {
513            Some(Value::Object(config)) => Ok(Some(config)),
514            Some(_) => Err(format!("{} tool configuration must be a JSON object", self.tool_type)),
515            None if required => Err(format!("{} tool missing web_search configuration", self.tool_type)),
516            None => Ok(None),
517        }
518    }
519}
520
521/// Static schema for apply_patch tool parameters.
522/// This is the same schema as `crate::tools::apply_patch::parameter_schema` but
523/// inlined to avoid depending on vtcode-core's tools module.
524fn apply_patch_schema(description: &str) -> Value {
525    json!({
526        "type": "object",
527        "properties": {
528            "patch": {
529                "type": "string",
530                "description": description
531            }
532        },
533        "required": ["patch"]
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::ToolDefinition;
540
541    #[test]
542    fn apply_patch_parameter_schema_requires_workspace_relative_paths() {
543        let tool = ToolDefinition::apply_patch("Apply patches".to_owned());
544        let function = tool.function.expect("apply_patch function definition");
545        let description = function.parameters["properties"]["patch"]["description"]
546            .as_str()
547            .expect("patch parameter description");
548
549        assert!(description.contains("workspace-relative"));
550        assert!(description.contains("absolute paths"));
551        assert!(description.contains("`..`"));
552        assert!(description.contains("traversal-like forms"));
553    }
554}