Skip to main content

thndrs_lib/core/
tools.rs

1//! Structured read-only filesystem tools.
2//!
3//! The model sees typed tools, not raw shell command strings. All subprocess
4//! invocations use [`std::process::Command`] argv arrays — never shell strings.
5//!
6//! ## Safety Rules
7//!
8//! - Workspace-root containment enforced after path normalization.
9//! - Ignore rules respected and hidden files skipped by default.
10//! - Hidden files, ignored files, symlink following, and unrestricted searches
11//!   are opt-in only.
12//! - Timeout, result-count, stdout/stderr byte, and line-length caps enforced.
13//! - `rg` exit code `1` is treated as "no matches", not an error.
14//! - Arbitrary `sed`/`awk`, `sed -i`, and `awk system()` are not exposed; only
15//!   typed read-only `sawk` inspection actions are available.
16
17pub mod shell;
18
19mod atomic_write;
20mod create_file;
21mod find_files;
22mod list_searchable_files;
23mod path;
24mod read_file_range;
25mod read_url;
26mod registry;
27mod replace_range;
28mod sawk;
29mod search_text;
30mod subproc;
31mod web_search;
32mod write_patch;
33
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38use thndrs_agent::CancelToken;
39
40use crate::app::ToolStatus;
41use crate::cli::{ReasoningEffort, ReasoningSummary, WebSearchMode};
42use crate::mcp::manager::McpManager;
43use crate::search::SearchConfig;
44
45/// Maximum number of tool-call iterations per agent turn before the loop
46/// stops with a cap-exceeded error.
47///
48/// This prevents recursive or unbounded tool-call loops (e.g. a model that
49/// keeps requesting tools without converging on a final answer).
50pub const MAX_TOOL_ITERATIONS: usize = 8;
51
52/// Maximum number of automatic tool-budget continuations per user turn.
53pub const MAX_TOOL_CONTINUATIONS: usize = 3;
54
55/// Default maximum number of results from a search or list operation.
56pub const MAX_RESULTS: usize = 100;
57/// Maximum stdout/stderr bytes captured from a subprocess.
58pub const MAX_OUTPUT_BYTES: usize = 65_536;
59/// Timeout in seconds for subprocess execution.
60pub const TIMEOUT_SECS: u64 = 10;
61/// Maximum line length before truncation in tool output.
62pub const MAX_LINE_LEN: usize = 512;
63
64/// The kind of write operation performed on a file.
65///
66/// Used by [`WriteResult`] and the session record to audit what changed.
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum WriteOp {
70    /// Create a new file that did not previously exist.
71    Create,
72    /// Replace the entire contents of an existing file.
73    Replace,
74    /// Edit a file by replacing a unique exact string occurrence.
75    Edit,
76}
77
78impl WriteOp {
79    /// Lowercase label used in transcript display and session records.
80    pub fn label(&self) -> &'static str {
81        match self {
82            WriteOp::Create => "create",
83            WriteOp::Replace => "replace",
84            WriteOp::Edit => "edit",
85        }
86    }
87}
88
89pub use thndrs_agent::ToolDefinition;
90pub use thndrs_agent::{ToolBudgetDecision, ToolIterationBudget};
91
92pub use registry::ProviderSchemaFormat;
93
94/// Configuration for an agent run, shared by the fake and Umans providers.
95#[derive(Clone, Debug)]
96pub struct AgentRunConfig {
97    /// Workspace root for tool containment and file reads.
98    pub root: PathBuf,
99    /// Selected model name.
100    pub model: String,
101    /// Web search mode.
102    pub search_mode: WebSearchMode,
103    /// Optional SearXNG base URL used by the application-owned web search tool.
104    pub search_url: Option<String>,
105    /// Reasoning effort requested for supporting provider models.
106    ///
107    /// TODO: Route this through every provider/model family that supports
108    /// reasoning-effort controls.
109    pub reasoning_effort: ReasoningEffort,
110    /// Whether supporting providers should return reasoning summaries.
111    ///
112    /// TODO: Route this through every provider/model family that supports
113    /// reasoning-summary controls.
114    pub reasoning_summary: ReasoningSummary,
115    /// Maximum tool-call iterations per turn.
116    pub max_tool_iterations: usize,
117    /// Optional MCP manager used to extend the built-in tool registry.
118    pub mcp_manager: Option<Arc<McpManager>>,
119    /// Shared application owner for background shell processes.
120    pub process_registry: Option<shell::ProcessRegistry>,
121    /// Turn id associated with request accounting.
122    pub accounting_turn_id: Option<String>,
123    /// Context candidates captured before the first provider request.
124    pub accounting_context: Vec<thndrs_agent::ContextItemSnapshot>,
125}
126
127impl AgentRunConfig {
128    pub fn new(root: PathBuf, model: String, search_mode: WebSearchMode) -> Self {
129        AgentRunConfig {
130            root,
131            model,
132            search_mode,
133            search_url: None,
134            reasoning_effort: ReasoningEffort::default(),
135            reasoning_summary: ReasoningSummary::default(),
136            max_tool_iterations: MAX_TOOL_ITERATIONS,
137            mcp_manager: None,
138            process_registry: None,
139            accounting_turn_id: None,
140            accounting_context: Vec::new(),
141        }
142    }
143
144    /// Apply the resolved reasoning settings for this run.
145    pub fn with_reasoning(mut self, effort: ReasoningEffort, summary: ReasoningSummary) -> Self {
146        self.reasoning_effort = effort;
147        self.reasoning_summary = summary;
148        self
149    }
150
151    /// Apply the configured SearXNG base URL for this run.
152    pub fn with_search_url(mut self, search_url: Option<String>) -> Self {
153        self.search_url = search_url;
154        self
155    }
156
157    /// Build the application-owned search configuration for this run.
158    pub fn search_config(&self) -> SearchConfig {
159        SearchConfig::from_parts(self.search_mode, self.search_url.clone())
160    }
161
162    /// Attach an MCP manager to this run.
163    pub fn with_mcp_manager(mut self, manager: Arc<McpManager>) -> Self {
164        self.mcp_manager = Some(manager);
165        self
166    }
167
168    /// Attach the application-owned registry used for background shell tools.
169    pub fn with_process_registry(mut self, registry: shell::ProcessRegistry) -> Self {
170        self.process_registry = Some(registry);
171        self
172    }
173
174    /// Attach the immutable context snapshot used by provider request accounting.
175    pub fn with_request_context(
176        mut self, turn_id: impl Into<String>, ledger: &thndrs_agent::context::ContextLedger,
177    ) -> Self {
178        self.accounting_turn_id = Some(turn_id.into());
179        self.accounting_context = thndrs_agent::snapshot_context(&ledger.items);
180        self
181    }
182}
183
184pub use thndrs_agent::{
185    ToolDisplayProjection, ToolEvidenceKind, ToolEvidenceMetadata, ToolModelProjection, ToolOutput, ToolUseRequest,
186};
187
188/// A single search match from `rg --json`.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct SearchMatch {
191    /// File path (relative to root, as reported by rg).
192    pub path: String,
193    /// 1-based line number.
194    pub line_number: u32,
195    /// Matched line text.
196    pub text: String,
197}
198
199/// Structured result of a file write operation.
200///
201/// Captures the operation type, target path, and before/after metadata needed
202/// for session audit. The actual file content is never stored — only hashes
203/// and byte counts, so secrets and large files are not persisted.
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct WriteResult {
206    /// Operation performed.
207    pub op: WriteOp,
208    /// Absolute path to the target file.
209    pub path: PathBuf,
210    /// Hash of the file content before the operation, if it existed.
211    pub before_hash: Option<u64>,
212    /// Byte count before the operation, if the file existed.
213    pub before_bytes: Option<usize>,
214    /// Hash of the file content after the operation.
215    pub after_hash: u64,
216    /// Byte count after the operation.
217    pub after_bytes: usize,
218}
219
220impl WriteResult {
221    /// Render a compact single-line summary for transcript display.
222    pub fn summary(&self) -> String {
223        let before = match (self.before_hash.is_some(), self.before_bytes) {
224            (true, Some(n)) => format!("from {n} bytes"),
225            _ => "new file".to_string(),
226        };
227        format!(
228            "{} {} ({} → {} bytes)",
229            self.op.label(),
230            self.path.display(),
231            before,
232            self.after_bytes
233        )
234    }
235}
236
237/// Compute a stable hash of content using the standard library hasher.
238///
239/// Shared by context loading and write operations so hashes are comparable.
240pub fn hash_content(content: &str) -> u64 {
241    use std::collections::hash_map::DefaultHasher;
242    use std::hash::{Hash, Hasher};
243    let mut hasher = DefaultHasher::new();
244    content.hash(&mut hasher);
245    hasher.finish()
246}
247
248/// Resolve a workspace path using the same containment and symlink checks as tools.
249pub fn resolve_workspace_path(root: &Path, path: &Path) -> std::io::Result<PathBuf> {
250    path::resolve_within_root(root, &path.display().to_string())
251}
252
253/// The catalog of tools exposed to the model.
254///
255/// These definitions are derived from the built-in registry so every
256/// provider-visible tool has a matching registry entry.
257pub fn tool_definitions() -> Vec<ToolDefinition> {
258    registry::tool_definitions()
259}
260
261/// Return built-in tools plus cached MCP tools for a run.
262pub fn runtime_tool_definitions(mcp_manager: Option<&McpManager>) -> Vec<ToolDefinition> {
263    let mut definitions = tool_definitions();
264    if let Some(manager) = mcp_manager {
265        definitions.extend(manager.tool_definitions());
266    }
267    definitions
268}
269
270/// Convert the tool catalog into provider-compatible tool schemas.
271pub fn provider_tool_catalog_schemas(defs: &[ToolDefinition], format: ProviderSchemaFormat) -> serde_json::Value {
272    registry::provider_tool_catalog_schemas(defs, format)
273}
274
275/// Convert the tool catalog into Anthropic-compatible tool schemas.
276///
277/// Returns a compact, stably-ordered JSON array of tool definitions suitable
278/// for the `tools` field of a `/v1/messages` request. Each entry carries
279/// `name`, `description`, and `input_schema`.
280///
281/// Send this schema every provider turn. Umans does not expose explicit
282/// reusable-history or prompt-cache behavior for tool definitions, so we do
283/// not rely on hidden provider state for them.
284pub fn tool_catalog_schemas(defs: &[ToolDefinition]) -> serde_json::Value {
285    provider_tool_catalog_schemas(defs, ProviderSchemaFormat::Anthropic)
286}
287
288/// Return a recursively sorted JSON value for deterministic debug rendering.
289pub fn sorted_json_value(value: &serde_json::Value) -> serde_json::Value {
290    match value {
291        serde_json::Value::Array(items) => serde_json::Value::Array(items.iter().map(sorted_json_value).collect()),
292        serde_json::Value::Object(object) => {
293            let mut entries: Vec<_> = object.iter().collect();
294            entries.sort_by_key(|(key, _)| *key);
295            serde_json::Value::Object(
296                entries
297                    .into_iter()
298                    .map(|(key, value)| (key.clone(), sorted_json_value(value)))
299                    .collect(),
300            )
301        }
302        _ => value.clone(),
303    }
304}
305
306/// Dispatch a provider tool-use request, returning the tool output, an optional
307/// file-write result, and an optional shell-execution result.
308///
309/// This is the unified entry point for the agent loop, returning all structured
310/// side effects alongside the [`ToolOutput`].
311pub fn dispatch_full(
312    request: &ToolUseRequest, root: &Path,
313) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
314    dispatch_full_with_search(request, root, &SearchConfig::default())
315}
316
317/// Dispatch a provider tool-use request with the active search backend.
318pub fn dispatch_full_with_search(
319    request: &ToolUseRequest, root: &Path, search: &SearchConfig,
320) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
321    let context = registry::ToolContext::with_search(root, search);
322    let execution = registry::execute(request, &context);
323    (execution.output, execution.write_result, execution.shell_result)
324}
325
326/// Dispatch a request with cancellation and the active search backend.
327pub fn dispatch_full_with_cancel_and_search(
328    request: &ToolUseRequest, root: &Path, cancel: &CancelToken, search: &SearchConfig,
329) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
330    dispatch_full_with_cancel_and_search_and_registry(request, root, cancel, search, None)
331}
332
333/// Dispatch a request with cancellation, search settings, and an optional
334/// application-owned background-process registry.
335pub fn dispatch_full_with_cancel_and_search_and_registry(
336    request: &ToolUseRequest, root: &Path, cancel: &CancelToken, search: &SearchConfig,
337    process_registry: Option<&shell::ProcessRegistry>,
338) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
339    let execution = if request.name == shell::NAME {
340        shell::execute_request_with_cancel_and_registry(request, root, cancel, process_registry)
341    } else {
342        let context = registry::ToolContext::with_search(root, search);
343        registry::execute(request, &context)
344    };
345    (execution.output, execution.write_result, execution.shell_result)
346}
347
348/// Dispatch through MCP first when a namespaced MCP tool is available.
349pub fn dispatch_runtime_full(
350    request: &ToolUseRequest, root: &Path, mcp_manager: Option<&McpManager>,
351) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
352    if request.name.starts_with("mcp__")
353        && let Some(manager) = mcp_manager
354    {
355        return (manager.call_tool(request), None, None);
356    }
357    dispatch_full(request, root)
358}
359
360/// Dispatch through MCP or built-ins with the active search backend.
361pub fn dispatch_runtime_full_with_search(
362    request: &ToolUseRequest, root: &Path, mcp_manager: Option<&McpManager>, search: &SearchConfig,
363) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
364    if request.name.starts_with("mcp__")
365        && let Some(manager) = mcp_manager
366    {
367        return (manager.call_tool(request), None, None);
368    }
369    dispatch_full_with_search(request, root, search)
370}
371
372/// Dispatch through MCP or built-ins with cancellation and search settings.
373pub fn dispatch_runtime_full_with_cancel_and_search(
374    request: &ToolUseRequest, root: &Path, mcp_manager: Option<&McpManager>, cancel: &CancelToken,
375    search: &SearchConfig,
376) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
377    dispatch_runtime_full_with_cancel_and_search_and_registry(request, root, mcp_manager, cancel, search, None)
378}
379
380/// Dispatch through MCP or built-ins with cancellation, search settings, and
381/// an optional application-owned background-process registry.
382pub fn dispatch_runtime_full_with_cancel_and_search_and_registry(
383    request: &ToolUseRequest, root: &Path, mcp_manager: Option<&McpManager>, cancel: &CancelToken,
384    search: &SearchConfig, process_registry: Option<&shell::ProcessRegistry>,
385) -> (ToolOutput, Option<WriteResult>, Option<shell::ProcessResult>) {
386    if request.name.starts_with("mcp__")
387        && let Some(manager) = mcp_manager
388    {
389        return (manager.call_tool(request), None, None);
390    }
391    dispatch_full_with_cancel_and_search_and_registry(request, root, cancel, search, process_registry)
392}
393
394/// Return searchable file paths for UI features that need file selection.
395pub fn searchable_file_paths(root: &Path, max_results: usize) -> Result<Vec<String>, String> {
396    let output = list_searchable_files::exec(root, None, max_results, false);
397    if output.status == ToolStatus::Failed {
398        return Err(output.error.unwrap_or_else(|| "file listing failed".to_string()));
399    }
400
401    Ok(output
402        .display
403        .lines
404        .into_iter()
405        .map(|p| normalize_tool_path(root, &p))
406        .collect())
407}
408
409fn normalize_tool_path(root: &Path, path: &str) -> String {
410    let path_buf = PathBuf::from(path);
411    path_buf
412        .strip_prefix(root)
413        .unwrap_or(&path_buf)
414        .to_string_lossy()
415        .to_string()
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn tool_output_ok() {
424        let output = ToolOutput::ok("test", vec!["line1".to_string()]);
425        assert_eq!(output.name, "test");
426        assert_eq!(output.status, ToolStatus::Ok);
427        assert_eq!(output.display.lines, vec!["line1"]);
428        assert!(output.error.is_none());
429    }
430
431    #[test]
432    fn tool_output_failed() {
433        let output = ToolOutput::failed("test", "something went wrong".to_string());
434        assert_eq!(output.name, "test");
435        assert_eq!(output.status, ToolStatus::Failed);
436        assert!(output.display.lines.is_empty());
437        assert_eq!(output.error.as_deref(), Some("something went wrong"));
438    }
439
440    #[test]
441    fn runtime_dispatch_cancellation_stops_run_shell() {
442        let dir = tempfile::tempdir().expect("temp dir");
443        let request = ToolUseRequest::new(
444            "run_shell".to_string(),
445            serde_json::json!({ "argv": ["sh", "-c", "exec sleep 30"] }).to_string(),
446            "call_1".to_string(),
447        );
448        let cancel = CancelToken::new();
449        let canceller = cancel.clone();
450        let stopper = std::thread::spawn(move || {
451            std::thread::sleep(std::time::Duration::from_millis(100));
452            canceller.cancel();
453        });
454        let started = std::time::Instant::now();
455
456        let (output, _, process) =
457            dispatch_runtime_full_with_cancel_and_search(&request, dir.path(), None, &cancel, &SearchConfig::default());
458
459        stopper.join().expect("cancellation thread");
460        let process = process.expect("shell process result");
461        assert_eq!(output.status, ToolStatus::Failed);
462        assert_eq!(process.status, shell::ProcessStatus::Cancelled);
463        assert!(started.elapsed() < std::time::Duration::from_secs(5));
464    }
465
466    /// Design assertion: the tool surface never exposes dangerous subprocess
467    /// flags.
468    ///
469    /// The tool implementations are the only entry points for `fd`,
470    /// `rg`, and `find`; they construct argv arrays from typed inputs and do
471    /// not pass through `--exec`, `--exec-batch`, `--pre`, `sed`, `awk`, or
472    /// any shell-string mechanism.
473    #[test]
474    fn no_dangerous_subprocess_flags_exposed() {
475        for v in &["FindFiles", "ListSearchableFiles", "SearchText", "ReadFileRange"] {
476            assert!(!v.contains("exec"), "no exec variant: {v}");
477            assert!(!v.contains("shell"), "no shell variant: {v}");
478            assert!(!v.contains("raw"), "no raw command variant: {v}");
479        }
480    }
481
482    /// Design assertion: every tool description is minimal but complete.
483    ///
484    /// Each description must lead with the tool name, state its purpose, and
485    /// mention at least one safety limit (containment, caps, truncation, or
486    /// rejection). Descriptions stay short so the tool catalog remains compact
487    /// when sent every provider turn.
488    #[test]
489    fn tool_descriptions_are_minimal_and_complete() {
490        let defs = tool_definitions();
491        assert!(!defs.is_empty(), "tool catalog should not be empty");
492
493        for def in &defs {
494            let desc = def.description.as_ref();
495            assert!(
496                desc.starts_with(def.name.as_ref()),
497                "description for `{}` should lead with its name, got: {desc}",
498                def.name
499            );
500            assert!(
501                desc.len() < 450,
502                "description for `{}` should be concise (<450 chars), got {} chars",
503                def.name,
504                desc.len()
505            );
506            let lower = desc.to_lowercase();
507            let mentions_safety = lower.contains("cap")
508                || lower.contains("reject")
509                || lower.contains("contain")
510                || lower.contains("truncat")
511                || lower.contains("enforce");
512            assert!(
513                mentions_safety,
514                "description for `{}` should mention a safety limit (caps/rejection/containment/truncation/enforcement), got: {desc}",
515                def.name
516            );
517        }
518    }
519
520    /// Design assertion: every tool description includes behavioral guidance
521    /// telling the model *when* to use the tool, following the Claude Code
522    /// pattern where tool descriptions carry usage policy.
523    #[test]
524    fn tool_descriptions_include_usage_guidance() {
525        let defs = tool_definitions();
526        for def in &defs {
527            let lower = def.description.to_lowercase();
528            let has_guidance = lower.contains("use this") || lower.contains("prefer") || lower.contains("when you");
529            assert!(
530                has_guidance,
531                "description for `{}` should include usage guidance (\"use this\" / \"prefer\" / \"when you\"), got: {}",
532                def.name, def.description
533            );
534        }
535    }
536
537    #[test]
538    fn tool_catalog_schemas_produces_anthropic_format() {
539        let defs = tool_definitions();
540        let schemas = tool_catalog_schemas(&defs);
541        let arr = schemas.as_array().expect("schemas should be a JSON array");
542        assert_eq!(arr.len(), defs.len(), "schema count should match definition count");
543
544        for (schema, def) in arr.iter().zip(defs.iter()) {
545            assert_eq!(schema["name"], def.name.as_ref(), "schema name should match");
546            assert_eq!(
547                schema["description"],
548                def.description.as_ref(),
549                "schema description should match"
550            );
551            assert!(
552                schema.get("input_schema").is_some(),
553                "schema should have input_schema for {}",
554                def.name
555            );
556        }
557    }
558
559    #[test]
560    fn tool_catalog_schemas_is_stably_ordered() {
561        let defs = tool_definitions();
562        let schemas_a = tool_catalog_schemas(&defs);
563        let schemas_b = tool_catalog_schemas(&defs);
564        assert_eq!(schemas_a, schemas_b, "repeated calls should produce identical ordering");
565
566        let names_a: Vec<&str> = schemas_a
567            .as_array()
568            .unwrap()
569            .iter()
570            .map(|s| s["name"].as_str().unwrap())
571            .collect();
572        let names_defs: Vec<&str> = defs.iter().map(|d| d.name.as_ref()).collect();
573        assert_eq!(names_a, names_defs, "schema order should match definition order");
574    }
575
576    #[test]
577    fn tool_catalog_schemas_empty_for_no_definitions() {
578        let schemas = tool_catalog_schemas(&[]);
579        assert!(schemas.as_array().unwrap().is_empty());
580    }
581
582    #[test]
583    fn registry_validates_builtin_entries() {
584        registry::validate().expect("built-in registry should validate");
585    }
586
587    #[test]
588    fn registry_names_are_stable_and_unique() {
589        let names: Vec<&str> = registry::builtins().iter().map(|entry| entry.name).collect();
590        assert_eq!(
591            names,
592            vec![
593                "find_files",
594                "list_searchable_files",
595                "search_text",
596                "read_file_range",
597                "sawk",
598                "web_search",
599                "read_url",
600                "create_file",
601                "replace_range",
602                "write_patch",
603                "run_shell",
604            ]
605        );
606
607        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
608        assert_eq!(unique.len(), names.len(), "tool names should be unique");
609    }
610
611    #[test]
612    fn tool_definitions_are_derived_from_registry() {
613        let defs = tool_definitions();
614        let registry_names: Vec<&str> = registry::builtins().iter().map(|entry| entry.name).collect();
615        let definition_names: Vec<&str> = defs.iter().map(|definition| definition.name.as_ref()).collect();
616        assert_eq!(definition_names, registry_names);
617    }
618
619    #[test]
620    fn registry_schemas_are_json_objects_with_required_fields() {
621        for entry in registry::builtins() {
622            let definition = (entry.definition)();
623            let schema = &definition.input_schema;
624            assert_eq!(
625                schema.get("type").and_then(|value| value.as_str()),
626                Some("object"),
627                "{} schema should be a JSON object",
628                entry.name
629            );
630            assert!(
631                schema.get("properties").is_some_and(|value| value.is_object()),
632                "{} schema should define properties",
633                entry.name
634            );
635
636            match schema.get("required") {
637                Some(required) => {
638                    assert!(required.is_array(), "{} required fields should be an array", entry.name);
639                }
640                None => {
641                    assert_eq!(
642                        entry.name, "list_searchable_files",
643                        "{} should declare required fields unless all inputs are optional",
644                        entry.name
645                    );
646                }
647            }
648        }
649    }
650
651    #[test]
652    fn registry_examples_are_valid_json_objects() {
653        for entry in registry::builtins() {
654            let value: serde_json::Value = serde_json::from_str(entry.example_input)
655                .unwrap_or_else(|err| panic!("{} example should parse as JSON: {err}", entry.name));
656            assert!(value.is_object(), "{} example should be a JSON object", entry.name);
657        }
658    }
659
660    #[test]
661    fn tool_catalog_snapshot_from_registry() {
662        let schemas = tool_catalog_schemas(&tool_definitions());
663        let json = serde_json::to_string_pretty(&sorted_json_value(&schemas)).expect("catalog JSON");
664        insta::assert_snapshot!(json);
665    }
666
667    #[test]
668    fn provider_tool_catalog_schemas_supports_openai_function_format() {
669        let defs = tool_definitions();
670        let schemas = provider_tool_catalog_schemas(&defs, ProviderSchemaFormat::OpenAiFunction);
671        let arr = schemas.as_array().expect("OpenAI schemas should be an array");
672        assert_eq!(arr.len(), defs.len());
673
674        let first = &arr[0];
675        assert_eq!(first["type"], "function");
676        assert_eq!(first["function"]["name"], defs[0].name.as_ref());
677        assert_eq!(first["function"]["description"], defs[0].description.as_ref());
678        assert_eq!(first["function"]["parameters"], defs[0].input_schema);
679    }
680
681    #[test]
682    fn tool_budget_continues_before_segment_cap() {
683        let mut budget = ToolIterationBudget::new(2, 3);
684        budget.record_tool_batch();
685        assert_eq!(budget.before_provider_request(), ToolBudgetDecision::Continue);
686    }
687
688    #[test]
689    fn tool_budget_continues_after_first_segment_cap() {
690        let mut budget = ToolIterationBudget::new(2, 3);
691        budget.record_tool_batch();
692        budget.record_tool_batch();
693
694        assert_eq!(
695            budget.before_provider_request(),
696            ToolBudgetDecision::ContinueAfterBudgetMessage
697        );
698
699        assert_eq!(budget.total_batches(), 2);
700        assert_eq!(budget.continuations_used(), 1);
701    }
702
703    #[test]
704    fn tool_budget_resets_segment_counter_after_continuation() {
705        let mut budget = ToolIterationBudget::new(2, 3);
706        budget.record_tool_batch();
707        budget.record_tool_batch();
708        assert_eq!(
709            budget.before_provider_request(),
710            ToolBudgetDecision::ContinueAfterBudgetMessage
711        );
712
713        budget.record_tool_batch();
714        assert_eq!(budget.before_provider_request(), ToolBudgetDecision::Continue);
715    }
716
717    #[test]
718    fn tool_budget_exhausts_after_three_auto_continuations() {
719        let mut budget = ToolIterationBudget::new(2, 3);
720        for _ in 0..3 {
721            budget.record_tool_batch();
722            budget.record_tool_batch();
723            assert_eq!(
724                budget.before_provider_request(),
725                ToolBudgetDecision::ContinueAfterBudgetMessage
726            );
727        }
728
729        budget.record_tool_batch();
730        budget.record_tool_batch();
731        assert_eq!(
732            budget.before_provider_request(),
733            ToolBudgetDecision::Exhausted { segment_iterations: 2, total_batches: 8, continuations_used: 3 }
734        );
735    }
736}