1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::Result;
7use async_trait::async_trait;
8use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
12
13mod outcome;
14mod prepared;
15mod resources;
16
17pub use outcome::{ToolExecutionOutcome, ToolTerminalStatus};
18pub use prepared::PreparedToolCall;
19pub use resources::{ResourceClaim, schedule_non_conflicting};
20
21tokio::task_local! {
22 static TOOL_EXECUTION_LOCK_HELD: ();
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum ToolCapability {
28 ReadOnly,
30 WritesFiles,
32 ExecutesCode,
34 Network,
36 Sandboxable,
38 RequiresApproval,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ApprovalRequirement {
45 #[default]
47 Auto,
48 Suggest,
50 Required,
52}
53
54#[derive(Debug, Clone, thiserror::Error)]
56pub enum ToolError {
57 #[error("Failed to validate input: {message}")]
58 InvalidInput { message: String },
59 #[error("Failed to validate input: missing required field '{field}'")]
60 MissingField { field: String },
61 #[error("Failed to resolve path '{}': path escapes workspace", path.display())]
62 PathEscape { path: PathBuf },
63 #[error("Failed to execute tool: {message}")]
64 ExecutionFailed { message: String },
65 #[error("Failed to execute tool: operation timed out after {seconds}s")]
66 Timeout { seconds: u64 },
67 #[error("Tool execution cancelled: {message}")]
68 Cancelled { message: String },
69 #[error("Failed to locate tool: {message}")]
70 NotAvailable { message: String },
71 #[error("Failed to authorize tool execution: {message}")]
72 PermissionDenied { message: String },
73}
74
75impl ToolError {
76 #[must_use]
77 pub fn invalid_input(msg: impl Into<String>) -> Self {
78 Self::InvalidInput {
79 message: msg.into(),
80 }
81 }
82
83 #[must_use]
84 pub fn missing_field(field: impl Into<String>) -> Self {
85 Self::MissingField {
86 field: field.into(),
87 }
88 }
89
90 #[must_use]
91 pub fn execution_failed(msg: impl Into<String>) -> Self {
92 Self::ExecutionFailed {
93 message: msg.into(),
94 }
95 }
96
97 #[must_use]
98 pub fn cancelled(msg: impl Into<String>) -> Self {
99 Self::Cancelled {
100 message: msg.into(),
101 }
102 }
103
104 #[must_use]
105 pub fn path_escape(path: impl Into<PathBuf>) -> Self {
106 Self::PathEscape { path: path.into() }
107 }
108
109 #[must_use]
110 pub fn not_available(msg: impl Into<String>) -> Self {
111 Self::NotAvailable {
112 message: msg.into(),
113 }
114 }
115
116 #[must_use]
117 pub fn permission_denied(msg: impl Into<String>) -> Self {
118 Self::PermissionDenied {
119 message: msg.into(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ToolResult {
127 pub content: String,
129 pub success: bool,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub metadata: Option<Value>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "type", rename_all = "snake_case")]
139pub enum ToolResultContentBlock {
140 Image { mime_type: String, data: String },
141}
142
143impl ToolResult {
144 #[must_use]
146 pub fn success(content: impl Into<String>) -> Self {
147 Self {
148 content: content.into(),
149 success: true,
150 metadata: None,
151 }
152 }
153
154 #[must_use]
156 pub fn error(message: impl Into<String>) -> Self {
157 Self {
158 content: message.into(),
159 success: false,
160 metadata: None,
161 }
162 }
163
164 pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
166 Ok(Self {
167 content: serde_json::to_string(value)?,
168 success: true,
169 metadata: None,
170 })
171 }
172
173 #[must_use]
175 pub fn with_metadata(mut self, metadata: Value) -> Self {
176 self.metadata = Some(metadata);
177 self
178 }
179}
180
181#[must_use]
183pub fn json_type_name(value: &Value) -> &'static str {
184 match value {
185 Value::Null => "null",
186 Value::Bool(_) => "boolean",
187 Value::Number(_) => "number",
188 Value::String(_) => "string",
189 Value::Array(_) => "array",
190 Value::Object(_) => "object",
191 }
192}
193
194#[must_use]
197pub fn value_preview(value: &Value) -> String {
198 let preview = value.to_string();
199 if preview.chars().count() > 120 {
200 preview.chars().take(117).collect::<String>() + "..."
201 } else {
202 preview
203 }
204}
205
206#[must_use]
212pub fn type_mismatch(field: &str, value: &Value, expected: &str) -> ToolError {
213 ToolError::invalid_input(format!(
214 "field '{field}' must be {expected}; got {}. Received: {}",
215 json_type_name(value),
216 value_preview(value)
217 ))
218}
219
220fn is_absent(value: Option<&Value>) -> bool {
228 matches!(value, None | Some(Value::Null))
229}
230
231pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> {
233 if let Some(value) = input.get(field) {
234 if let Some(string_value) = value.as_str() {
235 return Ok(string_value);
236 }
237
238 return Err(type_mismatch(field, value, "a string"));
239 }
240
241 let provided: Vec<&str> = input
244 .as_object()
245 .map(|obj| obj.keys().map(|k| k.as_str()).collect())
246 .unwrap_or_default();
247 if provided.is_empty() {
248 Err(ToolError::missing_field(field))
249 } else {
250 let hint = format!(
251 "missing required field '{field}'. Input provided: {}",
252 provided.join(", ")
253 );
254 Err(ToolError::invalid_input(hint))
255 }
256}
257
258pub fn optional_str<'a>(
263 input: &'a Value,
264 field: &str,
265) -> std::result::Result<Option<&'a str>, ToolError> {
266 let value = input.get(field);
267 if is_absent(value) {
268 return Ok(None);
269 }
270 let value = value.expect("is_absent covers the None case");
271 value
272 .as_str()
273 .map(Some)
274 .ok_or_else(|| type_mismatch(field, value, "a string"))
275}
276
277pub fn required_u64(input: &Value, field: &str) -> std::result::Result<u64, ToolError> {
284 let value = input.get(field);
285 if is_absent(value) {
286 return Err(ToolError::missing_field(field));
287 }
288 let value = value.expect("is_absent covers the None case");
289 value
290 .as_u64()
291 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
292}
293
294pub fn optional_u64(
298 input: &Value,
299 field: &str,
300 default: u64,
301) -> std::result::Result<u64, ToolError> {
302 let value = input.get(field);
303 if is_absent(value) {
304 return Ok(default);
305 }
306 let value = value.expect("is_absent covers the None case");
307 value
308 .as_u64()
309 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
310}
311
312pub fn optional_bool(
320 input: &Value,
321 field: &str,
322 default: bool,
323) -> std::result::Result<bool, ToolError> {
324 Ok(optional_bool_opt(input, field)?.unwrap_or(default))
325}
326
327pub fn optional_bool_opt(
334 input: &Value,
335 field: &str,
336) -> std::result::Result<Option<bool>, ToolError> {
337 let value = input.get(field);
338 if is_absent(value) {
339 return Ok(None);
340 }
341 let value = value.expect("is_absent covers the None case");
342 value
343 .as_bool()
344 .map(Some)
345 .ok_or_else(|| type_mismatch(field, value, "a boolean"))
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ToolDescriptor {
354 pub name: String,
356 pub input_schema: Value,
358 pub output_schema: Value,
360 pub supports_parallel_tool_calls: bool,
362 pub timeout_ms: Option<u64>,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ConfiguredToolDescriptor {
372 pub spec: ToolDescriptor,
374 pub supports_parallel_tool_calls: bool,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum ToolCallSource {
382 Direct,
384 JsRepl,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ToolCall {
394 pub name: String,
396 pub payload: ToolPayload,
398 pub source: ToolCallSource,
400 pub raw_tool_call_id: Option<String>,
402}
403
404impl ToolCall {
405 pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
412 match &self.payload {
413 ToolPayload::LocalShell { params } => (
414 params.command.clone(),
415 params
416 .cwd
417 .clone()
418 .unwrap_or_else(|| fallback_cwd.to_string()),
419 "shell",
420 ),
421 _ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
422 }
423 }
424}
425
426#[derive(Debug, Clone)]
431pub struct ToolInvocation {
432 pub call_id: String,
434 pub tool_name: String,
436 pub payload: ToolPayload,
438 pub source: ToolCallSource,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
450pub enum FunctionCallError {
451 ToolNotFound { name: String },
453 KindMismatch { expected: ToolKind, got: ToolKind },
455 MutatingToolRejected { name: String },
457 TimedOut { name: String, timeout_ms: u64 },
459 Cancelled { name: String },
461 ExecutionFailed { name: String, error: String },
463}
464
465#[async_trait]
470pub trait ToolHandler: Send + Sync {
471 fn kind(&self) -> ToolKind;
473
474 fn matches_kind(&self, kind: ToolKind) -> bool {
478 self.kind() == kind
479 }
480
481 fn is_mutating(&self) -> bool {
485 false
486 }
487
488 async fn handle(
490 &self,
491 invocation: ToolInvocation,
492 ) -> std::result::Result<ToolOutput, FunctionCallError>;
493}
494
495#[derive(Debug)]
501pub struct ToolCallRuntime {
502 execution_lock: Arc<RwLock<()>>,
503}
504
505impl Default for ToolCallRuntime {
506 fn default() -> Self {
507 Self {
508 execution_lock: Arc::new(RwLock::new(())),
509 }
510 }
511}
512
513#[derive(Debug)]
514enum ToolExecutionGuard {
515 Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>),
516 Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>),
517 Reentrant,
518}
519
520impl ToolCallRuntime {
521 async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard {
522 if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() {
523 return ToolExecutionGuard::Reentrant;
524 }
525
526 if supports_parallel {
527 ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await)
528 } else {
529 ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await)
530 }
531 }
532}
533
534#[derive(Default)]
540pub struct ToolRegistry {
541 handlers: HashMap<String, Arc<dyn ToolHandler>>,
542 specs: HashMap<String, ConfiguredToolDescriptor>,
543 runtime: ToolCallRuntime,
544}
545
546impl ToolRegistry {
547 pub fn register(&mut self, spec: ToolDescriptor, handler: Arc<dyn ToolHandler>) -> Result<()> {
553 let name = spec.name.clone();
554 self.specs.insert(
555 name.clone(),
556 ConfiguredToolDescriptor {
557 supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
558 spec,
559 },
560 );
561 self.handlers.insert(name, handler);
562 Ok(())
563 }
564
565 pub fn list_specs(&self) -> Vec<ConfiguredToolDescriptor> {
567 self.specs.values().cloned().collect()
568 }
569
570 pub async fn dispatch(
578 &self,
579 call: ToolCall,
580 allow_mutating: bool,
581 ) -> std::result::Result<ToolOutput, FunctionCallError> {
582 let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
583 FunctionCallError::ToolNotFound {
584 name: call.name.clone(),
585 }
586 })?;
587 let configured =
588 self.specs
589 .get(&call.name)
590 .cloned()
591 .ok_or_else(|| FunctionCallError::ToolNotFound {
592 name: call.name.clone(),
593 })?;
594
595 let payload_kind = tool_payload_kind(&call.payload);
596 let expected = handler.kind();
597 if !handler.matches_kind(payload_kind) {
598 return Err(FunctionCallError::KindMismatch {
599 expected,
600 got: payload_kind,
601 });
602 }
603 if handler.is_mutating() && !allow_mutating {
604 return Err(FunctionCallError::MutatingToolRejected { name: call.name });
605 }
606
607 let invocation = ToolInvocation {
608 call_id: call
609 .raw_tool_call_id
610 .clone()
611 .unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
612 tool_name: call.name.clone(),
613 payload: call.payload,
614 source: call.source,
615 };
616
617 let _guard = self
618 .runtime
619 .acquire(configured.supports_parallel_tool_calls)
620 .await;
621
622 TOOL_EXECUTION_LOCK_HELD
623 .scope(
624 (),
625 self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation),
626 )
627 .await
628 }
629
630 async fn execute_with_timeout(
631 &self,
632 handler: Arc<dyn ToolHandler>,
633 timeout_ms: Option<u64>,
634 invocation: ToolInvocation,
635 ) -> std::result::Result<ToolOutput, FunctionCallError> {
636 if let Some(timeout_ms) = timeout_ms {
637 let name = invocation.tool_name.clone();
638 match tokio::time::timeout(
639 Duration::from_millis(timeout_ms),
640 handler.handle(invocation),
641 )
642 .await
643 {
644 Ok(result) => result,
645 Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
646 }
647 } else {
648 handler.handle(invocation).await
649 }
650 }
651}
652
653fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
654 match payload {
655 ToolPayload::Mcp { .. } => ToolKind::Mcp,
656 ToolPayload::Function { .. }
657 | ToolPayload::Custom { .. }
658 | ToolPayload::LocalShell { .. } => ToolKind::Function,
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use serde_json::json;
665
666 use super::*;
667
668 #[test]
669 fn tool_result_success_sets_plain_content() {
670 let content = "operation completed successfully";
671 let result = ToolResult::success(content);
672
673 assert!(result.success);
674 assert_eq!(result.content, content);
675 assert!(result.metadata.is_none());
676 }
677
678 #[test]
679 fn tool_result_json_round_trips_content() {
680 let result = ToolResult::json(&json!({"ok": true})).expect("json");
681 assert!(result.success);
682 let content: serde_json::Value =
683 serde_json::from_str(&result.content).expect("content is valid json");
684 assert_eq!(content, json!({"ok": true}));
685 }
686
687 #[test]
688 fn helper_extractors_validate_shape() {
689 let input = json!({"name": "demo", "count": 7, "enabled": true});
690 assert_eq!(required_str(&input, "name").expect("name"), "demo");
691 assert_eq!(optional_str(&input, "name").unwrap(), Some("demo"));
692 assert_eq!(optional_str(&input, "missing").unwrap(), None);
693 assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
694 assert_eq!(optional_u64(&input, "count", 0).unwrap(), 7);
695 assert!(optional_bool(&input, "enabled", false).unwrap());
696 let err = required_u64(&input, "name")
699 .expect_err("a present string is not a missing u64")
700 .to_string();
701 assert!(
702 err.contains("field 'name' must be a non-negative integer"),
703 "{err}"
704 );
705 }
706
707 #[test]
711 fn optional_extractors_refuse_type_mismatches_instead_of_defaulting() {
712 let err = optional_bool(&json!({"dry_run": "true"}), "dry_run", false)
716 .expect_err("a stringy bool must not become the default")
717 .to_string();
718 assert!(err.contains("dry_run"), "{err}");
719 assert!(err.contains("must be a boolean"), "{err}");
720 assert!(err.contains("got string"), "{err}");
721 assert!(err.contains("\"true\""), "{err}");
722
723 for bad in [json!("true"), json!(1), json!(0), json!([]), json!({})] {
724 assert!(
725 optional_bool(&json!({"flag": bad}), "flag", false).is_err(),
726 "optional_bool accepted {bad}"
727 );
728 }
729 for bad in [json!("7"), json!(-1), json!(1.5), json!(true), json!([7])] {
730 assert!(
731 optional_u64(&json!({"n": bad}), "n", 42).is_err(),
732 "optional_u64 accepted {bad}"
733 );
734 }
735 for bad in [json!(7), json!(true), json!(["a"]), json!({"a": 1})] {
736 assert!(
737 optional_str(&json!({"s": bad}), "s").is_err(),
738 "optional_str accepted {bad}"
739 );
740 }
741
742 assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
744 assert_eq!(optional_u64(&json!({"n": null}), "n", 42).unwrap(), 42);
745 assert_eq!(optional_str(&json!({"s": null}), "s").unwrap(), None);
746 }
747
748 #[test]
749 fn type_mismatch_truncates_a_huge_offending_value() {
750 let big = Value::String("x".repeat(500));
751 let err = type_mismatch("body", &big, "a boolean").to_string();
752 assert!(err.contains("body"), "{err}");
753 assert!(err.ends_with("..."), "{err}");
754 assert!(err.chars().count() < 250, "{err}");
755 }
756
757 #[test]
758 fn required_u64_distinguishes_missing_from_type_mismatch() {
759 assert!(matches!(
761 required_u64(&json!({}), "count"),
762 Err(ToolError::MissingField { .. })
763 ));
764 assert!(matches!(
765 required_u64(&json!({"count": null}), "count"),
766 Err(ToolError::MissingField { .. })
767 ));
768
769 assert_eq!(required_u64(&json!({"count": 42}), "count").unwrap(), 42);
771 assert_eq!(
772 required_u64(&json!({"count": u64::MAX}), "count").unwrap(),
773 u64::MAX
774 );
775
776 for value in [json!(-1), json!(2.5), json!("42")] {
779 let err = required_u64(&json!({"count": value}), "count")
780 .expect_err("wrong type must not look missing")
781 .to_string();
782 assert!(
783 err.contains("field 'count' must be a non-negative integer"),
784 "{err}"
785 );
786 }
787 }
788
789 #[test]
790 fn required_str_reports_provided_fields_on_missing_required_field() {
791 let input = json!({"path": "src/lib.rs", "content": "new body"});
792 let err = required_str(&input, "replace").expect_err("replace is missing");
793 let message = err.to_string();
794 assert!(message.contains("missing required field 'replace'"));
795 assert!(message.contains("Input provided:"));
796 assert!(message.contains("path"));
797 assert!(message.contains("content"));
798 }
799
800 #[test]
801 fn required_str_reports_wrong_type_when_field_exists() {
802 let input = json!({"replace": [{"path": "src/lib.rs", "content": "new body"}]});
803 let err = required_str(&input, "replace").expect_err("replace has wrong type");
804 let message = err.to_string();
805 assert!(message.contains("field 'replace' must be a string"));
806 assert!(message.contains("got array"));
807 assert!(message.contains(r#""content":"new body""#));
808 assert!(message.contains(r#""path":"src/lib.rs""#));
809 }
810
811 #[test]
812 fn tool_error_display_matches_legacy_text() {
813 let err = ToolError::missing_field("path");
814 assert_eq!(
815 err.to_string(),
816 "Failed to validate input: missing required field 'path'"
817 );
818 }
819
820 #[test]
821 fn tool_error_missing_field_constructor() {
822 let err = ToolError::missing_field("my_field");
823 assert!(matches!(err, ToolError::MissingField { field } if field == "my_field"));
824 }
825
826 #[test]
827 fn tool_error_not_available_displays_reason() {
828 let err = ToolError::not_available("custom tool not found");
829
830 assert!(matches!(err, ToolError::NotAvailable { .. }));
831 assert_eq!(
832 err.to_string(),
833 "Failed to locate tool: custom tool not found"
834 );
835 }
836
837 #[test]
838 fn tool_error_permission_denied_displays_reason() {
839 let err = ToolError::permission_denied("unauthorized user");
840
841 assert!(matches!(err, ToolError::PermissionDenied { .. }));
842 assert_eq!(
843 err.to_string(),
844 "Failed to authorize tool execution: unauthorized user"
845 );
846 }
847
848 #[test]
849 fn tool_error_execution_failed_displays_reason() {
850 let err = ToolError::execution_failed("process crashed");
851
852 assert!(
853 matches!(err, ToolError::ExecutionFailed { ref message } if message == "process crashed")
854 );
855 assert_eq!(err.to_string(), "Failed to execute tool: process crashed");
856 }
857
858 #[test]
859 fn tool_error_invalid_input_creates_correct_variant() {
860 let err = ToolError::invalid_input("test invalid message");
861 match err {
862 ToolError::InvalidInput { message } => {
863 assert_eq!(message, "test invalid message");
864 }
865 _ => panic!("Expected ToolError::InvalidInput, got {err:?}"),
866 }
867 }
868
869 #[test]
870 fn tool_error_path_escape_display() {
871 let path = std::path::PathBuf::from("../outside");
872 let err = ToolError::path_escape(path);
873 assert_eq!(
874 err.to_string(),
875 "Failed to resolve path '../outside': path escapes workspace"
876 );
877 }
878
879 #[test]
880 fn tool_call_execution_subject_uses_local_shell_command_and_cwd() {
881 let call = ToolCall {
882 name: "shell".to_string(),
883 payload: ToolPayload::LocalShell {
884 params: codewhale_protocol::LocalShellParams {
885 command: "ls -l".to_string(),
886 cwd: Some("/custom/dir".to_string()),
887 timeout_ms: None,
888 },
889 },
890 source: ToolCallSource::Direct,
891 raw_tool_call_id: None,
892 };
893
894 assert_eq!(
895 call.execution_subject("/fallback/dir"),
896 ("ls -l".to_string(), "/custom/dir".to_string(), "shell")
897 );
898 }
899
900 #[test]
901 fn tool_call_execution_subject_falls_back_for_shell_without_cwd() {
902 let call = ToolCall {
903 name: "shell".to_string(),
904 payload: ToolPayload::LocalShell {
905 params: codewhale_protocol::LocalShellParams {
906 command: "echo hello".to_string(),
907 cwd: None,
908 timeout_ms: None,
909 },
910 },
911 source: ToolCallSource::Direct,
912 raw_tool_call_id: None,
913 };
914
915 assert_eq!(
916 call.execution_subject("/fallback/dir"),
917 (
918 "echo hello".to_string(),
919 "/fallback/dir".to_string(),
920 "shell"
921 )
922 );
923 }
924
925 #[test]
926 fn tool_call_execution_subject_uses_tool_name_for_non_shell_payloads() {
927 let call = ToolCall {
928 name: "my_tool".to_string(),
929 payload: ToolPayload::Function {
930 arguments: "{}".to_string(),
931 },
932 source: ToolCallSource::Direct,
933 raw_tool_call_id: None,
934 };
935
936 assert_eq!(
937 call.execution_subject("/fallback/dir"),
938 ("my_tool".to_string(), "/fallback/dir".to_string(), "tool")
939 );
940 }
941}