Skip to main content

codewhale_tools/
lib.rs

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/// Capabilities that a tool may have or require.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub 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)]
44pub 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)]
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/// Result of a tool execution.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub 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#[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    /// Create a successful result with content.
145    #[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    /// Create an error result with message.
155    #[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    /// Create a successful result from JSON.
165    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    /// Add metadata to the result.
174    #[must_use]
175    pub fn with_metadata(mut self, metadata: Value) -> Self {
176        self.metadata = Some(metadata);
177        self
178    }
179}
180
181/// Name the JSON type of a value the way a tool schema would spell it.
182#[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/// Render a value for an error message, truncated so a huge payload cannot
195/// swamp the transcript.
196#[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/// The one error every type mismatch on a tool parameter produces.
207///
208/// Names the parameter, the type that arrived, and the type the schema
209/// declares, plus the offending value — everything the caller needs to fix
210/// the call on the next turn without another round trip.
211#[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
220/// Whether a value counts as "the caller did not supply this field".
221///
222/// JSON `null` is the wire spelling of absence, so an optional field set to
223/// `null` takes its default rather than erroring. This is the *only*
224/// tolerance in the optional extractors, and it is uniform across all of
225/// them: `null` means no value, and no value is exactly what a default is
226/// for. Every other type mismatch is an error.
227fn is_absent(value: Option<&Value>) -> bool {
228    matches!(value, None | Some(Value::Null))
229}
230
231/// Helper to extract a required string field from JSON input.
232pub 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    // When the field is missing, list the fields the caller *did*
242    // supply so the model can spot the mismatch without a retry.
243    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
258/// Helper to extract an optional string field from JSON input.
259///
260/// A wrong type is an error, never a silent `None`. See [`type_mismatch`]
261/// for why nothing is coerced.
262pub 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
277/// Helper to extract a required u64 field from JSON input.
278///
279/// Absence (field missing or `null`) is a `missing_field` error; a value
280/// that is present but not a u64 is a [`type_mismatch`] naming the field and
281/// the expected type, so the caller fixes the field's type instead of
282/// re-sending it as missing.
283pub 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
294/// Helper to extract an optional u64 field with default.
295///
296/// A wrong type is an error, never a silent fall back to `default`.
297pub 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
312/// Helper to extract an optional bool field with default.
313///
314/// A wrong type is an error, never a silent fall back to `default`. In
315/// particular the string `"true"` is refused rather than coerced: the
316/// default this used to fall back to is frequently the *opposite* of what
317/// the caller asked for, and some of those defaults gate irreversible
318/// actions.
319pub 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
327/// Helper to extract an optional bool that has no default.
328///
329/// `None` means the caller did not supply the field; a wrong type is an
330/// error. Use this where "unset" is itself meaningful — an authority
331/// declaration that is dropped instead of read is a restriction that
332/// silently evaporates.
333pub 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/// Descriptor that describes a tool available in the registry.
349///
350/// Contains the tool's name, its JSON input/output schemas, and
351/// execution constraints such as timeout and parallelism.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ToolDescriptor {
354    /// Unique name used to look up the tool.
355    pub name: String,
356    /// JSON Schema describing the tool's expected input parameters.
357    pub input_schema: Value,
358    /// JSON Schema describing the tool's output format.
359    pub output_schema: Value,
360    /// Whether multiple invocations of this tool may run concurrently.
361    pub supports_parallel_tool_calls: bool,
362    /// Optional per-call timeout in milliseconds; `None` means no timeout.
363    pub timeout_ms: Option<u64>,
364}
365
366/// A [`ToolDescriptor`] together with its runtime configuration.
367///
368/// Wraps a `ToolDescriptor` and exposes the parallelism flag directly so the
369/// dispatcher can check it without digging into the inner spec.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ConfiguredToolDescriptor {
372    /// The underlying tool descriptor.
373    pub spec: ToolDescriptor,
374    /// Whether this tool supports concurrent invocations.
375    pub supports_parallel_tool_calls: bool,
376}
377
378/// Identifies where a tool call originated from.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum ToolCallSource {
382    /// Direct invocation from the model or user.
383    Direct,
384    /// Invocation through the JavaScript REPL environment.
385    JsRepl,
386}
387
388/// A tool invocation request before it has been validated and dispatched.
389///
390/// Contains the tool name, its input payload, and metadata about where the
391/// call originated.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ToolCall {
394    /// Name of the tool to invoke.
395    pub name: String,
396    /// The input payload for the tool.
397    pub payload: ToolPayload,
398    /// Where this call originated (direct or REPL).
399    pub source: ToolCallSource,
400    /// Optional raw tool-call identifier from the upstream provider.
401    pub raw_tool_call_id: Option<String>,
402}
403
404impl ToolCall {
405    /// Derive the execution subject for this call.
406    ///
407    /// For local shell payloads this returns the shell command and its
408    /// working directory; for all other payloads the tool name and the
409    /// provided `fallback_cwd` are returned instead. The third element
410    /// of the tuple is a human-readable kind label (`"shell"` or `"tool"`).
411    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/// A validated tool invocation ready to be handled.
427///
428/// Created by the registry after a [`ToolCall`] passes validation, this
429/// carries all the context a [`ToolHandler`] needs to execute the tool.
430#[derive(Debug, Clone)]
431pub struct ToolInvocation {
432    /// Unique identifier for this invocation (generated or from the provider).
433    pub call_id: String,
434    /// Name of the tool being invoked.
435    pub tool_name: String,
436    /// The input payload for the tool.
437    pub payload: ToolPayload,
438    /// Where this invocation originated.
439    pub source: ToolCallSource,
440}
441
442/// Errors that can occur during tool dispatch and execution.
443///
444/// Unlike [`ToolError`], which represents input validation failures within
445/// a tool, `FunctionCallError` covers problems at the dispatch layer: the
446/// tool was not found, its kind did not match, it was rejected because it
447/// is mutating, it timed out, was cancelled, or its handler returned an
448/// error.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub enum FunctionCallError {
451    /// No tool with the given name is registered.
452    ToolNotFound { name: String },
453    /// The payload kind does not match the handler's expected kind.
454    KindMismatch { expected: ToolKind, got: ToolKind },
455    /// The tool is mutating but `allow_mutating` was `false`.
456    MutatingToolRejected { name: String },
457    /// The tool execution exceeded its configured timeout.
458    TimedOut { name: String, timeout_ms: u64 },
459    /// The tool execution was cancelled.
460    Cancelled { name: String },
461    /// The tool handler returned an error.
462    ExecutionFailed { name: String, error: String },
463}
464
465/// Trait implemented by concrete tool handlers.
466///
467/// Each registered tool is backed by a handler that reports its kind,
468/// whether it is mutating, and performs the actual execution.
469#[async_trait]
470pub trait ToolHandler: Send + Sync {
471    /// The [`ToolKind`] this handler expects (e.g. `Function` or `Mcp`).
472    fn kind(&self) -> ToolKind;
473
474    /// Returns `true` if `kind` matches this handler's expected kind.
475    ///
476    /// The default implementation compares against [`kind()`](ToolHandler::kind).
477    fn matches_kind(&self, kind: ToolKind) -> bool {
478        self.kind() == kind
479    }
480
481    /// Whether this tool performs side-effects that require user approval.
482    ///
483    /// Defaults to `false` (read-only / safe).
484    fn is_mutating(&self) -> bool {
485        false
486    }
487
488    /// Execute the tool with the given invocation context.
489    async fn handle(
490        &self,
491        invocation: ToolInvocation,
492    ) -> std::result::Result<ToolOutput, FunctionCallError>;
493}
494
495/// Manages concurrent tool execution via a read/write lock.
496///
497/// Parallel-safe tools acquire a read lock (allowing overlap), while
498/// serial tools acquire a write lock (exclusive access). Reentrant calls
499/// (e.g. a tool invoking another tool) skip locking to avoid deadlock.
500#[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/// Central registry that maps tool names to their specs and handlers.
535///
536/// Use [`register()`](ToolRegistry::register) to add tools, then
537/// [`dispatch()`](ToolRegistry::dispatch) to invoke them. The registry
538/// owns a [`ToolCallRuntime`] that manages concurrent execution.
539#[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    /// Register a tool with its specification and handler.
548    ///
549    /// The tool's name is taken from `spec.name`. Returns an error if
550    /// registration fails (currently infallible, but the `Result` is
551    /// reserved for future validation).
552    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    /// Return the configured specs for every registered tool.
566    pub fn list_specs(&self) -> Vec<ConfiguredToolDescriptor> {
567        self.specs.values().cloned().collect()
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
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        // "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}