Skip to main content

swink_agent/
tool.rs

1//! Tool system traits and validation for the swink agent.
2//!
3//! This module defines the [`AgentTool`] trait that all tools must implement,
4//! the [`AgentToolResult`] type returned by tool execution, and the
5//! [`validate_tool_arguments`] function for validating tool call arguments
6//! against a JSON Schema.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13use std::sync::{LazyLock, Mutex};
14
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use tokio_util::sync::CancellationToken;
19
20use crate::agent_options::{ApproveToolFn, ApproveToolFuture};
21use crate::schema::schema_for;
22use crate::transfer::TransferSignal;
23use crate::types::ContentBlock;
24
25static SCHEMA_VALIDATOR_CACHE: LazyLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
26    LazyLock::new(|| Mutex::new(HashMap::new()));
27
28// ─── AgentToolResult ─────────────────────────────────────────────────────────
29
30/// The result of a tool execution.
31///
32/// Contains content blocks returned to the LLM and structured details for
33/// logging that are not sent to the model.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AgentToolResult {
36    /// Content blocks returned to the LLM as the tool result.
37    pub content: Vec<ContentBlock>,
38    /// Structured data for logging and display; not sent to the LLM.
39    pub details: Value,
40    /// Whether this result represents an error condition.
41    pub is_error: bool,
42    /// Optional transfer signal when this tool result requests an agent handoff.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub transfer_signal: Option<TransferSignal>,
45}
46
47impl AgentToolResult {
48    /// Create a result containing a single text content block with null details.
49    pub fn text(text: impl Into<String>) -> Self {
50        Self {
51            content: vec![ContentBlock::Text { text: text.into() }],
52            details: Value::Null,
53            is_error: false,
54            transfer_signal: None,
55        }
56    }
57
58    /// Create an error result containing a single text content block with null
59    /// details.
60    ///
61    /// Semantically identical to [`text`](Self::text) but communicates intent
62    /// at the call site.
63    pub fn error(message: impl Into<String>) -> Self {
64        Self {
65            content: vec![ContentBlock::Text {
66                text: message.into(),
67            }],
68            details: Value::Null,
69            is_error: true,
70            transfer_signal: None,
71        }
72    }
73
74    /// Create a result that signals a transfer to another agent.
75    ///
76    /// The result text is a brief confirmation message; the transfer signal
77    /// carries the actual handoff payload. The agent loop detects this signal
78    /// and terminates the turn with [`StopReason::Transfer`](crate::StopReason::Transfer).
79    pub fn transfer(signal: TransferSignal) -> Self {
80        let text = format!("Transfer to {} initiated.", signal.target_agent());
81        Self {
82            content: vec![ContentBlock::Text { text }],
83            details: Value::Null,
84            is_error: false,
85            transfer_signal: Some(signal),
86        }
87    }
88
89    /// Returns `true` if this result carries a transfer signal.
90    pub const fn is_transfer(&self) -> bool {
91        self.transfer_signal.is_some()
92    }
93}
94
95/// A boxed future returned by [`AgentTool`] execution.
96pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = AgentToolResult> + Send + 'a>>;
97
98// ─── Tool Metadata ──────────────────────────────────────────────────────────
99
100/// Optional organizational metadata for an [`AgentTool`].
101///
102/// Groups tools by namespace and tracks version. Existing tools default to
103/// no namespace and no version.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct ToolMetadata {
106    /// Logical grouping such as `"filesystem"`, `"git"`, or `"code_analysis"`.
107    pub namespace: Option<String>,
108    /// Semver-style version string for the tool (e.g. `"1.0.0"`).
109    pub version: Option<String>,
110}
111
112impl ToolMetadata {
113    /// Create metadata with a namespace.
114    #[must_use]
115    pub fn with_namespace(namespace: impl Into<String>) -> Self {
116        Self {
117            namespace: Some(namespace.into()),
118            version: None,
119        }
120    }
121
122    /// Set the version on this metadata.
123    #[must_use]
124    pub fn with_version(mut self, version: impl Into<String>) -> Self {
125        self.version = Some(version.into());
126        self
127    }
128}
129
130// ─── AgentTool Trait ─────────────────────────────────────────────────────────
131
132/// A tool that can be invoked by the agent loop.
133///
134/// Implementations must be object-safe, `Send`, and `Sync`. The trait uses a
135/// boxed future return type instead of `async fn` to maintain object safety.
136pub trait AgentTool: Send + Sync {
137    /// Unique routing key used to dispatch tool calls.
138    fn name(&self) -> &str;
139
140    /// Human-readable display name for logging and UI.
141    fn label(&self) -> &str;
142
143    /// Natural-language description included in the LLM prompt.
144    fn description(&self) -> &str;
145
146    /// JSON Schema describing the tool's input shape, used for validation.
147    fn parameters_schema(&self) -> &Value;
148
149    /// Whether this tool requires user approval before execution.
150    /// Default is `false` — tools execute immediately.
151    fn requires_approval(&self) -> bool {
152        false
153    }
154
155    /// Optional organizational metadata (namespace, version).
156    ///
157    /// Returns `None` by default for backward compatibility.
158    fn metadata(&self) -> Option<ToolMetadata> {
159        None
160    }
161
162    /// Optional rich context for the approval UI.
163    ///
164    /// When a tool call requires approval, this method is called to provide
165    /// additional context (e.g., a diff preview, estimated cost, query plan).
166    /// The returned value is attached to the [`ToolApprovalRequest`].
167    ///
168    /// Returns `None` by default — tools work fine without it. Panics are
169    /// caught and treated as `None`.
170    fn approval_context(&self, _params: &Value) -> Option<Value> {
171        None
172    }
173
174    /// Optional authentication configuration for this tool.
175    ///
176    /// When `Some`, the framework resolves credentials from the configured
177    /// [`CredentialResolver`](crate::CredentialResolver) before calling
178    /// [`execute()`](Self::execute). Returns `None` by default (no auth required).
179    fn auth_config(&self) -> Option<crate::credential::AuthConfig> {
180        None
181    }
182
183    /// Execute the tool with validated parameters.
184    ///
185    /// # Arguments
186    ///
187    /// * `tool_call_id` — unique identifier for this particular invocation
188    /// * `params` — validated input parameters as a JSON value
189    /// * `cancellation_token` — token that signals the tool should abort
190    /// * `on_update` — optional callback for streaming partial results
191    /// * `state` — shared session state for reading/writing structured data
192    /// * `credential` — resolved credential if `auth_config()` returns `Some`
193    fn execute(
194        &self,
195        tool_call_id: &str,
196        params: Value,
197        cancellation_token: CancellationToken,
198        on_update: Option<Box<dyn Fn(AgentToolResult) + Send + Sync>>,
199        state: Arc<std::sync::RwLock<crate::SessionState>>,
200        credential: Option<crate::credential::ResolvedCredential>,
201    ) -> ToolFuture<'_>;
202}
203
204// ─── IntoTool ────────────────────────────────────────────────────────────────
205
206/// Convenience trait to convert a tool implementation into `Arc<dyn AgentTool>`.
207///
208/// This eliminates the `Arc::new(tool) as Arc<dyn AgentTool>` ceremony.
209///
210/// # Example
211///
212/// ```ignore
213/// use swink_agent::{IntoTool, BashTool};
214/// let tools = vec![BashTool::new().into_tool()];
215/// ```
216pub trait IntoTool {
217    /// Wrap this tool in an `Arc<dyn AgentTool>`.
218    fn into_tool(self) -> Arc<dyn AgentTool>;
219}
220
221impl<T: AgentTool + 'static> IntoTool for T {
222    fn into_tool(self) -> Arc<dyn AgentTool> {
223        Arc::new(self)
224    }
225}
226
227// ─── Validation ──────────────────────────────────────────────────────────────
228
229/// Validate that a JSON value is a valid JSON Schema document.
230///
231/// This checks the schema itself for correctness (e.g., valid `type` values,
232/// proper structure). Distinct from [`validate_tool_arguments`], which validates
233/// data *against* a schema.
234///
235/// # Errors
236///
237/// Returns `Err(String)` when the schema document is invalid, with a
238/// human-readable description of the problem.
239pub fn validate_schema(schema: &Value) -> Result<(), String> {
240    compiled_validator(schema)?;
241    Ok(())
242}
243
244/// Validate tool call arguments against a JSON Schema.
245///
246/// Returns `Ok(())` when the arguments are valid, or `Err` with a list of
247/// human-readable error strings describing each validation failure.
248///
249/// # Errors
250///
251/// Returns `Err(Vec<String>)` when the arguments fail schema validation,
252/// containing one human-readable error string per violation.
253pub fn validate_tool_arguments(schema: &Value, arguments: &Value) -> Result<(), Vec<String>> {
254    let validator = compiled_validator(schema).map_err(|e| vec![e])?;
255
256    let errors: Vec<String> = validator
257        .iter_errors(arguments)
258        .map(|e| e.to_string())
259        .collect();
260
261    if errors.is_empty() {
262        Ok(())
263    } else {
264        Err(errors)
265    }
266}
267
268/// Build the default permissive object schema used by simple tools.
269#[must_use]
270pub(crate) fn permissive_object_schema() -> Value {
271    serde_json::json!({
272        "type": "object",
273        "properties": {},
274        "additionalProperties": true
275    })
276}
277
278/// Validate a schema in debug builds and return it unchanged.
279#[must_use]
280pub(crate) fn debug_validated_schema(schema: Value) -> Value {
281    debug_assert!(validate_schema(&schema).is_ok());
282    schema
283}
284
285/// Generate and debug-validate a schema from a `schemars` type.
286#[must_use]
287pub(crate) fn validated_schema_for<T: schemars::JsonSchema>() -> Value {
288    debug_validated_schema(schema_for::<T>())
289}
290
291fn compiled_validator(schema: &Value) -> Result<Arc<jsonschema::Validator>, String> {
292    let cache_key = serde_json::to_string(schema).map_err(|e| e.to_string())?;
293
294    {
295        let cache = SCHEMA_VALIDATOR_CACHE
296            .lock()
297            .unwrap_or_else(std::sync::PoisonError::into_inner);
298        if let Some(validator) = cache.get(&cache_key) {
299            return Ok(Arc::clone(validator));
300        }
301    }
302
303    let compiled = Arc::new(jsonschema::validator_for(schema).map_err(|e| e.to_string())?);
304    let mut cache = SCHEMA_VALIDATOR_CACHE
305        .lock()
306        .unwrap_or_else(std::sync::PoisonError::into_inner);
307    Ok(Arc::clone(
308        cache
309            .entry(cache_key)
310            .or_insert_with(|| Arc::clone(&compiled)),
311    ))
312}
313
314/// Build an error result for an unknown tool name.
315#[must_use]
316pub fn unknown_tool_result(tool_name: &str) -> AgentToolResult {
317    AgentToolResult::error(format!("unknown tool: {tool_name}"))
318}
319
320/// Build an error result listing all validation errors.
321#[must_use]
322pub fn validation_error_result(errors: &[String]) -> AgentToolResult {
323    let message = errors.join("\n");
324    AgentToolResult::error(message)
325}
326
327// ─── Tool Approval ──────────────────────────────────────────────────────────
328
329/// Result of the approval gate for a tool call.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub enum ToolApproval {
332    /// The tool call is approved and should proceed.
333    Approved,
334    /// The tool call is rejected and should not execute.
335    Rejected,
336    /// Approved with modified parameters (constrain scope or sanitize input).
337    ApprovedWith(serde_json::Value),
338}
339
340/// Information about a tool call pending approval.
341///
342/// The [`Debug`] implementation redacts the `arguments` field and sanitizes
343/// `context` to prevent sensitive values from leaking into logs and debug
344/// output.
345#[derive(Clone)]
346pub struct ToolApprovalRequest {
347    /// The unique ID of this tool call.
348    pub tool_call_id: String,
349    /// The name of the tool being called.
350    pub tool_name: String,
351    /// The arguments passed to the tool.
352    pub arguments: Value,
353    /// Whether the tool itself declared that it requires approval.
354    pub requires_approval: bool,
355    /// Optional rich context from the tool's `approval_context()` method.
356    pub context: Option<Value>,
357}
358
359impl fmt::Debug for ToolApprovalRequest {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        let redacted_context = self.context.as_ref().map(redact_sensitive_values);
362        f.debug_struct("ToolApprovalRequest")
363            .field("tool_call_id", &self.tool_call_id)
364            .field("tool_name", &self.tool_name)
365            .field("arguments", &"[REDACTED]")
366            .field("requires_approval", &self.requires_approval)
367            .field("context", &redacted_context)
368            .finish()
369    }
370}
371
372/// Controls whether the approval gate is active.
373#[non_exhaustive]
374#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
375pub enum ApprovalMode {
376    /// Every tool call goes through the approval callback.
377    Enabled,
378    /// Auto-approve read-only tools (where `requires_approval()` returns false);
379    /// prompt for all others. Supports per-tool session trust.
380    #[default]
381    Smart,
382    /// All tool calls auto-approved — callback is never called.
383    /// Use this to temporarily disable approval without removing the callback.
384    Bypassed,
385}
386
387// ─── selective_approve ───────────────────────────────────────────────────────
388
389/// Wraps an approval callback so that only tools with `requires_approval == true`
390/// go through the inner callback. All other tools are auto-approved.
391#[allow(clippy::type_complexity)]
392pub fn selective_approve<F>(inner: F) -> Box<ApproveToolFn>
393where
394    F: Fn(ToolApprovalRequest) -> ApproveToolFuture + Send + Sync + 'static,
395{
396    Box::new(move |req: ToolApprovalRequest| {
397        if req.requires_approval {
398            inner(req)
399        } else {
400            Box::pin(async { ToolApproval::Approved })
401        }
402    })
403}
404
405// ─── Sensitive Value Redaction ────────────────────────────────────────────────
406
407/// Placeholder used to replace redacted values.
408const REDACTED: &str = "[REDACTED]";
409
410/// Key names whose values are always redacted, regardless of content.
411const SENSITIVE_KEYS: &[&str] = &[
412    "password",
413    "secret",
414    "token",
415    "api_key",
416    "apikey",
417    "authorization",
418];
419
420/// Scan a [`serde_json::Value`] for common secret patterns and replace
421/// matching string values with `"[REDACTED]"`.
422///
423/// Redaction rules:
424/// - **Sensitive keys** — object keys named `password`, `secret`, `token`,
425///   `api_key`, `apikey`, or `authorization` (case-insensitive) have their
426///   values replaced unconditionally.
427/// - **Value prefixes** — string values starting with `sk-`, `key-`, `token-`,
428///   `bearer ` (case-insensitive), or `Basic ` (case-insensitive) are replaced.
429/// - **Env-var patterns** — strings matching `$SECRET` or `${API_KEY}` style
430///   references are replaced.
431///
432/// Non-string values and strings that do not match any pattern pass through
433/// unchanged. The input value is cloned — the original is not modified.
434#[must_use]
435pub fn redact_sensitive_values(value: &Value) -> Value {
436    redact_value(value, None)
437}
438
439/// Recursive redaction walker.
440fn redact_value(value: &Value, parent_key: Option<&str>) -> Value {
441    // If the parent key is sensitive, redact the entire value regardless of type.
442    if let Some(key) = parent_key
443        && SENSITIVE_KEYS.iter().any(|&s| key.eq_ignore_ascii_case(s))
444    {
445        return Value::String(REDACTED.to_string());
446    }
447
448    match value {
449        Value::String(s) => {
450            if is_sensitive_string(s) {
451                Value::String(REDACTED.to_string())
452            } else {
453                value.clone()
454            }
455        }
456        Value::Array(arr) => Value::Array(arr.iter().map(|v| redact_value(v, None)).collect()),
457        Value::Object(map) => {
458            let redacted = map
459                .iter()
460                .map(|(k, v)| (k.clone(), redact_value(v, Some(k))))
461                .collect();
462            Value::Object(redacted)
463        }
464        // Numbers, booleans, null — pass through.
465        _ => value.clone(),
466    }
467}
468
469/// Check whether a string value looks like a secret based on common prefixes
470/// and environment-variable reference patterns.
471fn is_sensitive_string(s: &str) -> bool {
472    // Prefix patterns (case-sensitive for API key prefixes, insensitive for auth).
473    if s.starts_with("sk-")
474        || s.starts_with("key-")
475        || s.starts_with("token-")
476        || s.to_ascii_lowercase().starts_with("bearer ")
477        || s.to_ascii_lowercase().starts_with("basic ")
478    {
479        return true;
480    }
481
482    // Env-var reference patterns: $SECRET or ${API_KEY}
483    // Matches strings that are exactly an env-var reference.
484    thread_local! {
485        static ENV_VAR_RE: Regex =
486            Regex::new(r"^\$\{?[A-Z_][A-Z0-9_]*\}?$").expect("valid regex");
487    }
488    ENV_VAR_RE.with(|re| re.is_match(s))
489}
490
491// ─── ToolParameters trait ───────────────────────────────────────────────────
492
493/// Trait for types that can produce a JSON Schema describing their fields.
494///
495/// Implemented automatically by `#[derive(ToolSchema)]` from the
496/// `swink-agent-macros` crate.
497pub trait ToolParameters {
498    /// Generate a JSON Schema for this type's fields.
499    fn json_schema() -> Value;
500}
501
502// ─── Compile-time Send + Sync assertions ────────────────────────────────────
503
504const _: () = {
505    const fn assert_send_sync<T: Send + Sync>() {}
506    assert_send_sync::<AgentToolResult>();
507    assert_send_sync::<ToolApproval>();
508    assert_send_sync::<ToolApprovalRequest>();
509    assert_send_sync::<ApprovalMode>();
510};
511
512#[cfg(test)]
513mod tests {
514    use serde_json::json;
515
516    use super::*;
517    use crate::FnTool;
518
519    fn stub_tool(name: &str) -> FnTool {
520        FnTool::new(name, name, "A test tool.")
521    }
522
523    // ─── ToolApprovalRequest Debug ──────────────────────────────────────────
524
525    #[test]
526    fn approval_request_debug_redacts_arguments_and_context() {
527        let req = ToolApprovalRequest {
528            tool_call_id: "call_1".into(),
529            tool_name: "bash".into(),
530            arguments: json!({"command": "echo secret"}),
531            requires_approval: true,
532            context: Some(json!({
533                "Authorization": "Bearer top-secret",
534                "path": "/tmp/output.txt",
535            })),
536        };
537        let debug = format!("{req:?}");
538        assert!(debug.contains("tool_call_id: \"call_1\""));
539        assert!(debug.contains("tool_name: \"bash\""));
540        assert!(debug.contains("[REDACTED]"));
541        assert!(!debug.contains("echo secret"));
542        assert!(!debug.contains("top-secret"));
543        assert!(debug.contains("/tmp/output.txt"));
544    }
545
546    // ─── redact_sensitive_values ────────────────────────────────────────────
547
548    #[test]
549    fn redacts_sk_prefix() {
550        let val = json!({"key": "sk-abc123"});
551        let redacted = redact_sensitive_values(&val);
552        assert_eq!(redacted["key"], json!("[REDACTED]"));
553    }
554
555    #[test]
556    fn redacts_key_prefix() {
557        let val = json!({"data": "key-live-xyz"});
558        let redacted = redact_sensitive_values(&val);
559        assert_eq!(redacted["data"], json!("[REDACTED]"));
560    }
561
562    #[test]
563    fn redacts_token_prefix() {
564        let val = json!({"tok": "token-abcdef"});
565        let redacted = redact_sensitive_values(&val);
566        assert_eq!(redacted["tok"], json!("[REDACTED]"));
567    }
568
569    #[test]
570    fn redacts_bearer_prefix_case_insensitive() {
571        let val = json!({"auth": "Bearer eyJhbGciOi..."});
572        let redacted = redact_sensitive_values(&val);
573        assert_eq!(redacted["auth"], json!("[REDACTED]"));
574
575        let val2 = json!({"auth": "bearer xyz"});
576        let redacted2 = redact_sensitive_values(&val2);
577        assert_eq!(redacted2["auth"], json!("[REDACTED]"));
578    }
579
580    #[test]
581    fn redacts_basic_prefix_case_insensitive() {
582        let val = json!({"auth": "Basic dXNlcjpwYXNz"});
583        let redacted = redact_sensitive_values(&val);
584        assert_eq!(redacted["auth"], json!("[REDACTED]"));
585
586        let val2 = json!({"auth": "basic abc"});
587        let redacted2 = redact_sensitive_values(&val2);
588        assert_eq!(redacted2["auth"], json!("[REDACTED]"));
589    }
590
591    #[test]
592    fn redacts_env_var_dollar_sign() {
593        let val = json!({"ref": "$SECRET"});
594        let redacted = redact_sensitive_values(&val);
595        assert_eq!(redacted["ref"], json!("[REDACTED]"));
596    }
597
598    #[test]
599    fn redacts_env_var_braced() {
600        let val = json!({"ref": "${API_KEY}"});
601        let redacted = redact_sensitive_values(&val);
602        assert_eq!(redacted["ref"], json!("[REDACTED]"));
603    }
604
605    #[test]
606    fn redacts_sensitive_key_password() {
607        let val = json!({"password": "hunter2"});
608        let redacted = redact_sensitive_values(&val);
609        assert_eq!(redacted["password"], json!("[REDACTED]"));
610    }
611
612    #[test]
613    fn redacts_sensitive_key_secret() {
614        let val = json!({"secret": "mysecret"});
615        let redacted = redact_sensitive_values(&val);
616        assert_eq!(redacted["secret"], json!("[REDACTED]"));
617    }
618
619    #[test]
620    fn redacts_sensitive_key_token() {
621        let val = json!({"Token": "abc"});
622        let redacted = redact_sensitive_values(&val);
623        assert_eq!(redacted["Token"], json!("[REDACTED]"));
624    }
625
626    #[test]
627    fn redacts_sensitive_key_api_key() {
628        let val = json!({"api_key": "abc"});
629        let redacted = redact_sensitive_values(&val);
630        assert_eq!(redacted["api_key"], json!("[REDACTED]"));
631    }
632
633    #[test]
634    fn redacts_sensitive_key_apikey() {
635        let val = json!({"apiKey": "abc"});
636        let redacted = redact_sensitive_values(&val);
637        assert_eq!(redacted["apiKey"], json!("[REDACTED]"));
638    }
639
640    #[test]
641    fn redacts_sensitive_key_authorization() {
642        let val = json!({"Authorization": "something"});
643        let redacted = redact_sensitive_values(&val);
644        assert_eq!(redacted["Authorization"], json!("[REDACTED]"));
645    }
646
647    #[test]
648    fn passes_through_non_sensitive_values() {
649        let val = json!({
650            "command": "echo hello",
651            "path": "/tmp/file.txt",
652            "count": 42,
653            "verbose": true,
654            "items": ["one", "two"]
655        });
656        let redacted = redact_sensitive_values(&val);
657        assert_eq!(redacted, val);
658    }
659
660    #[test]
661    fn redacts_nested_objects() {
662        let val = json!({
663            "config": {
664                "password": "secret123",
665                "host": "localhost"
666            }
667        });
668        let redacted = redact_sensitive_values(&val);
669        assert_eq!(redacted["config"]["password"], json!("[REDACTED]"));
670        assert_eq!(redacted["config"]["host"], json!("localhost"));
671    }
672
673    #[test]
674    fn redacts_values_in_arrays() {
675        let val = json!(["normal", "sk-secret", "also normal"]);
676        let redacted = redact_sensitive_values(&val);
677        assert_eq!(redacted, json!(["normal", "[REDACTED]", "also normal"]));
678    }
679
680    #[test]
681    fn handles_null_and_numbers() {
682        let val = json!({"a": null, "b": 42, "c": 2.72});
683        let redacted = redact_sensitive_values(&val);
684        assert_eq!(redacted, val);
685    }
686
687    // ─── validate_schema ──────────────────────────────────────────────────
688
689    #[test]
690    fn valid_schema_passes() {
691        let schema = json!({
692            "type": "object",
693            "properties": {
694                "name": { "type": "string" }
695            },
696            "required": ["name"]
697        });
698        assert!(validate_schema(&schema).is_ok());
699    }
700
701    #[test]
702    fn invalid_schema_returns_error() {
703        let schema = json!({
704            "type": "not_a_real_type"
705        });
706        assert!(validate_schema(&schema).is_err());
707    }
708
709    #[test]
710    fn empty_object_schema_is_valid() {
711        let schema = json!({
712            "type": "object",
713            "properties": {}
714        });
715        assert!(validate_schema(&schema).is_ok());
716    }
717
718    // ─── ApprovalMode ─────────────────────────────────────────────────────
719
720    #[test]
721    fn approval_mode_default_is_smart() {
722        assert_eq!(ApprovalMode::default(), ApprovalMode::Smart);
723    }
724
725    #[test]
726    fn approval_mode_variants_are_distinct() {
727        assert_ne!(ApprovalMode::Enabled, ApprovalMode::Smart);
728        assert_ne!(ApprovalMode::Smart, ApprovalMode::Bypassed);
729        assert_ne!(ApprovalMode::Enabled, ApprovalMode::Bypassed);
730    }
731
732    // ─── ToolMetadata ────────────────────────────────────────────────────
733
734    #[test]
735    fn tool_metadata_default_is_empty() {
736        let meta = ToolMetadata::default();
737        assert_eq!(meta.namespace, None);
738        assert_eq!(meta.version, None);
739    }
740
741    #[test]
742    fn tool_metadata_builder() {
743        let meta = ToolMetadata::with_namespace("filesystem").with_version("1.2.0");
744        assert_eq!(meta.namespace.as_deref(), Some("filesystem"));
745        assert_eq!(meta.version.as_deref(), Some("1.2.0"));
746    }
747
748    #[test]
749    fn agent_tool_metadata_defaults_to_none() {
750        let tool = stub_tool("minimal");
751        assert!(tool.metadata().is_none());
752    }
753
754    // T025: auth_config default returns None
755    #[test]
756    fn agent_tool_auth_config_defaults_to_none() {
757        let tool = stub_tool("no-auth");
758        assert!(tool.auth_config().is_none());
759    }
760
761    // ─── approval_context ────────────────────────────────────────────────
762
763    #[test]
764    fn approval_context_default_none() {
765        let tool = stub_tool("plain");
766        assert!(tool.approval_context(&json!({})).is_none());
767    }
768
769    #[test]
770    fn approval_context_returns_value() {
771        use crate::FnTool;
772
773        let tool = FnTool::new("ctx", "Ctx", "With context").with_approval_context(|params| {
774            Some(json!({"preview": format!("Will process: {}", params)}))
775        });
776
777        let ctx = tool.approval_context(&json!({"file": "test.txt"}));
778        assert!(ctx.is_some());
779        assert!(
780            ctx.unwrap()["preview"]
781                .as_str()
782                .unwrap()
783                .contains("test.txt")
784        );
785    }
786
787    #[test]
788    fn approval_context_panic_caught() {
789        use crate::FnTool;
790
791        let tool = FnTool::new("panicker", "Panicker", "Panics in context").with_approval_context(
792            |_params| {
793                panic!("oops");
794            },
795        );
796
797        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
798            tool.approval_context(&json!({}))
799        }));
800        // Panic is caught
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn approval_request_includes_context() {
806        let ctx = json!({"diff": "+new line"});
807        let req = ToolApprovalRequest {
808            tool_call_id: "call_1".into(),
809            tool_name: "write_file".into(),
810            arguments: json!({"path": "/tmp/test"}),
811            requires_approval: true,
812            context: Some(ctx.clone()),
813        };
814        assert_eq!(req.context, Some(ctx));
815    }
816
817    // ─── Transfer signal on AgentToolResult ─────────────────────────────
818
819    #[test]
820    fn transfer_constructor_sets_signal_and_text() {
821        use crate::transfer::TransferSignal;
822
823        let signal = TransferSignal::new("billing", "billing issue");
824        let result = AgentToolResult::transfer(signal);
825
826        assert!(result.is_transfer());
827        assert!(!result.is_error);
828        let text = match &result.content[0] {
829            ContentBlock::Text { text } => text.as_str(),
830            _ => panic!("expected text block"),
831        };
832        assert_eq!(text, "Transfer to billing initiated.");
833        assert!(result.transfer_signal.is_some());
834        let sig = result.transfer_signal.as_ref().unwrap();
835        assert_eq!(sig.target_agent(), "billing");
836        assert_eq!(sig.reason(), "billing issue");
837    }
838
839    #[test]
840    fn text_constructor_has_no_transfer_signal() {
841        let result = AgentToolResult::text("hello");
842        assert!(!result.is_transfer());
843        assert!(result.transfer_signal.is_none());
844    }
845
846    #[test]
847    fn error_constructor_has_no_transfer_signal() {
848        let result = AgentToolResult::error("something failed");
849        assert!(!result.is_transfer());
850        assert!(result.transfer_signal.is_none());
851    }
852
853    #[test]
854    fn deserialize_without_transfer_signal_defaults_to_none() {
855        let json = r#"{
856            "content": [{"type": "text", "text": "hello"}],
857            "details": null,
858            "is_error": false
859        }"#;
860        let result: AgentToolResult = serde_json::from_str(json).unwrap();
861        assert!(!result.is_transfer());
862        assert!(result.transfer_signal.is_none());
863    }
864
865    #[test]
866    fn transfer_signal_not_serialized_when_none() {
867        let result = AgentToolResult::text("hello");
868        let json = serde_json::to_value(&result).unwrap();
869        assert!(!json.as_object().unwrap().contains_key("transfer_signal"));
870    }
871}