1pub 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
45pub const MAX_TOOL_ITERATIONS: usize = 8;
51
52pub const MAX_TOOL_CONTINUATIONS: usize = 3;
54
55pub const MAX_RESULTS: usize = 100;
57pub const MAX_OUTPUT_BYTES: usize = 65_536;
59pub const TIMEOUT_SECS: u64 = 10;
61pub const MAX_LINE_LEN: usize = 512;
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum WriteOp {
70 Create,
72 Replace,
74 Edit,
76}
77
78impl WriteOp {
79 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#[derive(Clone, Debug)]
96pub struct AgentRunConfig {
97 pub root: PathBuf,
99 pub model: String,
101 pub search_mode: WebSearchMode,
103 pub search_url: Option<String>,
105 pub reasoning_effort: ReasoningEffort,
110 pub reasoning_summary: ReasoningSummary,
115 pub max_tool_iterations: usize,
117 pub mcp_manager: Option<Arc<McpManager>>,
119 pub process_registry: Option<shell::ProcessRegistry>,
121 pub accounting_turn_id: Option<String>,
123 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 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 pub fn with_search_url(mut self, search_url: Option<String>) -> Self {
153 self.search_url = search_url;
154 self
155 }
156
157 pub fn search_config(&self) -> SearchConfig {
159 SearchConfig::from_parts(self.search_mode, self.search_url.clone())
160 }
161
162 pub fn with_mcp_manager(mut self, manager: Arc<McpManager>) -> Self {
164 self.mcp_manager = Some(manager);
165 self
166 }
167
168 pub fn with_process_registry(mut self, registry: shell::ProcessRegistry) -> Self {
170 self.process_registry = Some(registry);
171 self
172 }
173
174 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#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct SearchMatch {
191 pub path: String,
193 pub line_number: u32,
195 pub text: String,
197}
198
199#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct WriteResult {
206 pub op: WriteOp,
208 pub path: PathBuf,
210 pub before_hash: Option<u64>,
212 pub before_bytes: Option<usize>,
214 pub after_hash: u64,
216 pub after_bytes: usize,
218}
219
220impl WriteResult {
221 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
237pub 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
248pub fn resolve_workspace_path(root: &Path, path: &Path) -> std::io::Result<PathBuf> {
250 path::resolve_within_root(root, &path.display().to_string())
251}
252
253pub fn tool_definitions() -> Vec<ToolDefinition> {
258 registry::tool_definitions()
259}
260
261pub 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
270pub fn provider_tool_catalog_schemas(defs: &[ToolDefinition], format: ProviderSchemaFormat) -> serde_json::Value {
272 registry::provider_tool_catalog_schemas(defs, format)
273}
274
275pub fn tool_catalog_schemas(defs: &[ToolDefinition]) -> serde_json::Value {
285 provider_tool_catalog_schemas(defs, ProviderSchemaFormat::Anthropic)
286}
287
288pub 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
306pub 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
317pub 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
326pub 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
333pub 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
348pub 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
360pub 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
372pub 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
380pub 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
394pub 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 #[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 #[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 #[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}