返回 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 /// Provider-neutral non-text content returned alongside a tool result.
137 /// Image-producing tools return owned base64 bytes here (MCP uses its standard
138 /// `content` image blocks). A path in `ToolResult.metadata` is descriptive
139 /// metadata, never permission for the engine to read another host file.
140 /// The runtime validates format, full decode and size, retains one image per
141 /// result, and keeps omitted-image receipts with the text result.
142 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143 #[serde(tag = "type", rename_all = "snake_case")]
144 pub enum ToolResultContentBlock {
145 Image { mime_type: String, data: String },
146 }
147
148 impl ToolResult {
149 /// Create a successful result with content.
150 #[must_use]
151 pub fn success(content: impl Into<String>) -> Self {
152 Self {
153 content: content.into(),
154 success: true,
155 metadata: None,
156 }
157 }
158
159 /// Create an error result with message.
160 #[must_use]
161 pub fn error(message: impl Into<String>) -> Self {
162 Self {
163 content: message.into(),
164 success: false,
165 metadata: None,
166 }
167 }
168
169 /// Create a successful result from JSON.
170 pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
171 Ok(Self {
172 content: serde_json::to_string(value)?,
173 success: true,
174 metadata: None,
175 })
176 }
177
178 /// Add metadata to the result.
179 #[must_use]
180 pub fn with_metadata(mut self, metadata: Value) -> Self {
181 self.metadata = Some(metadata);
182 self
183 }
184 }
185
186 /// Name the JSON type of a value the way a tool schema would spell it.
187 #[must_use]
188 pub fn json_type_name(value: &Value) -> &'static str {
189 match value {
190 Value::Null => "null",
191 Value::Bool(_) => "boolean",
192 Value::Number(_) => "number",
193 Value::String(_) => "string",
194 Value::Array(_) => "array",
195 Value::Object(_) => "object",
196 }
197 }
198
199 /// Render a value for an error message, truncated so a huge payload cannot
200 /// swamp the transcript.
201 #[must_use]
202 pub fn value_preview(value: &Value) -> String {
203 let preview = value.to_string();
204 if preview.chars().count() > 120 {
205 preview.chars().take(117).collect::<String>() + "..."
206 } else {
207 preview
208 }
209 }
210
211 /// The one error every type mismatch on a tool parameter produces.
212 ///
213 /// Names the parameter, the type that arrived, and the type the schema
214 /// declares, plus the offending value — everything the caller needs to fix
215 /// the call on the next turn without another round trip.
216 #[must_use]
217 pub fn type_mismatch(field: &str, value: &Value, expected: &str) -> ToolError {
218 ToolError::invalid_input(format!(
219 "field '{field}' must be {expected}; got {}. Received: {}",
220 json_type_name(value),
221 value_preview(value)
222 ))
223 }
224
225 /// Whether a value counts as "the caller did not supply this field".
226 ///
227 /// JSON `null` is the wire spelling of absence, so an optional field set to
228 /// `null` takes its default rather than erroring. This is the *only*
229 /// tolerance in the optional extractors, and it is uniform across all of
230 /// them: `null` means no value, and no value is exactly what a default is
231 /// for. Every other type mismatch is an error.
232 fn is_absent(value: Option<&Value>) -> bool {
233 matches!(value, None | Some(Value::Null))
234 }
235
236 /// Helper to extract a required string field from JSON input.
237 pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> {
238 if let Some(value) = input.get(field) {
239 if let Some(string_value) = value.as_str() {
240 return Ok(string_value);
241 }
242
243 return Err(type_mismatch(field, value, "a string"));
244 }
245
246 // When the field is missing, list the fields the caller *did*
247 // supply so the model can spot the mismatch without a retry.
248 let provided: Vec<&str> = input
249 .as_object()
250 .map(|obj| obj.keys().map(|k| k.as_str()).collect())
251 .unwrap_or_default();
252 if provided.is_empty() {
253 Err(ToolError::missing_field(field))
254 } else {
255 let hint = format!(
256 "missing required field '{field}'. Input provided: {}",
257 provided.join(", ")
258 );
259 Err(ToolError::invalid_input(hint))
260 }
261 }
262
263 /// Helper to extract an optional string field from JSON input.
264 ///
265 /// A wrong type is an error, never a silent `None`. See [`type_mismatch`]
266 /// for why nothing is coerced.
267 pub fn optional_str<'a>(
268 input: &'a Value,
269 field: &str,
270 ) -> std::result::Result<Option<&'a str>, ToolError> {
271 let value = input.get(field);
272 if is_absent(value) {
273 return Ok(None);
274 }
275 let value = value.expect("is_absent covers the None case");
276 value
277 .as_str()
278 .map(Some)
279 .ok_or_else(|| type_mismatch(field, value, "a string"))
280 }
281
282 /// Helper to extract a required u64 field from JSON input.
283 ///
284 /// Absence (field missing or `null`) is a `missing_field` error; a value
285 /// that is present but not a u64 is a [`type_mismatch`] naming the field and
286 /// the expected type, so the caller fixes the field's type instead of
287 /// re-sending it as missing.
288 pub fn required_u64(input: &Value, field: &str) -> std::result::Result<u64, ToolError> {
289 let value = input.get(field);
290 if is_absent(value) {
291 return Err(ToolError::missing_field(field));
292 }
293 let value = value.expect("is_absent covers the None case");
294 value
295 .as_u64()
296 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
297 }
298
299 /// Helper to extract an optional u64 field with default.
300 ///
301 /// A wrong type is an error, never a silent fall back to `default`.
302 pub fn optional_u64(
303 input: &Value,
304 field: &str,
305 default: u64,
306 ) -> std::result::Result<u64, ToolError> {
307 let value = input.get(field);
308 if is_absent(value) {
309 return Ok(default);
310 }
311 let value = value.expect("is_absent covers the None case");
312 value
313 .as_u64()
314 .ok_or_else(|| type_mismatch(field, value, "a non-negative integer"))
315 }
316
317 /// Helper to extract an optional bool field with default.
318 ///
319 /// A wrong type is an error, never a silent fall back to `default`. In
320 /// particular the string `"true"` is refused rather than coerced: the
321 /// default this used to fall back to is frequently the *opposite* of what
322 /// the caller asked for, and some of those defaults gate irreversible
323 /// actions.
324 pub fn optional_bool(
325 input: &Value,
326 field: &str,
327 default: bool,
328 ) -> std::result::Result<bool, ToolError> {
329 Ok(optional_bool_opt(input, field)?.unwrap_or(default))
330 }
331
332 /// Helper to extract an optional bool that has no default.
333 ///
334 /// `None` means the caller did not supply the field; a wrong type is an
335 /// error. Use this where "unset" is itself meaningful — an authority
336 /// declaration that is dropped instead of read is a restriction that
337 /// silently evaporates.
338 pub fn optional_bool_opt(
339 input: &Value,
340 field: &str,
341 ) -> std::result::Result<Option<bool>, ToolError> {
342 let value = input.get(field);
343 if is_absent(value) {
344 return Ok(None);
345 }
346 let value = value.expect("is_absent covers the None case");
347 value
348 .as_bool()
349 .map(Some)
350 .ok_or_else(|| type_mismatch(field, value, "a boolean"))
351 }
352
353 /// Descriptor that describes a tool available in the registry.
354 ///
355 /// Contains the tool's name, its JSON input/output schemas, and
356 /// execution constraints such as timeout and parallelism.
357 #[derive(Debug, Clone, Serialize, Deserialize)]
358 pub struct ToolDescriptor {
359 /// Unique name used to look up the tool.
360 pub name: String,
361 /// JSON Schema describing the tool's expected input parameters.
362 pub input_schema: Value,
363 /// JSON Schema describing the tool's output format.
364 pub output_schema: Value,
365 /// Whether multiple invocations of this tool may run concurrently.
366 pub supports_parallel_tool_calls: bool,
367 /// Optional per-call timeout in milliseconds; `None` means no timeout.
368 pub timeout_ms: Option<u64>,
369 }
370
371 /// A [`ToolDescriptor`] together with its runtime configuration.
372 ///
373 /// Wraps a `ToolDescriptor` and exposes the parallelism flag directly so the
374 /// dispatcher can check it without digging into the inner spec.
375 #[derive(Debug, Clone, Serialize, Deserialize)]
376 pub struct ConfiguredToolDescriptor {
377 /// The underlying tool descriptor.
378 pub spec: ToolDescriptor,
379 /// Whether this tool supports concurrent invocations.
380 pub supports_parallel_tool_calls: bool,
381 }
382
383 /// Identifies where a tool call originated from.
384 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385 #[serde(rename_all = "snake_case")]
386 pub enum ToolCallSource {
387 /// Direct invocation from the model or user.
388 Direct,
389 /// Invocation through the JavaScript REPL environment.
390 JsRepl,
391 }
392
393 /// A tool invocation request before it has been validated and dispatched.
394 ///
395 /// Contains the tool name, its input payload, and metadata about where the
396 /// call originated.
397 #[derive(Debug, Clone, Serialize, Deserialize)]
398 pub struct ToolCall {
399 /// Name of the tool to invoke.
400 pub name: String,
401 /// The input payload for the tool.
402 pub payload: ToolPayload,
403 /// Where this call originated (direct or REPL).
404 pub source: ToolCallSource,
405 /// Optional raw tool-call identifier from the upstream provider.
406 pub raw_tool_call_id: Option<String>,
407 }
408
409 impl ToolCall {
410 /// Derive the execution subject for this call.
411 ///
412 /// For local shell payloads this returns the shell command and its
413 /// working directory; for all other payloads the tool name and the
414 /// provided `fallback_cwd` are returned instead. The third element
415 /// of the tuple is a human-readable kind label (`"shell"` or `"tool"`).
416 pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
417 match &self.payload {
418 ToolPayload::LocalShell { params } => (
419 params.command.clone(),
420 params
421 .cwd
422 .clone()
423 .unwrap_or_else(|| fallback_cwd.to_string()),
424 "shell",
425 ),
426 _ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
427 }
428 }
429 }
430
431 /// A validated tool invocation ready to be handled.
432 ///
433 /// Created by the registry after a [`ToolCall`] passes validation, this
434 /// carries all the context a [`ToolHandler`] needs to execute the tool.
435 #[derive(Debug, Clone)]
436 pub struct ToolInvocation {
437 /// Unique identifier for this invocation (generated or from the provider).
438 pub call_id: String,
439 /// Name of the tool being invoked.
440 pub tool_name: String,
441 /// The input payload for the tool.
442 pub payload: ToolPayload,
443 /// Where this invocation originated.
444 pub source: ToolCallSource,
445 }
446
447 /// Errors that can occur during tool dispatch and execution.
448 ///
449 /// Unlike [`ToolError`], which represents input validation failures within
450 /// a tool, `FunctionCallError` covers problems at the dispatch layer: the
451 /// tool was not found, its kind did not match, it was rejected because it
452 /// is mutating, it timed out, was cancelled, or its handler returned an
453 /// error.
454 #[derive(Debug, Clone, Serialize, Deserialize)]
455 pub enum FunctionCallError {
456 /// No tool with the given name is registered.
457 ToolNotFound { name: String },
458 /// The payload kind does not match the handler's expected kind.
459 KindMismatch { expected: ToolKind, got: ToolKind },
460 /// The tool is mutating but `allow_mutating` was `false`.
461 MutatingToolRejected { name: String },
462 /// The tool execution exceeded its configured timeout.
463 TimedOut { name: String, timeout_ms: u64 },
464 /// The tool execution was cancelled.
465 Cancelled { name: String },
466 /// The tool handler returned an error.
467 ExecutionFailed { name: String, error: String },
468 }
469
470 /// Trait implemented by concrete tool handlers.
471 ///
472 /// Each registered tool is backed by a handler that reports its kind,
473 /// whether it is mutating, and performs the actual execution.
474 #[async_trait]
475 pub trait ToolHandler: Send + Sync {
476 /// The [`ToolKind`] this handler expects (e.g. `Function` or `Mcp`).
477 fn kind(&self) -> ToolKind;
478
479 /// Returns `true` if `kind` matches this handler's expected kind.
480 ///
481 /// The default implementation compares against [`kind()`](ToolHandler::kind).
482 fn matches_kind(&self, kind: ToolKind) -> bool {
483 self.kind() == kind
484 }
485
486 /// Whether this tool performs side-effects that require user approval.
487 ///
488 /// Defaults to `false` (read-only / safe).
489 fn is_mutating(&self) -> bool {
490 false
491 }
492
493 /// Execute the tool with the given invocation context.
494 async fn handle(
495 &self,
496 invocation: ToolInvocation,
497 ) -> std::result::Result<ToolOutput, FunctionCallError>;
498 }
499
500 /// Manages concurrent tool execution via a read/write lock.
501 ///
502 /// Parallel-safe tools acquire a read lock (allowing overlap), while
503 /// serial tools acquire a write lock (exclusive access). Reentrant calls
504 /// (e.g. a tool invoking another tool) skip locking to avoid deadlock.
505 #[derive(Debug)]
506 pub struct ToolCallRuntime {
507 execution_lock: Arc<RwLock<()>>,
508 }
509
510 impl Default for ToolCallRuntime {
511 fn default() -> Self {
512 Self {
513 execution_lock: Arc::new(RwLock::new(())),
514 }
515 }
516 }
517
518 #[derive(Debug)]
519 enum ToolExecutionGuard {
520 Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>),
521 Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>),
522 Reentrant,
523 }
524
525 impl ToolCallRuntime {
526 async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard {
527 if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() {
528 return ToolExecutionGuard::Reentrant;
529 }
530
531 if supports_parallel {
532 ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await)
533 } else {
534 ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await)
535 }
536 }
537 }
538
539 /// Central registry that maps tool names to their specs and handlers.
540 ///
541 /// Use [`register()`](ToolRegistry::register) to add tools, then
542 /// [`dispatch()`](ToolRegistry::dispatch) to invoke them. The registry
543 /// owns a [`ToolCallRuntime`] that manages concurrent execution.
544 #[derive(Default)]
545 pub struct ToolRegistry {
546 handlers: HashMap<String, Arc<dyn ToolHandler>>,
547 specs: HashMap<String, ConfiguredToolDescriptor>,
548 runtime: ToolCallRuntime,
549 }
550
551 impl ToolRegistry {
552 /// Register a tool with its specification and handler.
553 ///
554 /// The tool's name is taken from `spec.name`. Returns an error if
555 /// registration fails (currently infallible, but the `Result` is
556 /// reserved for future validation).
557 pub fn register(&mut self, spec: ToolDescriptor, handler: Arc<dyn ToolHandler>) -> Result<()> {
558 let name = spec.name.clone();
559 self.specs.insert(
560 name.clone(),
561 ConfiguredToolDescriptor {
562 supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
563 spec,
564 },
565 );
566 self.handlers.insert(name, handler);
567 Ok(())
568 }
569
570 /// Validate and execute a tool call.
571 ///
572 /// Looks up the tool by name, verifies the payload kind matches the
573 /// handler, enforces the `allow_mutating` guard, acquires the
574 /// appropriate execution lock, and forwards the call to the handler.
575 /// Returns a [`FunctionCallError`] if any validation step fails or
576 /// the handler returns an error.
577 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
653 fn 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)]
663 mod 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 // "name" is present but a string: a type mismatch, not a missing
697 // field, so the caller fixes the type instead of re-sending the name.
698 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 /// The rule, stated once: an optional parameter of the wrong JSON type is
708 /// an error that names the parameter, what arrived, and what was wanted.
709 /// `null` alone means "absent" and takes the default.
710 #[test]
711 fn optional_extractors_refuse_type_mismatches_instead_of_defaulting() {
712 // The shipping bug: a stringy "true" became the default `false`,
713 // which for `dry_run` is the opposite of what the caller asked and
714 // gates an irreversible action.
715 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 // `null` is the wire spelling of absence, uniformly across all three.
743 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 // Absent (or null) is a missing-field error.
760 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 // Present and valid values pass through, including the extremes.
770 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 // Present but wrongly typed is a type mismatch naming the field and
777 // the expected type — never a missing-field misdirection.
778 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 }
942
942 lines RUST