1use std::collections::HashSet;
4use std::future::Future;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex};
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::agents::knowledge::Knowledge;
14use crate::agents::tickets::TicketSystem;
15use crate::providers::types::ContentBlock;
16use crate::providers::{ProviderResult, ProviderToolDefinition};
17
18use super::error::ToolError;
19
20const PER_TOOL_CAP: usize = 50_000;
24
25const PER_TURN_CAP: usize = 200_000;
29
30const PREVIEW_CHARS: usize = 2_000;
35
36#[derive(Clone)]
42pub struct ToolContext {
43 pub dir: PathBuf,
46 pub interrupt_signal: Arc<AtomicBool>,
47 pub(crate) tool_registry: Option<Arc<ToolRegistry>>,
48 pub(crate) ticket_system: Option<Arc<TicketSystem>>,
49 pub(crate) agent_name: Option<String>,
50 pub(crate) ticket_key: Option<String>,
51 pub(crate) knowledge: Option<Arc<Knowledge>>,
52}
53
54impl ToolContext {
55 pub fn new(dir: PathBuf) -> Self {
60 Self {
61 dir,
62 interrupt_signal: Arc::new(AtomicBool::new(false)),
63 tool_registry: None,
64 ticket_system: None,
65 agent_name: None,
66 ticket_key: None,
67 knowledge: None,
68 }
69 }
70
71 pub fn interrupt_signal(mut self, signal: Arc<AtomicBool>) -> Self {
74 self.interrupt_signal = signal;
75 self
76 }
77
78 pub(crate) fn registry(mut self, registry: Arc<ToolRegistry>) -> Self {
79 self.tool_registry = Some(registry);
80 self
81 }
82
83 pub(crate) fn ticket_system(mut self, system: Arc<TicketSystem>) -> Self {
84 self.ticket_system = Some(system);
85 self
86 }
87
88 pub(crate) fn agent_name(mut self, name: String) -> Self {
89 self.agent_name = Some(name);
90 self
91 }
92
93 pub(crate) fn ticket_key(mut self, key: String) -> Self {
94 self.ticket_key = Some(key);
95 self
96 }
97
98 pub(crate) fn knowledge(mut self, knowledge: Arc<Knowledge>) -> Self {
99 self.knowledge = Some(knowledge);
100 self
101 }
102
103 pub(crate) fn ticket_system_handle(&self) -> Option<&Arc<TicketSystem>> {
104 self.ticket_system.as_ref()
105 }
106
107 pub(crate) fn agent_name_str(&self) -> Option<&str> {
108 self.agent_name.as_deref()
109 }
110
111 pub async fn wait_for_cancel(&self) {
117 const POLL: std::time::Duration = std::time::Duration::from_millis(50);
118 loop {
119 if self.interrupt_signal.load(Ordering::Relaxed) {
120 return;
121 }
122 tokio::time::sleep(POLL).await;
123 }
124 }
125
126 pub(crate) fn mark_tool_discovered(&self, name: &str) {
127 if let Some(registry) = self.tool_registry.as_ref() {
128 registry.mark_discovered(name);
129 }
130 }
131}
132
133impl std::fmt::Debug for ToolContext {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("ToolContext")
136 .field("dir", &self.dir)
137 .field("has_registry", &self.tool_registry.is_some())
138 .field("has_ticket_system", &self.ticket_system.is_some())
139 .finish()
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct ToolCall {
146 pub id: String,
148 pub name: String,
150 pub input: Value,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
162pub enum ToolResult {
163 Success(String),
165 Error(String),
167 SchemaError(String),
170}
171
172impl ToolResult {
173 pub fn success(content: impl Into<String>) -> Self {
175 Self::Success(content.into())
176 }
177
178 pub fn error(content: impl Into<String>) -> Self {
180 Self::Error(content.into())
181 }
182
183 pub fn schema_error(content: impl Into<String>) -> Self {
187 Self::SchemaError(content.into())
188 }
189}
190
191pub trait ToolLike: Send + Sync {
197 fn name(&self) -> &str;
199
200 fn description(&self) -> &str;
202
203 fn input_schema(&self) -> Value;
205
206 fn is_read_only(&self) -> bool {
209 false
210 }
211
212 fn should_defer(&self) -> bool {
216 false
217 }
218
219 fn call<'a>(
223 &'a self,
224 input: Value,
225 ctx: &'a ToolContext,
226 ) -> Pin<Box<dyn Future<Output = ProviderResult<ToolResult>> + Send + 'a>>;
227}
228
229pub(crate) struct ToolRegistry {
233 pub(crate) tools: Vec<Arc<dyn ToolLike>>,
234 pub(crate) discovered: Mutex<HashSet<String>>,
235}
236
237impl std::fmt::Debug for ToolRegistry {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 let names: Vec<&str> = self.tools.iter().map(|t| t.name()).collect();
240 f.debug_struct("ToolRegistry")
241 .field("tools", &names)
242 .finish()
243 }
244}
245
246impl Default for ToolRegistry {
247 fn default() -> Self {
248 Self {
249 tools: Vec::new(),
250 discovered: Mutex::new(HashSet::new()),
251 }
252 }
253}
254
255impl ToolRegistry {
256 pub(crate) fn register(&mut self, tool: impl ToolLike + 'static) {
257 self.tools.push(Arc::new(tool));
258 }
259
260 pub(crate) fn get(&self, name: &str) -> Option<Arc<dyn ToolLike>> {
261 let name = name.trim();
262 self.tools.iter().find(|t| t.name() == name).cloned()
263 }
264
265 pub(crate) fn mark_discovered(&self, name: &str) {
266 self.discovered.lock().unwrap().insert(name.to_string());
267 }
268
269 pub(crate) fn definitions(&self) -> Vec<ProviderToolDefinition> {
273 let discovered = self.discovered.lock().unwrap();
274 self.tools
275 .iter()
276 .map(|t| {
277 if t.should_defer() && !discovered.contains(t.name()) {
278 ProviderToolDefinition {
279 name: t.name().to_string(),
280 description: String::new(),
281 input_schema: serde_json::json!({}),
282 }
283 } else {
284 ProviderToolDefinition {
285 name: t.name().to_string(),
286 description: t.description().to_string(),
287 input_schema: t.input_schema(),
288 }
289 }
290 })
291 .collect()
292 }
293
294 pub(crate) fn search(&self, query: &str) -> Vec<ProviderToolDefinition> {
297 let query_lower = query.to_lowercase();
298 let mut scored: Vec<(ProviderToolDefinition, u32)> = self
299 .tools
300 .iter()
301 .filter_map(|t| {
302 let mut score = 0u32;
303 let name = t.name().to_lowercase();
304 let desc = t.description().to_lowercase();
305
306 if name == query_lower {
307 score += 100;
308 } else if name.contains(&query_lower) {
309 score += 50;
310 }
311
312 if desc.contains(&query_lower) {
313 score += 25;
314 }
315
316 if score > 0 {
317 Some((
318 ProviderToolDefinition {
319 name: t.name().to_string(),
320 description: t.description().to_string(),
321 input_schema: t.input_schema(),
322 },
323 score,
324 ))
325 } else {
326 None
327 }
328 })
329 .collect();
330
331 scored.sort_by(|a, b| b.1.cmp(&a.1));
332 scored.into_iter().map(|(def, _)| def).collect()
333 }
334
335 pub(crate) async fn execute(
342 &self,
343 calls: &[ToolCall],
344 ctx: &ToolContext,
345 ) -> Vec<(
346 ContentBlock,
347 std::result::Result<String, ToolError>,
348 Option<PathBuf>,
349 )> {
350 let batches = partition_tool_calls(calls, self);
351 let mut results: Vec<(
352 ContentBlock,
353 std::result::Result<String, ToolError>,
354 Option<PathBuf>,
355 )> = Vec::new();
356 let semaphore = Arc::new(tokio::sync::Semaphore::new(10));
357
358 for batch in batches {
359 match batch {
360 ToolBatch::Concurrent(calls) => {
361 let mut set = tokio::task::JoinSet::new();
362 for call in calls {
363 let sem = semaphore.clone();
364 let ctx = ctx.clone();
365 let tool_arc = self.get(&call.name);
366 let call_id = call.id.clone();
367 let call_name = call.name.clone();
368 let input = call.input.clone();
369
370 set.spawn(async move {
371 let _permit = sem.acquire().await.unwrap();
372 let outcome = invoke(tool_arc, &call_name, input, &ctx).await;
373 let outcome = replace_empty_output(outcome, &call_name);
374 let (outcome, path) =
375 cap_oversized_result(outcome, &ctx, &call_id, PER_TOOL_CAP);
376 (call_id, outcome, path)
377 });
378 }
379
380 while let Some(join_result) = set.join_next().await {
381 if let Ok((id, outcome, path)) = join_result {
382 let block = content_block_for(&id, &outcome);
383 results.push((block, outcome, path));
384 }
385 }
386 }
387 ToolBatch::Serial(call) => {
388 let outcome =
389 invoke(self.get(&call.name), &call.name, call.input.clone(), ctx).await;
390 let outcome = replace_empty_output(outcome, &call.name);
391 let (outcome, path) =
392 cap_oversized_result(outcome, ctx, &call.id, PER_TOOL_CAP);
393 let block = content_block_for(&call.id, &outcome);
394 results.push((block, outcome, path));
395 }
396 }
397 }
398
399 cap_aggregate_outputs(&mut results, ctx, PER_TURN_CAP);
400
401 results
402 }
403}
404
405impl Clone for ToolRegistry {
406 fn clone(&self) -> Self {
407 Self {
408 tools: self.tools.clone(),
409 discovered: Mutex::new(HashSet::new()),
410 }
411 }
412}
413
414type ToolHandler = Box<
415 dyn Fn(
416 Value,
417 &ToolContext,
418 ) -> Pin<Box<dyn Future<Output = ProviderResult<ToolResult>> + Send + '_>>
419 + Send
420 + Sync,
421>;
422
423pub struct Tool {
438 name: String,
439 description: String,
440 schema: Value,
441 read_only: bool,
442 defer: bool,
443 handler: Option<ToolHandler>,
444}
445
446impl Tool {
447 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
450 Self {
451 name: name.into(),
452 description: description.into(),
453 schema: serde_json::json!({"type": "object", "properties": {}}),
454 read_only: false,
455 defer: false,
456 handler: None,
457 }
458 }
459
460 pub fn from_tool_file(json: &str) -> Self {
465 let tf = super::tool_file::ToolFile::parse(json);
466 Tool::new(tf.name.clone(), tf.render_markdown())
467 .schema(tf.input_schema.clone())
468 .read_only(tf.read_only)
469 }
470
471 pub fn schema(mut self, schema: Value) -> Self {
474 self.schema = schema;
475 self
476 }
477
478 pub fn read_only(mut self, read_only: bool) -> Self {
481 self.read_only = read_only;
482 self
483 }
484
485 pub fn defer(mut self, defer: bool) -> Self {
488 self.defer = defer;
489 self
490 }
491
492 pub fn handler<F, Fut>(mut self, f: F) -> Self
497 where
498 F: Fn(Value, ToolContext) -> Fut + Send + Sync + 'static,
499 Fut: Future<Output = ProviderResult<ToolResult>> + Send + 'static,
500 {
501 self.handler = Some(Box::new(move |v, c| Box::pin(f(v, c.clone()))));
502 self
503 }
504}
505
506impl ToolLike for Tool {
507 fn name(&self) -> &str {
508 &self.name
509 }
510
511 fn description(&self) -> &str {
512 &self.description
513 }
514
515 fn input_schema(&self) -> Value {
516 self.schema.clone()
517 }
518
519 fn is_read_only(&self) -> bool {
520 self.read_only
521 }
522
523 fn should_defer(&self) -> bool {
524 self.defer
525 }
526
527 fn call<'a>(
528 &'a self,
529 input: Value,
530 ctx: &'a ToolContext,
531 ) -> Pin<Box<dyn Future<Output = ProviderResult<ToolResult>> + Send + 'a>> {
532 let handler = self
533 .handler
534 .as_ref()
535 .expect("Tool requires a handler — set one via `.handler(...)` before use");
536 (handler)(input, ctx)
537 }
538}
539
540async fn invoke(
541 tool: Option<Arc<dyn ToolLike>>,
542 name: &str,
543 input: Value,
544 ctx: &ToolContext,
545) -> std::result::Result<String, ToolError> {
546 let Some(t) = tool else {
547 return Err(ToolError::ToolNotFound {
548 tool_name: name.into(),
549 });
550 };
551 match t.call(input, ctx).await {
552 Ok(ToolResult::Success(s)) => Ok(s),
553 Ok(ToolResult::Error(s)) => Err(ToolError::ExecutionFailed {
554 tool_name: name.into(),
555 message: s,
556 }),
557 Ok(ToolResult::SchemaError(s)) => Err(ToolError::SchemaValidationFailed {
558 tool_name: name.into(),
559 message: s,
560 }),
561 Err(e) => Err(ToolError::ExecutionFailed {
562 tool_name: name.into(),
563 message: e.to_string(),
564 }),
565 }
566}
567
568fn replace_empty_output(
573 outcome: std::result::Result<String, ToolError>,
574 tool_name: &str,
575) -> std::result::Result<String, ToolError> {
576 match outcome {
577 Ok(s) if s.is_empty() => Ok(format!("({tool_name} completed with no output)")),
578 other => other,
579 }
580}
581
582fn content_block_for(
583 tool_use_id: &str,
584 outcome: &std::result::Result<String, ToolError>,
585) -> ContentBlock {
586 let (content, succeeded) = match outcome {
587 Ok(s) => (s.clone(), true),
588 Err(e) => (e.message(), false),
589 };
590 ContentBlock::ToolResult {
591 tool_use_id: tool_use_id.to_string(),
592 content,
593 succeeded,
594 }
595}
596
597enum ToolBatch {
598 Concurrent(Vec<ToolCall>),
599 Serial(ToolCall),
600}
601
602fn partition_tool_calls(calls: &[ToolCall], registry: &ToolRegistry) -> Vec<ToolBatch> {
603 let mut batches: Vec<ToolBatch> = Vec::new();
604 let mut concurrent_batch: Vec<ToolCall> = Vec::new();
605
606 for call in calls {
607 let is_read_only = registry.get(&call.name).is_some_and(|t| t.is_read_only());
608
609 if is_read_only {
610 concurrent_batch.push(call.clone());
611 } else {
612 if !concurrent_batch.is_empty() {
613 batches.push(ToolBatch::Concurrent(std::mem::take(&mut concurrent_batch)));
614 }
615 batches.push(ToolBatch::Serial(call.clone()));
616 }
617 }
618
619 if !concurrent_batch.is_empty() {
620 batches.push(ToolBatch::Concurrent(concurrent_batch));
621 }
622
623 batches
624}
625
626fn cap_oversized_result(
634 outcome: std::result::Result<String, ToolError>,
635 ctx: &ToolContext,
636 tool_use_id: &str,
637 per_tool_cap: usize,
638) -> (std::result::Result<String, ToolError>, Option<PathBuf>) {
639 match outcome {
640 Err(e) => (Err(e), None),
641 Ok(content) if content.len() <= per_tool_cap => (Ok(content), None),
642 Ok(content) => match persist_output(ctx, tool_use_id, &content) {
643 None => (Ok(content), None),
644 Some(p) => {
645 let preview = truncate_preview(&content);
646 let stub = format_oversized_tool_result(content.len(), &p.display, preview);
647 (Ok(stub), Some(p.rel))
648 }
649 },
650 }
651}
652
653fn cap_aggregate_outputs(
659 results: &mut [(
660 ContentBlock,
661 std::result::Result<String, ToolError>,
662 Option<PathBuf>,
663 )],
664 ctx: &ToolContext,
665 per_turn_cap: usize,
666) {
667 loop {
668 let total: usize = results
669 .iter()
670 .map(|(b, _, _)| match b {
671 ContentBlock::ToolResult { content, .. } => content.len(),
672 _ => 0,
673 })
674 .sum();
675 if total <= per_turn_cap {
676 return;
677 }
678 let mut largest: Option<(usize, usize)> = None;
679 for (i, (b, _, _)) in results.iter().enumerate() {
680 if let ContentBlock::ToolResult { content, .. } = b {
681 if content.starts_with(OVERSIZED_STUB_TAG_OPEN) {
682 continue;
683 }
684 let len = content.len();
685 if largest.is_none_or(|(_, max_len)| len > max_len) {
686 largest = Some((i, len));
687 }
688 }
689 }
690 let Some((i, _)) = largest else {
691 return;
692 };
693 let ContentBlock::ToolResult {
694 tool_use_id,
695 content,
696 succeeded,
697 } = &results[i].0
698 else {
699 return;
700 };
701 let tool_use_id = tool_use_id.clone();
702 let original = content.clone();
703 let succeeded = *succeeded;
704 let Some(p) = persist_output(ctx, &tool_use_id, &original) else {
705 return;
707 };
708 let preview = truncate_preview(&original);
709 let stub = format_oversized_tool_result(original.len(), &p.display, preview);
710 results[i].0 = ContentBlock::ToolResult {
711 tool_use_id,
712 content: stub.clone(),
713 succeeded,
714 };
715 if results[i].1.is_ok() {
716 results[i].1 = Ok(stub);
717 }
718 results[i].2 = Some(p.rel);
719 }
720}
721
722fn persist_output(ctx: &ToolContext, tool_use_id: &str, content: &str) -> Option<PersistedOutput> {
729 let system = ctx.ticket_system.as_ref()?;
730 let key = ctx.ticket_key.as_deref()?;
731 let rel = system.write_tool_output(key, tool_use_id, content)?;
732 let display = system.dir_value().join(&rel);
733 Some(PersistedOutput { rel, display })
734}
735
736struct PersistedOutput {
737 rel: PathBuf,
738 display: PathBuf,
739}
740
741const OVERSIZED_STUB_TAG_OPEN: &str = "<persisted-output>";
742const OVERSIZED_STUB_TAG_CLOSE: &str = "</persisted-output>";
743
744fn format_oversized_tool_result(original_len: usize, path: &Path, preview: &str) -> String {
748 let size = format_bytes(original_len);
749 let preview_size = format_bytes(preview.len());
750 format!(
751 "{OVERSIZED_STUB_TAG_OPEN}Output too large ({size}). Full output saved to: {path}\n\
752 Preview (first {preview_size}):\n\
753 {preview}\n\
754 {OVERSIZED_STUB_TAG_CLOSE}",
755 path = path.display(),
756 )
757}
758
759fn truncate_preview(content: &str) -> &str {
763 let window = PREVIEW_CHARS.min(content.len());
764 let cut = content[..window]
765 .rfind('\n')
766 .map(|i| i + 1)
767 .unwrap_or_else(|| utf8_boundary_floor(content, window));
768 &content[..cut]
769}
770
771fn format_bytes(n: usize) -> String {
772 const KB: f64 = 1024.0;
773 const MB: f64 = 1024.0 * 1024.0;
774 if n < 1024 {
775 format!("{n} B")
776 } else if (n as f64) < MB {
777 format!("{:.1} KB", n as f64 / KB)
778 } else {
779 format!("{:.1} MB", n as f64 / MB)
780 }
781}
782
783fn utf8_boundary_floor(s: &str, mut i: usize) -> usize {
787 while i > 0 && !s.is_char_boundary(i) {
788 i -= 1;
789 }
790 i
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 struct MockTool {
799 name: String,
800 read_only: bool,
801 result: String,
802 }
803
804 impl MockTool {
805 fn new(name: &str, read_only: bool, result: &str) -> Self {
806 Self {
807 name: name.into(),
808 read_only,
809 result: result.into(),
810 }
811 }
812 }
813
814 impl ToolLike for MockTool {
815 fn name(&self) -> &str {
816 &self.name
817 }
818 fn description(&self) -> &str {
819 "mock"
820 }
821 fn input_schema(&self) -> Value {
822 serde_json::json!({"type": "object"})
823 }
824 fn is_read_only(&self) -> bool {
825 self.read_only
826 }
827 fn call<'a>(
828 &'a self,
829 _input: Value,
830 _ctx: &'a ToolContext,
831 ) -> Pin<Box<dyn Future<Output = ProviderResult<ToolResult>> + Send + 'a>> {
832 let result = self.result.clone();
833 Box::pin(async move { Ok(ToolResult::success(result)) })
834 }
835 }
836
837 struct DeferredMockTool {
838 name: String,
839 }
840
841 impl DeferredMockTool {
842 fn new(name: &str) -> Self {
843 Self { name: name.into() }
844 }
845 }
846
847 impl ToolLike for DeferredMockTool {
848 fn name(&self) -> &str {
849 &self.name
850 }
851 fn description(&self) -> &str {
852 "deferred mock"
853 }
854 fn input_schema(&self) -> Value {
855 serde_json::json!({"type": "object"})
856 }
857 fn should_defer(&self) -> bool {
858 true
859 }
860 fn call<'a>(
861 &'a self,
862 _input: Value,
863 _ctx: &'a ToolContext,
864 ) -> Pin<Box<dyn Future<Output = ProviderResult<ToolResult>> + Send + 'a>> {
865 Box::pin(async { Ok(ToolResult::success("ok")) })
866 }
867 }
868
869 fn test_ctx() -> ToolContext {
870 ToolContext::new(std::env::current_dir().unwrap())
871 }
872
873 #[test]
874 fn registry_register_and_get() {
875 let mut registry = ToolRegistry::default();
876 registry.register(MockTool::new("read_file", true, "file contents"));
877 assert!(registry.get("read_file").is_some());
878 assert!(registry.get("nonexistent").is_none());
879 }
880
881 #[test]
882 fn from_tool_file_populates_name_description_schema_read_only() {
883 let json = r#"{
884 "name": "demo_tool",
885 "summary": ["Do the demo thing."],
886 "constraints": ["Returns nothing useful."],
887 "read_only": true,
888 "input_schema": {
889 "type": "object",
890 "properties": {"x": {"type": "string"}},
891 "required": ["x"]
892 }
893 }"#;
894 let tool = Tool::from_tool_file(json);
895 assert_eq!(tool.name(), "demo_tool");
896 assert!(tool.description().contains("Do the demo thing."));
897 assert!(tool.description().contains("- Returns nothing useful."));
898 assert!(tool.is_read_only());
899 let schema = tool.input_schema();
900 assert_eq!(schema["properties"]["x"]["type"], "string");
901 assert_eq!(schema["required"][0], "x");
902 }
903
904 #[test]
905 fn registry_definitions() {
906 let mut registry = ToolRegistry::default();
907 registry.register(MockTool::new("read", true, "ok"));
908 registry.register(MockTool::new("write", false, "ok"));
909
910 let defs = registry.definitions();
911 assert_eq!(defs.len(), 2);
912 assert_eq!(defs[0].name, "read");
913 assert_eq!(defs[1].name, "write");
914 }
915
916 #[test]
917 fn registry_definitions_deferred() {
918 let mut registry = ToolRegistry::default();
919 registry.register(MockTool::new("always_visible", true, "ok"));
920 registry.register(DeferredMockTool::new("deferred_tool"));
921
922 let defs = registry.definitions();
923 assert_eq!(defs.len(), 2);
924 let deferred = defs.iter().find(|d| d.name == "deferred_tool").unwrap();
925 assert!(deferred.description.is_empty());
926 assert_eq!(deferred.input_schema, serde_json::json!({}));
927
928 registry.mark_discovered("deferred_tool");
929 let defs = registry.definitions();
930 let deferred = defs.iter().find(|d| d.name == "deferred_tool").unwrap();
931 assert!(!deferred.description.is_empty());
932 }
933
934 #[test]
935 fn registry_search_by_name() {
936 let mut registry = ToolRegistry::default();
937 registry.register(MockTool::new("read_file", true, "ok"));
938 registry.register(MockTool::new("write_file", false, "ok"));
939
940 let results = registry.search("read");
941 assert_eq!(results.len(), 1);
942 assert_eq!(results[0].name, "read_file");
943 }
944
945 #[test]
946 fn registry_clone() {
947 let mut registry = ToolRegistry::default();
948 registry.register(MockTool::new("t", true, "ok"));
949 let cloned = registry.clone();
950 assert_eq!(cloned.definitions().len(), 1);
951 }
952
953 #[tokio::test]
954 async fn execute_unknown_tool_returns_error() {
955 let registry = ToolRegistry::default();
956 let ctx = test_ctx();
957 let calls = vec![ToolCall {
958 id: "c1".into(),
959 name: "nonexistent".into(),
960 input: serde_json::json!({}),
961 }];
962
963 let results = registry.execute(&calls, &ctx).await;
964 assert_eq!(results.len(), 1);
965 match &results[0].0 {
966 ContentBlock::ToolResult {
967 succeeded, content, ..
968 } => {
969 assert!(!succeeded);
970 assert!(content.contains("Unknown tool"));
971 }
972 other => panic!("Expected ToolResult, got {other:?}"),
973 }
974 }
975
976 #[tokio::test]
977 async fn execute_read_only_tools_concurrently() {
978 let mut registry = ToolRegistry::default();
979 registry.register(MockTool::new("read1", true, "result1"));
980 registry.register(MockTool::new("read2", true, "result2"));
981 let ctx = test_ctx();
982
983 let calls = vec![
984 ToolCall {
985 id: "c1".into(),
986 name: "read1".into(),
987 input: serde_json::json!({}),
988 },
989 ToolCall {
990 id: "c2".into(),
991 name: "read2".into(),
992 input: serde_json::json!({}),
993 },
994 ];
995
996 let results = registry.execute(&calls, &ctx).await;
997 assert_eq!(results.len(), 2);
998 }
999
1000 #[tokio::test]
1001 async fn execute_serial_tool() {
1002 let mut registry = ToolRegistry::default();
1003 registry.register(MockTool::new("write_file", false, "written"));
1004 let ctx = test_ctx();
1005
1006 let calls = vec![ToolCall {
1007 id: "c1".into(),
1008 name: "write_file".into(),
1009 input: serde_json::json!({"path": "/tmp/test"}),
1010 }];
1011
1012 let results = registry.execute(&calls, &ctx).await;
1013 assert_eq!(results.len(), 1);
1014 match &results[0].0 {
1015 ContentBlock::ToolResult {
1016 content, succeeded, ..
1017 } => {
1018 assert!(succeeded);
1019 assert_eq!(content, "written");
1020 }
1021 other => panic!("Expected ToolResult, got {other:?}"),
1022 }
1023 }
1024
1025 #[test]
1026 fn tool_basic() {
1027 let tool = Tool::new("echo", "Echoes input")
1028 .schema(
1029 serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}),
1030 )
1031 .read_only(true)
1032 .handler(|input, _ctx| async move {
1033 let text = input["text"].as_str().unwrap_or("").to_string();
1034 Ok(ToolResult::success(text))
1035 });
1036
1037 assert_eq!(tool.name(), "echo");
1038 assert!(tool.is_read_only());
1039 }
1040
1041 #[test]
1042 fn tool_defer_builder() {
1043 let tool = Tool::new("advanced", "Advanced tool")
1044 .defer(true)
1045 .handler(|_input, _ctx| async { Ok(ToolResult::success("ok")) });
1046
1047 assert!(tool.should_defer());
1048 }
1049
1050 #[tokio::test]
1051 #[should_panic(expected = "requires a handler")]
1052 async fn tool_panics_without_handler() {
1053 let tool = Tool::new("no_handler", "missing");
1054 let ctx = test_ctx();
1055 let _ = tool.call(serde_json::json!({}), &ctx).await;
1056 }
1057
1058 fn ticket_ctx() -> (
1061 ToolContext,
1062 Arc<TicketSystem>,
1063 String,
1064 crate::test_util::TempDir,
1065 ) {
1066 let dir = crate::test_util::TempDir::new().unwrap();
1067 let system = TicketSystem::new();
1068 system.dir(dir.path().to_path_buf());
1069 system.task("seed");
1070 let key = "TICKET-1".to_string();
1071 let ctx = test_ctx()
1072 .ticket_system(Arc::clone(&system))
1073 .ticket_key(key.clone());
1074 (ctx, system, key, dir)
1075 }
1076
1077 fn tool_result_block(id: &str, content: &str) -> ContentBlock {
1078 ContentBlock::ToolResult {
1079 tool_use_id: id.into(),
1080 content: content.into(),
1081 succeeded: true,
1082 }
1083 }
1084
1085 fn relative_outputs_path(key: &str, tool_use_id: &str) -> PathBuf {
1086 PathBuf::from("tickets")
1087 .join(key)
1088 .join("outputs")
1089 .join(format!("{tool_use_id}.txt"))
1090 }
1091
1092 fn absolute_outputs_path(dir: &std::path::Path, key: &str, tool_use_id: &str) -> PathBuf {
1093 dir.join(relative_outputs_path(key, tool_use_id))
1094 }
1095
1096 #[test]
1097 fn write_tool_output_stores_relative_path_in_comment() {
1098 let (ctx, _system, key, _dir) = ticket_ctx();
1099 let (_outcome, path) = cap_oversized_result(Ok("z".repeat(500)), &ctx, "call-rel", 100);
1100 let stored = path.expect("offload happened");
1101 assert_eq!(stored, relative_outputs_path(&key, "call-rel"));
1102 assert!(
1103 stored.is_relative(),
1104 "comment path must stay portable: {}",
1105 stored.display()
1106 );
1107 }
1108
1109 #[test]
1110 fn persisted_output_renders_absolute_path_for_model() {
1111 let (ctx, _system, key, dir) = ticket_ctx();
1112 let (outcome, _path) = cap_oversized_result(Ok("y".repeat(500)), &ctx, "call-abs", 100);
1113 let stub = outcome.unwrap();
1114 let absolute = absolute_outputs_path(dir.path(), &key, "call-abs");
1115 assert!(
1116 stub.contains(&absolute.display().to_string()),
1117 "stub must give the model the joinable on-disk path: {stub}"
1118 );
1119 }
1120
1121 #[test]
1122 fn cap_oversized_result_passes_through_under_cap() {
1123 let ctx = test_ctx();
1124 let (outcome, path) = cap_oversized_result(Ok("hello".into()), &ctx, "call-1", 100);
1125 assert_eq!(outcome.unwrap(), "hello");
1126 assert!(path.is_none());
1127 }
1128
1129 #[test]
1130 fn cap_oversized_result_replaces_oversized_ok_with_stub() {
1131 let (ctx, _system, key, dir) = ticket_ctx();
1132 let (outcome, path) = cap_oversized_result(Ok("a".repeat(500)), &ctx, "call-xyz", 100);
1133 let stub = outcome.unwrap();
1134 assert!(stub.starts_with("<persisted-output>"));
1135 assert!(stub.contains("Output too large"));
1136 assert!(stub.contains("Full output saved to:"));
1137 let absolute = absolute_outputs_path(dir.path(), &key, "call-xyz");
1138 assert!(
1139 stub.contains(&absolute.display().to_string()),
1140 "stub must name the absolute path so the model can read the file: {stub}"
1141 );
1142 assert!(stub.contains("Preview (first"));
1143 assert!(stub.ends_with("</persisted-output>"));
1144 let path = path.expect("offload path");
1145 assert_eq!(path, relative_outputs_path(&key, "call-xyz"));
1146 let body = std::fs::read_to_string(&absolute).unwrap();
1147 assert_eq!(body, "a".repeat(500));
1148 }
1149
1150 #[test]
1151 fn cap_oversized_result_passes_through_errs() {
1152 let ctx = test_ctx();
1153 let (outcome, path) = cap_oversized_result(
1154 Err(ToolError::ExecutionFailed {
1155 tool_name: "tool".into(),
1156 message: "boom".into(),
1157 }),
1158 &ctx,
1159 "call-1",
1160 10,
1161 );
1162 assert!(matches!(outcome, Err(ToolError::ExecutionFailed { .. })));
1163 assert!(path.is_none());
1164 }
1165
1166 #[test]
1167 fn cap_oversized_result_returns_raw_when_no_ticket_key() {
1168 let ctx = test_ctx();
1169 let payload = "x".repeat(500);
1170 let (outcome, path) = cap_oversized_result(Ok(payload.clone()), &ctx, "call-1", 100);
1171 assert_eq!(outcome.unwrap(), payload);
1172 assert!(path.is_none(), "no ticket key means no offload");
1173 }
1174
1175 #[test]
1176 fn cap_aggregate_offloads_largest_first() {
1177 let (ctx, _system, key, dir) = ticket_ctx();
1178 let small = "a".repeat(40_000);
1180 let big = "b".repeat(80_000);
1181 let tiny = "c".repeat(30_000);
1182 let mut results = vec![
1183 (tool_result_block("c1", &small), Ok(small.clone()), None),
1184 (tool_result_block("c2", &big), Ok(big.clone()), None),
1185 (tool_result_block("c3", &tiny), Ok(tiny.clone()), None),
1186 ];
1187 cap_aggregate_outputs(&mut results, &ctx, 100_000);
1188 match &results[1].0 {
1190 ContentBlock::ToolResult { content, .. } => {
1191 assert!(content.starts_with("<persisted-output>"));
1192 assert!(content.contains("Full output saved to:"));
1193 }
1194 _ => panic!("expected ToolResult"),
1195 }
1196 let big_path = results[1].2.clone().expect("c2 path recorded");
1197 assert_eq!(big_path, relative_outputs_path(&key, "c2"));
1198 let body = std::fs::read_to_string(absolute_outputs_path(dir.path(), &key, "c2")).unwrap();
1199 assert_eq!(body, big);
1200
1201 assert!(matches!(
1202 &results[0].0,
1203 ContentBlock::ToolResult { content, .. } if content.len() == 40_000
1204 ));
1205 assert!(matches!(
1206 &results[2].0,
1207 ContentBlock::ToolResult { content, .. } if content.len() == 30_000
1208 ));
1209 assert!(results[0].2.is_none());
1210 assert!(results[2].2.is_none());
1211 }
1212
1213 #[test]
1214 fn cap_aggregate_stops_when_only_small_results_remain() {
1215 let (ctx, _system, _key, _dir) = ticket_ctx();
1216 let mut results: Vec<(
1220 ContentBlock,
1221 std::result::Result<String, ToolError>,
1222 Option<PathBuf>,
1223 )> = (0..5)
1224 .map(|i| {
1225 let id = format!("c{i}");
1226 let stub = format!("<persisted-output>already stubbed {i}</persisted-output>");
1227 (tool_result_block(&id, &stub), Ok(stub), None)
1228 })
1229 .collect();
1230 let before: Vec<String> = results
1231 .iter()
1232 .map(|(b, _, _)| match b {
1233 ContentBlock::ToolResult { content, .. } => content.clone(),
1234 _ => String::new(),
1235 })
1236 .collect();
1237 cap_aggregate_outputs(&mut results, &ctx, 10);
1238 let after: Vec<String> = results
1239 .iter()
1240 .map(|(b, _, _)| match b {
1241 ContentBlock::ToolResult { content, .. } => content.clone(),
1242 _ => String::new(),
1243 })
1244 .collect();
1245 assert_eq!(
1246 before, after,
1247 "aggregate must be a no-op when only stubs remain"
1248 );
1249 }
1250
1251 #[test]
1252 fn format_oversized_tool_result_renders_template() {
1253 let path = PathBuf::from("/tmp/agentwerk/tickets/TICKET-1/outputs/call-1.txt");
1254 let stub = format_oversized_tool_result(1_048_576, &path, "preview-body");
1255 assert!(stub.starts_with("<persisted-output>"));
1256 assert!(stub.contains("Output too large (1.0 MB)."));
1257 assert!(stub
1258 .contains("Full output saved to: /tmp/agentwerk/tickets/TICKET-1/outputs/call-1.txt"));
1259 assert!(stub.contains("Preview (first 12 B):"));
1260 assert!(stub.contains("preview-body"));
1261 assert!(stub.ends_with("</persisted-output>"));
1262 }
1263
1264 #[test]
1265 fn truncate_preview_snaps_at_last_newline_in_window() {
1266 let mut content = String::new();
1267 content.push_str(&"a".repeat(1_900));
1270 content.push('\n');
1271 content.push_str(&"b".repeat(500));
1272 let preview = truncate_preview(&content);
1273 assert_eq!(preview.len(), 1_901);
1274 assert!(preview.ends_with('\n'));
1275 }
1276
1277 #[test]
1278 fn truncate_preview_falls_back_to_utf8_boundary_when_no_newline() {
1279 let content = "x".repeat(3_000);
1280 let preview = truncate_preview(&content);
1281 assert_eq!(preview.len(), PREVIEW_CHARS);
1282 assert!(content.is_char_boundary(preview.len()));
1283 }
1284
1285 #[test]
1286 fn replace_empty_output_substitutes_placeholder() {
1287 let outcome: std::result::Result<String, ToolError> = Ok(String::new());
1288 let outcome = replace_empty_output(outcome, "bash");
1289 assert_eq!(outcome.unwrap(), "(bash completed with no output)");
1290 }
1291
1292 #[test]
1293 fn replace_empty_output_passes_non_empty_through() {
1294 let outcome: std::result::Result<String, ToolError> = Ok("hello".into());
1295 let outcome = replace_empty_output(outcome, "bash");
1296 assert_eq!(outcome.unwrap(), "hello");
1297 }
1298
1299 #[test]
1300 fn replace_empty_output_passes_errors_through() {
1301 let outcome: std::result::Result<String, ToolError> = Err(ToolError::ExecutionFailed {
1302 tool_name: "bash".into(),
1303 message: "boom".into(),
1304 });
1305 let outcome = replace_empty_output(outcome, "bash");
1306 assert!(matches!(outcome, Err(ToolError::ExecutionFailed { .. })));
1307 }
1308}