返回 CodeWhale
lib.rs
根目录 / crates / tools / src / lib.rs
1 use std::collections::HashMap;
2 use std::path::PathBuf;
3 use std::sync::Arc;
4 use std::time::Duration;
5
6 use anyhow::Result;
7 use async_trait::async_trait;
8 use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
9 use serde::{Deserialize, Serialize};
10 use serde_json::Value;
11 use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
12
13 mod outcome;
14 mod prepared;
15 mod resources;
16
17 pub use outcome::{ToolExecutionOutcome, ToolTerminalStatus};
18 pub use prepared::PreparedToolCall;
19 pub use resources::{ResourceClaim, schedule_non_conflicting};
20
21 tokio::task_local! {
22 static TOOL_EXECUTION_LOCK_HELD: ();
23 }
24
25 /// Capabilities that a tool may have or require.
26 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27 pub enum ToolCapability {
28 /// Tool only reads data, never modifies state.
29 ReadOnly,
30 /// Tool writes to the filesystem.
31 WritesFiles,
32 /// Tool executes arbitrary shell commands.
33 ExecutesCode,
34 /// Tool makes network requests.
35 Network,
36 /// Tool can be run in a sandbox.
37 Sandboxable,
38 /// Tool requires user approval before execution.
39 RequiresApproval,
40 }
41
42 /// Approval requirement for a tool.
43 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44 pub enum ApprovalRequirement {
45 /// Never needs approval: safe read-only operations.
46 #[default]
47 Auto,
48 /// Suggest approval but allow user to skip.
49 Suggest,
50 /// Always require explicit user approval.
51 Required,
52 }
53
54 /// Errors that can occur during tool execution.
55 #[derive(Debug, Clone, thiserror::Error)]
56 pub 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
75 impl 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 /// Result of a tool execution.
125 #[derive(Debug, Clone, Serialize, Deserialize)]
126 pub struct ToolResult {
127 /// The output content, which may be JSON or plain text.
128 pub content: String,
129 /// Whether the execution was successful.
130 pub success: bool,
131 /// Optional structured metadata.
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub metadata: Option<Value>,
134 }
135
136 impl ToolResult {
137 /// Create a successful result with content.
138 #[must_use]
139 pub fn success(content: impl Into<String>) -> Self {
140 Self {
141 content: content.into(),
142 success: true,
143 metadata: None,
144 }
145 }
146
147 /// Create an error result with message.
148 #[must_use]
149 pub fn error(message: impl Into<String>) -> Self {
150 Self {
151 content: message.into(),
152 success: false,
153 metadata: None,
154 }
155 }
156
157 /// Create a successful result from JSON.
158 pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
159 Ok(Self {
160 content: serde_json::to_string(value)?,
161 success: true,
162 metadata: None,
163 })
164 }
165
166 /// Add metadata to the result.
167 #[must_use]
168 pub fn with_metadata(mut self, metadata: Value) -> Self {
169 self.metadata = Some(metadata);
170 self
171 }
172 }
173
174 /// Name the JSON type of a value the way a tool schema would spell it.
175 #[must_use]
176 pub fn json_type_name(value: &Value) -> &'static str {
177 match value {
178 Value::Null => "null",
179 Value::Bool(_) => "boolean",
180 Value::Number(_) => "number",
181 Value::String(_) => "string",
182 Value::Array(_) => "array",
183 Value::Object(_) => "object",
184 }
185 }
186
187 /// Render a value for an error message, truncated so a huge payload cannot
188 /// swamp the transcript.
189 #[must_use]
190 pub fn value_preview(value: &Value) -> String {
191 let preview = value.to_string();
192 if preview.chars().count() > 120 {
193 preview.chars().take(117).collect::<String>() + "..."
194 } else {
195 preview
196 }
197 }
198
199 /// The one error every type mismatch on a tool parameter produces.
200 ///
201 /// Names the parameter, the type that arrived, and the type the schema
202 /// declares, plus the offending value — everything the caller needs to fix
203 /// the call on the next turn without another round trip.
204 #[must_use]
205 pub fn type_mismatch(field: &str, value: &Value, expected: &str) -> ToolError {
206 ToolError::invalid_input(format!(
207 "field '{field}' must be {expected}; got {}. Received: {}",
208 json_type_name(value),
209 value_preview(value)
210 ))
211 }
212
213 /// Whether a value counts as "the caller did not supply this field".
214 ///
215 /// JSON `null` is the wire spelling of absence, so an optional field set to
216 /// `null` takes its default rather than erroring. This is the *only*
217 /// tolerance in the optional extractors, and it is uniform across all of
218 /// them: `null` means no value, and no value is exactly what a default is
219 /// for. Every other type mismatch is an error.
220 fn is_absent(value: Option<&Value>) -> bool {
221 matches!(value, None | Some(Value::Null))
222 }
223
224 /// Helper to extract a required string field from JSON input.
225 pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> {
226 if let Some(value) = input.get(field) {
227 if let Some(string_value) = value.as_str() {
228 return Ok(string_value);
229 }
230
231 return Err(type_mismatch(field, value, "a string"));
232 }
233
234 // When the field is missing, list the fields the caller *did*
235 // supply so the model can spot the mismatch without a retry.
236 let provided: Vec<&str> = input
237 .as_object()
238 .map(|obj| obj.keys().map(|k| k.as_str()).collect())
239 .unwrap_or_default();
240 if provided.is_empty() {
241 Err(ToolError::missing_field(field))
242 } else {
243 let hint = format!(
244 "missing required field '{field}'. Input provided: {}",
245 provided.join(", ")
246 );
247 Err(ToolError::invalid_input(hint))
248 }
249 }
250
251 /// Helper to extract an optional string field from JSON input.
252 ///
253 /// A wrong type is an error, never a silent `None`. See [`type_mismatch`]
254 /// for why nothing is coerced.
255 pub fn optional_str<'a>(
256 input: &'a Value,
257 field: &str,
258 ) -> std::result::Result<Option<&'a str>, ToolError> {
259 let value = input.get(field);
260 if is_absent(value) {
261 return Ok(None);
262 }
263 let value = value.expect("is_absent covers the None case");
264 value
265 .as_str()
266 .map(Some)
267 .ok_or_else(|| type_mismatch(field, value, "a string"))
268 }
269
270 /// Helper to extract a required u64 field from JSON input.
271 ///
272 /// Absence (field missing or `null`) is a `missing_field` error; a value
273 /// that is present but not a u64 is a [`type_mismatch`] naming the field and
274 /// the expected type, so the caller fixes the field's type instead of
275 /// re-sending it as missing.
276 pub fn required_u64(input: &Value, field: &str) -> std::result::Result<u64, ToolError> {
277 let value = input.get(field);
278 if is_absent(value) {
279 return Err(ToolError::missing_field(field));
280 }
281 let value = value.expect("is_absent covers the None case");
282 value
283 .as_u64()
284 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
285 }
286
287 /// Helper to extract an optional u64 field with default.
288 ///
289 /// A wrong type is an error, never a silent fall back to `default`.
290 pub fn optional_u64(
291 input: &Value,
292 field: &str,
293 default: u64,
294 ) -> std::result::Result<u64, ToolError> {
295 let value = input.get(field);
296 if is_absent(value) {
297 return Ok(default);
298 }
299 let value = value.expect("is_absent covers the None case");
300 value
301 .as_u64()
302 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
303 }
304
305 /// Helper to extract an optional bool field with default.
306 ///
307 /// A wrong type is an error, never a silent fall back to `default`. In
308 /// particular the string `"true"` is refused rather than coerced: the
309 /// default this used to fall back to is frequently the *opposite* of what
310 /// the caller asked for, and some of those defaults gate irreversible
311 /// actions.
312 pub fn optional_bool(
313 input: &Value,
314 field: &str,
315 default: bool,
316 ) -> std::result::Result<bool, ToolError> {
317 Ok(optional_bool_opt(input, field)?.unwrap_or(default))
318 }
319
320 /// Helper to extract an optional bool that has no default.
321 ///
322 /// `None` means the caller did not supply the field; a wrong type is an
323 /// error. Use this where "unset" is itself meaningful — an authority
324 /// declaration that is dropped instead of read is a restriction that
325 /// silently evaporates.
326 pub fn optional_bool_opt(
327 input: &Value,
328 field: &str,
329 ) -> std::result::Result<Option<bool>, ToolError> {
330 let value = input.get(field);
331 if is_absent(value) {
332 return Ok(None);
333 }
334 let value = value.expect("is_absent covers the None case");
335 value
336 .as_bool()
337 .map(Some)
338 .ok_or_else(|| type_mismatch(field, value, "a boolean"))
339 }
340
341 /// Descriptor that describes a tool available in the registry.
342 ///
343 /// Contains the tool's name, its JSON input/output schemas, and
344 /// execution constraints such as timeout and parallelism.
345 #[derive(Debug, Clone, Serialize, Deserialize)]
346 pub struct ToolDescriptor {
347 /// Unique name used to look up the tool.
348 pub name: String,
349 /// JSON Schema describing the tool's expected input parameters.
350 pub input_schema: Value,
351 /// JSON Schema describing the tool's output format.
352 pub output_schema: Value,
353 /// Whether multiple invocations of this tool may run concurrently.
354 pub supports_parallel_tool_calls: bool,
355 /// Optional per-call timeout in milliseconds; `None` means no timeout.
356 pub timeout_ms: Option<u64>,
357 }
358
359 /// A [`ToolDescriptor`] together with its runtime configuration.
360 ///
361 /// Wraps a `ToolDescriptor` and exposes the parallelism flag directly so the
362 /// dispatcher can check it without digging into the inner spec.
363 #[derive(Debug, Clone, Serialize, Deserialize)]
364 pub struct ConfiguredToolDescriptor {
365 /// The underlying tool descriptor.
366 pub spec: ToolDescriptor,
367 /// Whether this tool supports concurrent invocations.
368 pub supports_parallel_tool_calls: bool,
369 }
370
371 /// Identifies where a tool call originated from.
372 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373 #[serde(rename_all = "snake_case")]
374 pub enum ToolCallSource {
375 /// Direct invocation from the model or user.
376 Direct,
377 /// Invocation through the JavaScript REPL environment.
378 JsRepl,
379 }
380
381 /// A tool invocation request before it has been validated and dispatched.
382 ///
383 /// Contains the tool name, its input payload, and metadata about where the
384 /// call originated.
385 #[derive(Debug, Clone, Serialize, Deserialize)]
386 pub struct ToolCall {
387 /// Name of the tool to invoke.
388 pub name: String,
389 /// The input payload for the tool.
390 pub payload: ToolPayload,
391 /// Where this call originated (direct or REPL).
392 pub source: ToolCallSource,
393 /// Optional raw tool-call identifier from the upstream provider.
394 pub raw_tool_call_id: Option<String>,
395 }
396
397 impl ToolCall {
398 /// Derive the execution subject for this call.
399 ///
400 /// For local shell payloads this returns the shell command and its
401 /// working directory; for all other payloads the tool name and the
402 /// provided `fallback_cwd` are returned instead. The third element
403 /// of the tuple is a human-readable kind label (`"shell"` or `"tool"`).
404 pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
405 match &self.payload {
406 ToolPayload::LocalShell { params } => (
407 params.command.clone(),
408 params
409 .cwd
410 .clone()
411 .unwrap_or_else(|| fallback_cwd.to_string()),
412 "shell",
413 ),
414 _ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
415 }
416 }
417 }
418
419 /// A validated tool invocation ready to be handled.
420 ///
421 /// Created by the registry after a [`ToolCall`] passes validation, this
422 /// carries all the context a [`ToolHandler`] needs to execute the tool.
423 #[derive(Debug, Clone)]
424 pub struct ToolInvocation {
425 /// Unique identifier for this invocation (generated or from the provider).
426 pub call_id: String,
427 /// Name of the tool being invoked.
428 pub tool_name: String,
429 /// The input payload for the tool.
430 pub payload: ToolPayload,
431 /// Where this invocation originated.
432 pub source: ToolCallSource,
433 }
434
435 /// Errors that can occur during tool dispatch and execution.
436 ///
437 /// Unlike [`ToolError`], which represents input validation failures within
438 /// a tool, `FunctionCallError` covers problems at the dispatch layer: the
439 /// tool was not found, its kind did not match, it was rejected because it
440 /// is mutating, it timed out, was cancelled, or its handler returned an
441 /// error.
442 #[derive(Debug, Clone, Serialize, Deserialize)]
443 pub enum FunctionCallError {
444 /// No tool with the given name is registered.
445 ToolNotFound { name: String },
446 /// The payload kind does not match the handler's expected kind.
447 KindMismatch { expected: ToolKind, got: ToolKind },
448 /// The tool is mutating but `allow_mutating` was `false`.
449 MutatingToolRejected { name: String },
450 /// The tool execution exceeded its configured timeout.
451 TimedOut { name: String, timeout_ms: u64 },
452 /// The tool execution was cancelled.
453 Cancelled { name: String },
454 /// The tool handler returned an error.
455 ExecutionFailed { name: String, error: String },
456 }
457
458 /// Trait implemented by concrete tool handlers.
459 ///
460 /// Each registered tool is backed by a handler that reports its kind,
461 /// whether it is mutating, and performs the actual execution.
462 #[async_trait]
463 pub trait ToolHandler: Send + Sync {
464 /// The [`ToolKind`] this handler expects (e.g. `Function` or `Mcp`).
465 fn kind(&self) -> ToolKind;
466
467 /// Returns `true` if `kind` matches this handler's expected kind.
468 ///
469 /// The default implementation compares against [`kind()`](ToolHandler::kind).
470 fn matches_kind(&self, kind: ToolKind) -> bool {
471 self.kind() == kind
472 }
473
474 /// Whether this tool performs side-effects that require user approval.
475 ///
476 /// Defaults to `false` (read-only / safe).
477 fn is_mutating(&self) -> bool {
478 false
479 }
480
481 /// Execute the tool with the given invocation context.
482 async fn handle(
483 &self,
484 invocation: ToolInvocation,
485 ) -> std::result::Result<ToolOutput, FunctionCallError>;
486 }
487
488 /// Manages concurrent tool execution via a read/write lock.
489 ///
490 /// Parallel-safe tools acquire a read lock (allowing overlap), while
491 /// serial tools acquire a write lock (exclusive access). Reentrant calls
492 /// (e.g. a tool invoking another tool) skip locking to avoid deadlock.
493 #[derive(Debug)]
494 pub struct ToolCallRuntime {
495 execution_lock: Arc<RwLock<()>>,
496 }
497
498 impl Default for ToolCallRuntime {
499 fn default() -> Self {
500 Self {
501 execution_lock: Arc::new(RwLock::new(())),
502 }
503 }
504 }
505
506 #[derive(Debug)]
507 enum ToolExecutionGuard {
508 Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>),
509 Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>),
510 Reentrant,
511 }
512
513 impl ToolCallRuntime {
514 async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard {
515 if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() {
516 return ToolExecutionGuard::Reentrant;
517 }
518
519 if supports_parallel {
520 ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await)
521 } else {
522 ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await)
523 }
524 }
525 }
526
527 /// Central registry that maps tool names to their specs and handlers.
528 ///
529 /// Use [`register()`](ToolRegistry::register) to add tools, then
530 /// [`dispatch()`](ToolRegistry::dispatch) to invoke them. The registry
531 /// owns a [`ToolCallRuntime`] that manages concurrent execution.
532 #[derive(Default)]
533 pub struct ToolRegistry {
534 handlers: HashMap<String, Arc<dyn ToolHandler>>,
535 specs: HashMap<String, ConfiguredToolDescriptor>,
536 runtime: ToolCallRuntime,
537 }
538
539 impl ToolRegistry {
540 /// Register a tool with its specification and handler.
541 ///
542 /// The tool's name is taken from `spec.name`. Returns an error if
543 /// registration fails (currently infallible, but the `Result` is
544 /// reserved for future validation).
545 pub fn register(&mut self, spec: ToolDescriptor, handler: Arc<dyn ToolHandler>) -> Result<()> {
546 let name = spec.name.clone();
547 self.specs.insert(
548 name.clone(),
549 ConfiguredToolDescriptor {
550 supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
551 spec,
552 },
553 );
554 self.handlers.insert(name, handler);
555 Ok(())
556 }
557
558 /// Return the configured specs for every registered tool.
559 pub fn list_specs(&self) -> Vec<ConfiguredToolDescriptor> {
560 self.specs.values().cloned().collect()
561 }
562
563 /// Validate and execute a tool call.
564 ///
565 /// Looks up the tool by name, verifies the payload kind matches the
566 /// handler, enforces the `allow_mutating` guard, acquires the
567 /// appropriate execution lock, and forwards the call to the handler.
568 /// Returns a [`FunctionCallError`] if any validation step fails or
569 /// the handler returns an error.
570 pub async fn dispatch(
571 &self,
572 call: ToolCall,
573 allow_mutating: bool,
574 ) -> std::result::Result<ToolOutput, FunctionCallError> {
575 let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
576 FunctionCallError::ToolNotFound {
577 name: call.name.clone(),
578 }
579 })?;
580 let configured =
581 self.specs
582 .get(&call.name)
583 .cloned()
584 .ok_or_else(|| FunctionCallError::ToolNotFound {
585 name: call.name.clone(),
586 })?;
587
588 let payload_kind = tool_payload_kind(&call.payload);
589 let expected = handler.kind();
590 if !handler.matches_kind(payload_kind) {
591 return Err(FunctionCallError::KindMismatch {
592 expected,
593 got: payload_kind,
594 });
595 }
596 if handler.is_mutating() && !allow_mutating {
597 return Err(FunctionCallError::MutatingToolRejected { name: call.name });
598 }
599
600 let invocation = ToolInvocation {
601 call_id: call
602 .raw_tool_call_id
603 .clone()
604 .unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
605 tool_name: call.name.clone(),
606 payload: call.payload,
607 source: call.source,
608 };
609
610 let _guard = self
611 .runtime
612 .acquire(configured.supports_parallel_tool_calls)
613 .await;
614
615 TOOL_EXECUTION_LOCK_HELD
616 .scope(
617 (),
618 self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation),
619 )
620 .await
621 }
622
623 async fn execute_with_timeout(
624 &self,
625 handler: Arc<dyn ToolHandler>,
626 timeout_ms: Option<u64>,
627 invocation: ToolInvocation,
628 ) -> std::result::Result<ToolOutput, FunctionCallError> {
629 if let Some(timeout_ms) = timeout_ms {
630 let name = invocation.tool_name.clone();
631 match tokio::time::timeout(
632 Duration::from_millis(timeout_ms),
633 handler.handle(invocation),
634 )
635 .await
636 {
637 Ok(result) => result,
638 Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
639 }
640 } else {
641 handler.handle(invocation).await
642 }
643 }
644 }
645
646 fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
647 match payload {
648 ToolPayload::Mcp { .. } => ToolKind::Mcp,
649 ToolPayload::Function { .. }
650 | ToolPayload::Custom { .. }
651 | ToolPayload::LocalShell { .. } => ToolKind::Function,
652 }
653 }
654
655 #[cfg(test)]
656 mod tests {
657 use serde_json::json;
658
659 use super::*;
660
661 #[test]
662 fn tool_result_success_sets_plain_content() {
663 let content = "operation completed successfully";
664 let result = ToolResult::success(content);
665
666 assert!(result.success);
667 assert_eq!(result.content, content);
668 assert!(result.metadata.is_none());
669 }
670
671 #[test]
672 fn tool_result_json_round_trips_content() {
673 let result = ToolResult::json(&json!({"ok": true})).expect("json");
674 assert!(result.success);
675 let content: serde_json::Value =
676 serde_json::from_str(&result.content).expect("content is valid json");
677 assert_eq!(content, json!({"ok": true}));
678 }
679
680 #[test]
681 fn helper_extractors_validate_shape() {
682 let input = json!({"name": "demo", "count": 7, "enabled": true});
683 assert_eq!(required_str(&input, "name").expect("name"), "demo");
684 assert_eq!(optional_str(&input, "name").unwrap(), Some("demo"));
685 assert_eq!(optional_str(&input, "missing").unwrap(), None);
686 assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
687 assert_eq!(optional_u64(&input, "count", 0).unwrap(), 7);
688 assert!(optional_bool(&input, "enabled", false).unwrap());
689 // "name" is present but a string: a type mismatch, not a missing
690 // field, so the caller fixes the type instead of re-sending the name.
691 let err = required_u64(&input, "name")
692 .expect_err("a present string is not a missing u64")
693 .to_string();
694 assert!(
695 err.contains("field 'name' must be a non-negative integer"),
696 "{err}"
697 );
698 }
699
700 /// The rule, stated once: an optional parameter of the wrong JSON type is
701 /// an error that names the parameter, what arrived, and what was wanted.
702 /// `null` alone means "absent" and takes the default.
703 #[test]
704 fn optional_extractors_refuse_type_mismatches_instead_of_defaulting() {
705 // The shipping bug: a stringy "true" became the default `false`,
706 // which for `dry_run` is the opposite of what the caller asked and
707 // gates an irreversible action.
708 let err = optional_bool(&json!({"dry_run": "true"}), "dry_run", false)
709 .expect_err("a stringy bool must not become the default")
710 .to_string();
711 assert!(err.contains("dry_run"), "{err}");
712 assert!(err.contains("must be a boolean"), "{err}");
713 assert!(err.contains("got string"), "{err}");
714 assert!(err.contains("\"true\""), "{err}");
715
716 for bad in [json!("true"), json!(1), json!(0), json!([]), json!({})] {
717 assert!(
718 optional_bool(&json!({"flag": bad}), "flag", false).is_err(),
719 "optional_bool accepted {bad}"
720 );
721 }
722 for bad in [json!("7"), json!(-1), json!(1.5), json!(true), json!([7])] {
723 assert!(
724 optional_u64(&json!({"n": bad}), "n", 42).is_err(),
725 "optional_u64 accepted {bad}"
726 );
727 }
728 for bad in [json!(7), json!(true), json!(["a"]), json!({"a": 1})] {
729 assert!(
730 optional_str(&json!({"s": bad}), "s").is_err(),
731 "optional_str accepted {bad}"
732 );
733 }
734
735 // `null` is the wire spelling of absence, uniformly across all three.
736 assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
737 assert_eq!(optional_u64(&json!({"n": null}), "n", 42).unwrap(), 42);
738 assert_eq!(optional_str(&json!({"s": null}), "s").unwrap(), None);
739 }
740
741 #[test]
742 fn type_mismatch_truncates_a_huge_offending_value() {
743 let big = Value::String("x".repeat(500));
744 let err = type_mismatch("body", &big, "a boolean").to_string();
745 assert!(err.contains("body"), "{err}");
746 assert!(err.ends_with("..."), "{err}");
747 assert!(err.chars().count() < 250, "{err}");
748 }
749
750 #[test]
751 fn required_u64_distinguishes_missing_from_type_mismatch() {
752 // Absent (or null) is a missing-field error.
753 assert!(matches!(
754 required_u64(&json!({}), "count"),
755 Err(ToolError::MissingField { .. })
756 ));
757 assert!(matches!(
758 required_u64(&json!({"count": null}), "count"),
759 Err(ToolError::MissingField { .. })
760 ));
761
762 // Present and valid values pass through, including the extremes.
763 assert_eq!(required_u64(&json!({"count": 42}), "count").unwrap(), 42);
764 assert_eq!(
765 required_u64(&json!({"count": u64::MAX}), "count").unwrap(),
766 u64::MAX
767 );
768
769 // Present but wrongly typed is a type mismatch naming the field and
770 // the expected type — never a missing-field misdirection.
771 for value in [json!(-1), json!(2.5), json!("42")] {
772 let err = required_u64(&json!({"count": value}), "count")
773 .expect_err("wrong type must not look missing")
774 .to_string();
775 assert!(
776 err.contains("field 'count' must be a non-negative integer"),
777 "{err}"
778 );
779 }
780 }
781
782 #[test]
783 fn required_str_reports_provided_fields_on_missing_required_field() {
784 let input = json!({"path": "src/lib.rs", "content": "new body"});
785 let err = required_str(&input, "replace").expect_err("replace is missing");
786 let message = err.to_string();
787 assert!(message.contains("missing required field 'replace'"));
788 assert!(message.contains("Input provided:"));
789 assert!(message.contains("path"));
790 assert!(message.contains("content"));
791 }
792
793 #[test]
794 fn required_str_reports_wrong_type_when_field_exists() {
795 let input = json!({"replace": [{"path": "src/lib.rs", "content": "new body"}]});
796 let err = required_str(&input, "replace").expect_err("replace has wrong type");
797 let message = err.to_string();
798 assert!(message.contains("field 'replace' must be a string"));
799 assert!(message.contains("got array"));
800 assert!(message.contains(r#""content":"new body""#));
801 assert!(message.contains(r#""path":"src/lib.rs""#));
802 }
803
804 #[test]
805 fn tool_error_display_matches_legacy_text() {
806 let err = ToolError::missing_field("path");
807 assert_eq!(
808 err.to_string(),
809 "Failed to validate input: missing required field 'path'"
810 );
811 }
812
813 #[test]
814 fn tool_error_missing_field_constructor() {
815 let err = ToolError::missing_field("my_field");
816 assert!(matches!(err, ToolError::MissingField { field } if field == "my_field"));
817 }
818
819 #[test]
820 fn tool_error_not_available_displays_reason() {
821 let err = ToolError::not_available("custom tool not found");
822
823 assert!(matches!(err, ToolError::NotAvailable { .. }));
824 assert_eq!(
825 err.to_string(),
826 "Failed to locate tool: custom tool not found"
827 );
828 }
829
830 #[test]
831 fn tool_error_permission_denied_displays_reason() {
832 let err = ToolError::permission_denied("unauthorized user");
833
834 assert!(matches!(err, ToolError::PermissionDenied { .. }));
835 assert_eq!(
836 err.to_string(),
837 "Failed to authorize tool execution: unauthorized user"
838 );
839 }
840
841 #[test]
842 fn tool_error_execution_failed_displays_reason() {
843 let err = ToolError::execution_failed("process crashed");
844
845 assert!(
846 matches!(err, ToolError::ExecutionFailed { ref message } if message == "process crashed")
847 );
848 assert_eq!(err.to_string(), "Failed to execute tool: process crashed");
849 }
850
851 #[test]
852 fn tool_error_invalid_input_creates_correct_variant() {
853 let err = ToolError::invalid_input("test invalid message");
854 match err {
855 ToolError::InvalidInput { message } => {
856 assert_eq!(message, "test invalid message");
857 }
858 _ => panic!("Expected ToolError::InvalidInput, got {err:?}"),
859 }
860 }
861
862 #[test]
863 fn tool_error_path_escape_display() {
864 let path = std::path::PathBuf::from("../outside");
865 let err = ToolError::path_escape(path);
866 assert_eq!(
867 err.to_string(),
868 "Failed to resolve path '../outside': path escapes workspace"
869 );
870 }
871
872 #[test]
873 fn tool_call_execution_subject_uses_local_shell_command_and_cwd() {
874 let call = ToolCall {
875 name: "shell".to_string(),
876 payload: ToolPayload::LocalShell {
877 params: codewhale_protocol::LocalShellParams {
878 command: "ls -l".to_string(),
879 cwd: Some("/custom/dir".to_string()),
880 timeout_ms: None,
881 },
882 },
883 source: ToolCallSource::Direct,
884 raw_tool_call_id: None,
885 };
886
887 assert_eq!(
888 call.execution_subject("/fallback/dir"),
889 ("ls -l".to_string(), "/custom/dir".to_string(), "shell")
890 );
891 }
892
893 #[test]
894 fn tool_call_execution_subject_falls_back_for_shell_without_cwd() {
895 let call = ToolCall {
896 name: "shell".to_string(),
897 payload: ToolPayload::LocalShell {
898 params: codewhale_protocol::LocalShellParams {
899 command: "echo hello".to_string(),
900 cwd: None,
901 timeout_ms: None,
902 },
903 },
904 source: ToolCallSource::Direct,
905 raw_tool_call_id: None,
906 };
907
908 assert_eq!(
909 call.execution_subject("/fallback/dir"),
910 (
911 "echo hello".to_string(),
912 "/fallback/dir".to_string(),
913 "shell"
914 )
915 );
916 }
917
918 #[test]
919 fn tool_call_execution_subject_uses_tool_name_for_non_shell_payloads() {
920 let call = ToolCall {
921 name: "my_tool".to_string(),
922 payload: ToolPayload::Function {
923 arguments: "{}".to_string(),
924 },
925 source: ToolCallSource::Direct,
926 raw_tool_call_id: None,
927 };
928
929 assert_eq!(
930 call.execution_subject("/fallback/dir"),
931 ("my_tool".to_string(), "/fallback/dir".to_string(), "tool")
932 );
933 }
934 }
935
935 lines RUST