Skip to main content

everruns_core/
tools.rs

1// Tool Abstraction for Agent Loop
2//
3// This module provides a high-level abstraction for tools that can be executed
4// by the agent loop. Tools are defined using the `Tool` trait and can be
5// registered with a `ToolRegistry` for use in the loop.
6//
7// Design decisions:
8// - Tools are defined via a trait for flexibility (function-style tools)
9// - ToolRegistry implements ToolExecutor for integration with the agent loop
10// - Error handling distinguishes between user-visible errors and internal errors
11// - Internal errors are logged but not exposed to the LLM (security)
12
13use async_trait::async_trait;
14use serde_json::Value;
15use std::collections::HashMap;
16use std::sync::Arc;
17use tracing::error;
18
19use crate::background::BackgroundExecutableTool;
20use crate::tool_types::{
21    BuiltinTool, DeferrablePolicy, ToolCall, ToolDefinition, ToolHints, ToolPolicy, ToolResult,
22    ToolResultImage,
23};
24use crate::{
25    tool_context::ToolContext, tool_context::ToolContextService, tool_context::ToolContextServices,
26};
27
28use crate::error::{AgentLoopError, Result};
29use crate::tool_execution::ToolExecutor;
30// EVE-888: `spawn_background`, its session-task mirroring, the background
31// event sink and the reattach path moved to `everruns-platform`
32// (`background_run`). Creating session tasks and schedules is hosted behaviour;
33// the kernel keeps the neutral `BackgroundExecutableTool`/`BackgroundEventSink`
34// contracts in `crate::background` and runs whatever a host supplies.
35
36// ============================================================================
37// Tool Execution Result - Error Handling Contract
38// ============================================================================
39
40/// Result of a tool execution.
41///
42/// This enum distinguishes between different outcomes:
43/// - `Success`: Tool executed successfully, result is returned to LLM
44/// - `SuccessWithImages`: Successful execution with JSON result plus images
45/// - `ToolError`: Tool-level error that should be shown to the LLM
46///   (e.g., "City not found", "Invalid date format")
47/// - `InternalError`: System-level error that should NOT be exposed to the LLM
48///   (e.g., database connection failure, API key issues)
49///
50/// # Security
51///
52/// Internal errors are logged but replaced with a generic message when
53/// returned to the LLM. This prevents leaking sensitive information like
54/// database errors, API keys, or internal system details.
55#[derive(Debug)]
56pub enum ToolExecutionResult {
57    /// Successful execution with a JSON result
58    Success(Value),
59
60    /// Successful execution with a JSON result and images.
61    /// Images are sent to the LLM as native image content blocks
62    /// (not stringified JSON), enabling visual understanding.
63    SuccessWithImages {
64        result: Value,
65        images: Vec<ToolResultImage>,
66    },
67
68    /// Tool-level error that is safe to show to the LLM
69    ///
70    /// Use this for expected error conditions that the LLM should know about,
71    /// such as validation errors, resource not found, etc.
72    ToolError(String),
73
74    /// Internal/system error that should NOT be exposed to the LLM
75    ///
76    /// Use this for unexpected errors like network failures, database errors,
77    /// or other internal issues. The error details will be logged but replaced
78    /// with a generic message when returned to the LLM.
79    InternalError(ToolInternalError),
80
81    /// A user connection is required to execute this tool.
82    ///
83    /// Instead of returning an error, this signals that the workflow should
84    /// pause and ask the client to set up a connection for the given provider.
85    /// The UI renders an inline connection dialog; once the user saves (or
86    /// cancels), a tool result is submitted and execution resumes.
87    ConnectionRequired {
88        /// Connection provider id (e.g. "daytona", "brave_search")
89        provider: String,
90    },
91}
92
93impl ToolExecutionResult {
94    /// Create a successful result
95    pub fn success(value: impl Into<Value>) -> Self {
96        ToolExecutionResult::Success(value.into())
97    }
98
99    /// Create a successful result with pre-truncation raw output for VFS persistence.
100    /// The raw output is transferred to `ToolResult.raw_output` during `into_tool_result()`.
101    pub fn success_with_raw_output(value: impl Into<Value>, raw_output: String) -> Self {
102        let mut value = value.into();
103        // Embed raw output in a sidecar key — extracted in into_tool_result().
104        // Non-object values are wrapped in a scalar carrier so raw_output still
105        // flows through; the carrier is unwrapped on extraction.
106        match value.as_object_mut() {
107            Some(obj)
108                if !obj.contains_key("_raw_output") && !obj.contains_key("_raw_output_scalar") =>
109            {
110                obj.insert("_raw_output".to_string(), Value::String(raw_output));
111            }
112            _ => {
113                // Wrap colliding object keys too: caller data must not be
114                // overwritten or mistaken for our scalar carrier on extraction.
115                value = serde_json::json!({
116                    "_raw_output_scalar": value,
117                    "_raw_output": raw_output,
118                });
119            }
120        }
121        ToolExecutionResult::Success(value)
122    }
123
124    /// Create a successful result with images
125    pub fn success_with_images(value: impl Into<Value>, images: Vec<ToolResultImage>) -> Self {
126        ToolExecutionResult::SuccessWithImages {
127            result: value.into(),
128            images,
129        }
130    }
131
132    /// Create a tool-level error (safe to show to LLM)
133    pub fn tool_error(message: impl Into<String>) -> Self {
134        ToolExecutionResult::ToolError(message.into())
135    }
136
137    /// Create an internal error (will be hidden from LLM)
138    pub fn internal_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
139        ToolExecutionResult::InternalError(ToolInternalError::new(error))
140    }
141
142    /// Create an internal error from a string message
143    pub fn internal_error_msg(message: impl Into<String>) -> Self {
144        ToolExecutionResult::InternalError(ToolInternalError::from_message(message))
145    }
146
147    /// Signal that a user connection is required before this tool can execute.
148    pub fn connection_required(provider: impl Into<String>) -> Self {
149        ToolExecutionResult::ConnectionRequired {
150            provider: provider.into(),
151        }
152    }
153
154    /// Check if this is a successful result
155    pub fn is_success(&self) -> bool {
156        matches!(
157            self,
158            ToolExecutionResult::Success(_) | ToolExecutionResult::SuccessWithImages { .. }
159        )
160    }
161
162    /// Check if this is an error (either tool error or internal error)
163    pub fn is_error(&self) -> bool {
164        matches!(
165            self,
166            ToolExecutionResult::ToolError(_) | ToolExecutionResult::InternalError(_)
167        )
168    }
169
170    /// Check if this requires a user connection setup
171    pub fn is_connection_required(&self) -> bool {
172        matches!(self, ToolExecutionResult::ConnectionRequired { .. })
173    }
174
175    /// Convert to a ToolResult for the agent loop
176    ///
177    /// Both tool errors and internal errors are packaged as `{"error": "..."}` in the
178    /// result field. This provides a consistent contract where the result field always
179    /// contains the payload, and the agent loop continues the same way for all outcomes.
180    ///
181    /// Internal errors are logged but replaced with a generic message when returned.
182    pub fn into_tool_result(self, tool_call_id: &str, tool_name: &str) -> ToolResult {
183        match self {
184            ToolExecutionResult::Success(mut value) => {
185                // Extract sidecar raw output if present (from success_with_raw_output)
186                let raw_output = value
187                    .as_object_mut()
188                    .and_then(|obj| obj.remove("_raw_output"))
189                    .and_then(|v| v.as_str().map(|s| s.to_string()));
190                // Unwrap scalar carrier only when it matches the exact wrapper shape
191                // set by success_with_raw_output for non-object inputs.
192                let result_value = if let Some(obj) = value.as_object_mut() {
193                    let is_scalar_carrier = raw_output.is_some()
194                        && obj.len() == 1
195                        && obj.contains_key("_raw_output_scalar");
196                    if is_scalar_carrier {
197                        obj.remove("_raw_output_scalar").unwrap_or(Value::Null)
198                    } else {
199                        value
200                    }
201                } else {
202                    value
203                };
204                ToolResult {
205                    tool_call_id: tool_call_id.to_string(),
206                    result: Some(result_value),
207                    images: None,
208                    error: None,
209                    connection_required: None,
210                    raw_output,
211                }
212            }
213            ToolExecutionResult::SuccessWithImages { result, images } => ToolResult {
214                tool_call_id: tool_call_id.to_string(),
215                result: Some(result),
216                images: if images.is_empty() {
217                    None
218                } else {
219                    Some(images)
220                },
221                error: None,
222                connection_required: None,
223                raw_output: None,
224            },
225            ToolExecutionResult::ToolError(message) => ToolResult {
226                tool_call_id: tool_call_id.to_string(),
227                result: Some(serde_json::json!({ "error": &message })),
228                images: None,
229                error: Some(message),
230                connection_required: None,
231                raw_output: None,
232            },
233            ToolExecutionResult::InternalError(err) => {
234                // Log the full error details for debugging
235                error!(
236                    tool_name = %tool_name,
237                    tool_call_id = %tool_call_id,
238                    error = %err.message,
239                    error_chain = %err.chain_string(),
240                    "Tool internal error (details hidden from LLM)"
241                );
242
243                // Return generic error message to LLM, packaged as {"error": "..."}
244                let generic_msg = "An internal error occurred while executing the tool";
245                ToolResult {
246                    tool_call_id: tool_call_id.to_string(),
247                    result: Some(serde_json::json!({
248                        "error": generic_msg
249                    })),
250                    images: None,
251                    error: Some(generic_msg.to_string()),
252                    connection_required: None,
253                    raw_output: None,
254                }
255            }
256            ToolExecutionResult::ConnectionRequired { ref provider } => ToolResult {
257                tool_call_id: tool_call_id.to_string(),
258                result: Some(serde_json::json!({
259                    "connection_required": provider,
260                })),
261                images: None,
262                error: None,
263                connection_required: Some(provider.clone()),
264                raw_output: None,
265            },
266        }
267    }
268}
269
270/// Internal error details (logged but not exposed to LLM)
271#[derive(Debug)]
272pub struct ToolInternalError {
273    /// Error message for logging
274    pub message: String,
275    /// Optional source error
276    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
277}
278
279impl ToolInternalError {
280    /// Create from an error
281    pub fn new(error: impl std::error::Error + Send + Sync + 'static) -> Self {
282        Self {
283            message: error.to_string(),
284            source: Some(Box::new(error)),
285        }
286    }
287
288    /// Create from a string message
289    pub fn from_message(message: impl Into<String>) -> Self {
290        Self {
291            message: message.into(),
292            source: None,
293        }
294    }
295
296    pub fn chain_string(&self) -> String {
297        let mut parts = vec![self.message.clone()];
298        let mut current = <Self as std::error::Error>::source(self);
299        while let Some(source) = current {
300            let message = source.to_string();
301            if parts.last() != Some(&message) {
302                parts.push(message);
303            }
304            current = source.source();
305        }
306        parts.join(": ")
307    }
308}
309
310impl std::fmt::Display for ToolInternalError {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        write!(f, "{}", self.message)
313    }
314}
315
316impl std::error::Error for ToolInternalError {
317    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
318        self.source
319            .as_ref()
320            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
321    }
322}
323
324// ============================================================================
325// Tool Trait - Core Tool Abstraction
326// ============================================================================
327
328/// Trait for implementing tools that can be executed by the agent loop.
329///
330/// # Example
331///
332/// ```ignore
333/// use async_trait::async_trait;
334/// use serde_json::{json, Value};
335///
336/// struct GetCurrentTime;
337///
338/// #[async_trait]
339/// impl Tool for GetCurrentTime {
340///     fn name(&self) -> &str {
341///         "get_current_time"
342///     }
343///
344///     fn description(&self) -> &str {
345///         "Get the current date and time"
346///     }
347///
348///     fn parameters_schema(&self) -> Value {
349///         json!({
350///             "type": "object",
351///             "properties": {
352///                 "timezone": {
353///                     "type": "string",
354///                     "description": "Timezone (e.g., 'UTC', 'America/New_York')"
355///                 }
356///             }
357///         })
358///     }
359///
360///     async fn execute(&self, arguments: Value) -> ToolExecutionResult {
361///         let timezone = arguments.get("timezone")
362///             .and_then(|v| v.as_str())
363///             .unwrap_or("UTC");
364///
365///         ToolExecutionResult::success(json!({
366///             "current_time": chrono::Utc::now().to_rfc3339(),
367///             "timezone": timezone
368///         }))
369///     }
370/// }
371/// ```
372#[async_trait]
373pub trait Tool: Send + Sync {
374    /// Returns the tool's unique name.
375    ///
376    /// This name is used by the LLM to invoke the tool and must be unique
377    /// within a ToolRegistry.
378    fn name(&self) -> &str;
379
380    /// Returns a human-readable display name for UI rendering.
381    ///
382    /// This name is shown to users in the UI instead of the technical tool name.
383    /// For example, "Get Current Time" instead of "get_current_time".
384    /// Returns None if no display name is set, in which case the UI may
385    /// fall back to the technical name.
386    fn display_name(&self) -> Option<&str> {
387        None
388    }
389
390    /// Returns a description of what the tool does.
391    ///
392    /// This description is provided to the LLM to help it understand
393    /// when and how to use the tool.
394    fn description(&self) -> &str;
395
396    /// Returns the JSON schema for the tool's parameters.
397    ///
398    /// This schema follows the JSON Schema specification and describes
399    /// the expected arguments for the tool. The LLM uses this to
400    /// generate valid tool calls.
401    fn parameters_schema(&self) -> Value;
402
403    /// Execute the tool with the given arguments.
404    ///
405    /// # Arguments
406    ///
407    /// * `arguments` - The arguments passed to the tool as a JSON value.
408    ///   These should conform to the schema returned by `parameters_schema()`.
409    ///
410    /// # Returns
411    ///
412    /// A `ToolExecutionResult` indicating success, tool error, or internal error.
413    async fn execute(&self, arguments: Value) -> ToolExecutionResult;
414
415    /// Execute the tool with context.
416    ///
417    /// This method provides access to runtime context like session ID and
418    /// optional stores (file store, etc.). Override this method for tools
419    /// that need access to session context or external resources.
420    ///
421    /// The default implementation simply calls `execute()`, ignoring the context.
422    ///
423    /// # Arguments
424    ///
425    /// * `arguments` - The arguments passed to the tool as a JSON value.
426    /// * `context` - Runtime context containing session ID and optional stores.
427    ///
428    /// # Returns
429    ///
430    /// A `ToolExecutionResult` indicating success, tool error, or internal error.
431    async fn execute_with_context(
432        &self,
433        arguments: Value,
434        _context: &ToolContext,
435    ) -> ToolExecutionResult {
436        // Default: delegate to execute(), ignoring context
437        self.execute(arguments).await
438    }
439
440    /// Returns true if this tool requires context for execution.
441    ///
442    /// Tools that need session context (like filesystem tools) should
443    /// override this to return true.
444    fn requires_context(&self) -> bool {
445        false
446    }
447
448    /// Runtime services that must be present before this tool can be exposed.
449    ///
450    /// Context-aware tools should declare hard requirements here. Optional
451    /// services that only enable extra behavior should not be listed.
452    fn required_context_services(&self) -> &'static [ToolContextService] {
453        &[]
454    }
455
456    /// Returns the tool policy (auto or requires_approval).
457    ///
458    /// Default is `Auto` which means the tool executes immediately.
459    /// Override to return `RequiresApproval` for sensitive operations.
460    fn policy(&self) -> ToolPolicy {
461        ToolPolicy::Auto
462    }
463
464    /// Returns semantic hints describing the tool's behavioral properties.
465    ///
466    /// Override to provide hints like readonly, destructive, idempotent, etc.
467    /// Default is empty (all hints unspecified).
468    fn hints(&self) -> ToolHints {
469        ToolHints::default()
470    }
471
472    /// Returns backend-authored narration for a call to this tool, e.g.
473    /// "Read AGENTS.md".
474    ///
475    /// The owning capability's default [`crate::capabilities::Capability::narrate`]
476    /// dispatches here for the tool whose `name()` matches the call. Return
477    /// `None` to accept the generic `narration_noun`/display-name fallback.
478    /// Implementations should use the phrasing helpers in
479    /// [`crate::tool_narration`] (`narrate_read_file`, `narrate_shell_exec`, …)
480    /// so wording and localization stay consistent.
481    fn narrate(
482        &self,
483        _tool_call: &crate::tool_types::ToolCall,
484        _phase: crate::tool_narration::ToolNarrationPhase,
485        _locale: Option<&str>,
486        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
487    ) -> Option<String> {
488        None
489    }
490
491    /// Returns native background execution support when this tool opts into
492    /// detached execution via `hints().supports_background`.
493    fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
494        None
495    }
496
497    /// Deferral policy for progressive tool-schema disclosure (tool search).
498    /// Hot-path or "consult-first" tools can return [`DeferrablePolicy::Never`]
499    /// to always keep their full schema directly callable. Defaults to
500    /// [`DeferrablePolicy::Automatic`].
501    fn deferrable_policy(&self) -> DeferrablePolicy {
502        DeferrablePolicy::default()
503    }
504
505    /// Convert this tool to a ToolDefinition for the agent config.
506    ///
507    /// This is used by ToolRegistry to generate tool definitions
508    /// for the LLM provider.
509    fn to_definition(&self) -> ToolDefinition {
510        ToolDefinition::Builtin(BuiltinTool {
511            name: self.name().to_string(),
512            display_name: self.display_name().map(|s| s.to_string()),
513            description: self.description().to_string(),
514            parameters: self.parameters_schema(),
515            policy: self.policy(),
516            category: None,
517            deferrable: self.deferrable_policy(),
518            hints: self.hints(),
519            full_parameters: None,
520        })
521    }
522}
523
524// ============================================================================
525// ToolRegistry - Collection of Tools
526// ============================================================================
527
528/// A registry that holds multiple tools and implements ToolExecutor.
529///
530/// ToolRegistry provides a convenient way to manage multiple tools and
531/// integrate them with the agent loop. It implements `ToolExecutor` so
532/// it can be used directly with `AgentLoop`.
533///
534/// # Example
535///
536/// ```ignore
537/// use everruns_core::tools::{Tool, ToolRegistry};
538///
539/// // Create registry and add tools
540/// let mut registry = ToolRegistry::new();
541/// registry.register(Box::new(GetCurrentTime));
542/// registry.register(Box::new(GetWeather));
543///
544/// // Get tool definitions for agent config
545/// let definitions = registry.tool_definitions();
546///
547/// // Use with agent loop
548/// let agent_loop = AgentLoop::new(config, emitter, store, llm, registry);
549/// ```
550#[derive(Default, Clone)]
551pub struct ToolRegistry {
552    tools: HashMap<String, Arc<dyn Tool>>,
553}
554
555impl ToolRegistry {
556    /// Create a new empty tool registry
557    pub fn new() -> Self {
558        Self {
559            tools: HashMap::new(),
560        }
561    }
562
563    /// Create a tool registry with default built-in tools.
564    ///
565    /// This includes `report_progress`, the neutral progress-reporting
566    /// contract tool. Test doubles such as echo tools belong to test-support
567    /// or the test that owns them.
568    ///
569    /// Test fixture tools (test math/weather) are NOT included: they moved to
570    /// the `everruns-test-support` crate (EVE-875) and are registered
571    /// explicitly by tests that need them.
572    pub fn with_defaults() -> Self {
573        use crate::progress_reporting::ReportProgressTool;
574
575        let builder = ToolRegistry::builder()
576            // NOTE: `spawn_background` is intentionally NOT a default tool —
577            // it is contributed by the `background_execution` capability,
578            // which is auto-activated by
579            // `collect_capabilities_with_configs` whenever a collected tool
580            // declares `ToolHints::supports_background = Some(true)`. Keeping
581            // it out of defaults preserves the lockstep contract between
582            // model-visible tools and the worker execution registry: the
583            // executor only knows about `spawn_background` when the model
584            // can also see it.
585            .tool(ReportProgressTool);
586
587        builder.build()
588    }
589
590    /// Create a tool registry for autonomous scheduled monitor probes.
591    ///
592    /// Probe execution currently uses a scheduler-local [`ToolContext`] instead
593    /// of the fully populated worker/API executor context. Keep this registry to
594    /// context-free tools so scheduled probes cannot bypass session-scoped
595    /// controls such as network ACLs, egress routing, storage, or filesystem
596    /// mediation.
597    pub fn with_monitor_probe_defaults() -> Self {
598        Self::new()
599    }
600
601    /// Register a tool with the registry.
602    ///
603    /// If a tool with the same name already exists, it will be replaced.
604    pub fn register(&mut self, tool: impl Tool + 'static) {
605        self.tools.insert(tool.name().to_string(), Arc::new(tool));
606    }
607
608    /// Register a boxed tool
609    pub fn register_boxed(&mut self, tool: Box<dyn Tool>) {
610        self.tools.insert(tool.name().to_string(), Arc::from(tool));
611    }
612
613    /// Register an Arc-wrapped tool
614    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
615        self.tools.insert(tool.name().to_string(), tool);
616    }
617
618    /// Get a tool by name
619    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
620        self.tools.get(name)
621    }
622
623    /// Check if a tool is registered
624    pub fn has(&self, name: &str) -> bool {
625        self.tools.contains_key(name)
626    }
627
628    /// Get the number of registered tools
629    pub fn len(&self) -> usize {
630        self.tools.len()
631    }
632
633    /// Check if the registry is empty
634    pub fn is_empty(&self) -> bool {
635        self.tools.is_empty()
636    }
637
638    /// Get all tool names
639    pub fn tool_names(&self) -> Vec<&str> {
640        self.tools.keys().map(|s| s.as_str()).collect()
641    }
642
643    /// Get tool definitions for use in RuntimeAgent.
644    ///
645    /// Returns a Vec of ToolDefinition that can be passed to
646    /// `RuntimeAgent::with_tools()`.
647    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
648        self.tools.values().map(|t| t.to_definition()).collect()
649    }
650
651    /// Fail configuration before model exposure when a registered tool is
652    /// missing a runtime service it declares as required.
653    pub fn validate_context_services(&self, services: &ToolContextServices) -> Result<()> {
654        let mut tools: Vec<_> = self.tools.values().collect();
655        tools.sort_by_key(|tool| tool.name());
656        for tool in tools {
657            for service in tool.required_context_services() {
658                if !services.provides(*service) {
659                    return Err(crate::error::AgentLoopError::config(format!(
660                        "tool \"{}\" requires unavailable ToolContext service {}",
661                        tool.name(),
662                        service.name(),
663                    )));
664                }
665            }
666        }
667        Ok(())
668    }
669
670    /// Remove a tool from the registry
671    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
672        self.tools.remove(name)
673    }
674
675    /// Clear all tools from the registry
676    pub fn clear(&mut self) {
677        self.tools.clear();
678    }
679
680    /// Create a builder for fluent tool registration
681    pub fn builder() -> ToolRegistryBuilder {
682        ToolRegistryBuilder::new()
683    }
684}
685
686impl std::fmt::Debug for ToolRegistry {
687    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
688        f.debug_struct("ToolRegistry")
689            .field("tools", &self.tool_names())
690            .finish()
691    }
692}
693
694fn validate_tool_arguments(tool: &dyn Tool, tool_call: &ToolCall) -> Result<Option<String>> {
695    let arguments = tool_call.execution_arguments();
696    let definition = tool.to_definition();
697    let validator = jsonschema::validator_for(definition.parameters()).map_err(|error| {
698        AgentLoopError::config(format!(
699            "Tool '{}' has an invalid parameters schema: {error}",
700            tool_call.name
701        ))
702    })?;
703    let issues: Vec<_> = validator
704        .iter_errors(&arguments)
705        .map(|error| {
706            serde_json::json!({
707                "instance_path": error.instance_path().to_string(),
708                "message": error.to_string(),
709                "schema_path": error.schema_path().to_string(),
710            })
711        })
712        .collect();
713    if issues.is_empty() {
714        return Ok(None);
715    }
716
717    Ok(Some(
718        serde_json::json!({
719            "code": "invalid_tool_arguments",
720            "tool": tool_call.name,
721            "issues": issues,
722        })
723        .to_string(),
724    ))
725}
726
727#[async_trait]
728impl ToolExecutor for ToolRegistry {
729    async fn execute(
730        &self,
731        tool_call: &ToolCall,
732        _tool_def: &ToolDefinition,
733    ) -> Result<ToolResult> {
734        let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
735            crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
736        })?;
737
738        if let Some(error) = validate_tool_arguments(tool.as_ref(), tool_call)? {
739            return Ok(ToolExecutionResult::tool_error(error)
740                .into_tool_result(&tool_call.id, &tool_call.name));
741        }
742
743        let result = tool.execute(tool_call.execution_arguments()).await;
744        Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
745    }
746
747    async fn execute_with_context(
748        &self,
749        tool_call: &ToolCall,
750        _tool_def: &ToolDefinition,
751        context: &ToolContext,
752    ) -> Result<ToolResult> {
753        let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
754            crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
755        })?;
756
757        if let Some(error) = validate_tool_arguments(tool.as_ref(), tool_call)? {
758            return Ok(ToolExecutionResult::tool_error(error)
759                .into_tool_result(&tool_call.id, &tool_call.name));
760        }
761
762        // Context-aware tools use the supplied context; regular tools delegate to execute().
763        let result = tool
764            .execute_with_context(tool_call.execution_arguments(), context)
765            .await;
766        Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
767    }
768}
769
770// ============================================================================
771// ToolRegistryBuilder - Fluent API for Building Registry
772// ============================================================================
773
774/// Builder for creating a ToolRegistry with a fluent API.
775///
776/// # Example
777///
778/// ```ignore
779/// let registry = ToolRegistry::builder()
780///     .tool(GetCurrentTime)
781///     .tool(GetWeather)
782///     .build();
783/// ```
784pub struct ToolRegistryBuilder {
785    registry: ToolRegistry,
786}
787
788impl ToolRegistryBuilder {
789    /// Create a new builder
790    pub fn new() -> Self {
791        Self {
792            registry: ToolRegistry::new(),
793        }
794    }
795
796    /// Add a tool to the registry
797    pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
798        self.registry.register(tool);
799        self
800    }
801
802    /// Add a boxed tool to the registry
803    pub fn tool_boxed(mut self, tool: Box<dyn Tool>) -> Self {
804        self.registry.register_boxed(tool);
805        self
806    }
807
808    /// Add an Arc-wrapped tool to the registry
809    pub fn tool_arc(mut self, tool: Arc<dyn Tool>) -> Self {
810        self.registry.register_arc(tool);
811        self
812    }
813
814    /// Build the registry
815    pub fn build(self) -> ToolRegistry {
816        self.registry
817    }
818}
819
820impl Default for ToolRegistryBuilder {
821    fn default() -> Self {
822        Self::new()
823    }
824}
825
826// ============================================================================
827// Built-in Tools
828// ============================================================================
829
830/// A tool that echoes back its arguments (useful for testing).
831#[cfg(test)]
832pub struct EchoTool;
833
834#[cfg(test)]
835#[async_trait]
836impl Tool for EchoTool {
837    fn name(&self) -> &str {
838        "echo"
839    }
840
841    fn display_name(&self) -> Option<&str> {
842        Some("Echo")
843    }
844
845    fn description(&self) -> &str {
846        "Echo back the provided message. Useful for testing tool execution."
847    }
848
849    fn parameters_schema(&self) -> Value {
850        serde_json::json!({
851            "type": "object",
852            "properties": {
853                "message": {
854                    "type": "string",
855                    "description": "The message to echo back"
856                }
857            },
858            "required": ["message"],
859            "additionalProperties": false
860        })
861    }
862
863    fn hints(&self) -> ToolHints {
864        ToolHints::default()
865            .with_readonly(true)
866            .with_idempotent(true)
867    }
868
869    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
870        let message = arguments
871            .get("message")
872            .and_then(|v| v.as_str())
873            .unwrap_or("");
874
875        ToolExecutionResult::success(serde_json::json!({
876            "echoed": message,
877            "length": message.len()
878        }))
879    }
880}
881
882/// A tool that always fails (useful for testing error handling).
883#[cfg(test)]
884pub struct FailingTool {
885    error_message: String,
886    use_internal_error: bool,
887}
888
889#[cfg(test)]
890impl FailingTool {
891    /// Create a failing tool with a tool-level error
892    pub fn with_tool_error(message: impl Into<String>) -> Self {
893        Self {
894            error_message: message.into(),
895            use_internal_error: false,
896        }
897    }
898
899    /// Create a failing tool with an internal error
900    pub fn with_internal_error(message: impl Into<String>) -> Self {
901        Self {
902            error_message: message.into(),
903            use_internal_error: true,
904        }
905    }
906}
907
908#[cfg(test)]
909impl Default for FailingTool {
910    fn default() -> Self {
911        Self::with_tool_error("Tool execution failed")
912    }
913}
914
915#[cfg(test)]
916#[async_trait]
917impl Tool for FailingTool {
918    fn name(&self) -> &str {
919        "failing_tool"
920    }
921
922    fn display_name(&self) -> Option<&str> {
923        Some("Failing Tool")
924    }
925
926    fn description(&self) -> &str {
927        "A tool that always fails (for testing error handling)"
928    }
929
930    fn parameters_schema(&self) -> Value {
931        serde_json::json!({
932            "type": "object",
933            "properties": {},
934            "additionalProperties": false
935        })
936    }
937
938    fn hints(&self) -> ToolHints {
939        ToolHints::default()
940            .with_readonly(true)
941            .with_idempotent(true)
942    }
943
944    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
945        if self.use_internal_error {
946            ToolExecutionResult::internal_error_msg(&self.error_message)
947        } else {
948            ToolExecutionResult::tool_error(&self.error_message)
949        }
950    }
951}
952
953// ============================================================================
954// Tests
955// ============================================================================
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    struct CountingTool {
962        calls: Arc<std::sync::atomic::AtomicUsize>,
963        label: &'static str,
964    }
965
966    #[async_trait]
967    impl Tool for CountingTool {
968        fn name(&self) -> &str {
969            "counting"
970        }
971        fn display_name(&self) -> Option<&str> {
972            Some(self.label)
973        }
974        fn description(&self) -> &str {
975            "Count validated dispatches"
976        }
977        fn parameters_schema(&self) -> Value {
978            serde_json::json!({"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false})
979        }
980        fn policy(&self) -> ToolPolicy {
981            ToolPolicy::RequiresApproval
982        }
983        fn deferrable_policy(&self) -> DeferrablePolicy {
984            DeferrablePolicy::Never
985        }
986        fn hints(&self) -> ToolHints {
987            ToolHints::default()
988                .with_readonly(true)
989                .with_idempotent(true)
990        }
991        async fn execute(&self, arguments: Value) -> ToolExecutionResult {
992            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
993            ToolExecutionResult::success(
994                serde_json::json!({"label":self.label,"arguments":arguments}),
995            )
996        }
997    }
998
999    #[tokio::test]
1000    async fn registry_registration_paths_replace_and_dispatch_complete_definitions() {
1001        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1002        let tool = |label| CountingTool {
1003            calls: calls.clone(),
1004            label,
1005        };
1006        let mut registry = ToolRegistry::builder()
1007            .tool(tool("first"))
1008            .tool_boxed(Box::new(tool("boxed")))
1009            .tool_arc(Arc::new(tool("last")))
1010            .build();
1011        assert_eq!(registry.tool_names(), ["counting"]);
1012        let definitions = registry.tool_definitions();
1013        assert_eq!(definitions.len(), 1);
1014        let ToolDefinition::Builtin(definition) = &definitions[0] else {
1015            panic!("builtin expected")
1016        };
1017        assert_eq!(definition.name, "counting");
1018        assert_eq!(definition.display_name.as_deref(), Some("last"));
1019        assert_eq!(definition.description, "Count validated dispatches");
1020        assert_eq!(
1021            definition.parameters,
1022            serde_json::json!({"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false})
1023        );
1024        assert_eq!(definition.policy, ToolPolicy::RequiresApproval);
1025        assert_eq!(definition.deferrable, DeferrablePolicy::Never);
1026        assert_eq!(
1027            definition.hints,
1028            ToolHints::default()
1029                .with_readonly(true)
1030                .with_idempotent(true)
1031        );
1032        assert!(definition.category.is_none());
1033        assert!(definition.full_parameters.is_none());
1034        let call = ToolCall {
1035            id: "dispatch-id".into(),
1036            name: "counting".into(),
1037            arguments: serde_json::json!({"message":"payload"}),
1038        };
1039        let result = registry.execute(&call, &definitions[0]).await.unwrap();
1040        assert_eq!(result.tool_call_id, "dispatch-id");
1041        assert_eq!(
1042            result.result,
1043            Some(serde_json::json!({"label":"last","arguments":{"message":"payload"}}))
1044        );
1045        assert!(result.error.is_none());
1046        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1047        assert_eq!(
1048            registry.unregister("counting").unwrap().display_name(),
1049            Some("last")
1050        );
1051        assert!(registry.is_empty());
1052        assert!(registry.unregister("counting").is_none());
1053        registry.register(tool("again"));
1054        registry.clear();
1055        assert!(registry.tool_definitions().is_empty());
1056    }
1057
1058    #[tokio::test]
1059    async fn registry_errors_preserve_public_failures_and_hide_internal_details() {
1060        for (tool, expected) in [
1061            (FailingTool::with_tool_error("Invalid city"), "Invalid city"),
1062            (
1063                FailingTool::with_internal_error("PRIVATE-DATABASE-TOKEN"),
1064                "An internal error occurred while executing the tool",
1065            ),
1066        ] {
1067            let registry = ToolRegistry::builder().tool(tool).build();
1068            let call = ToolCall {
1069                id: "failure-id".into(),
1070                name: "failing_tool".into(),
1071                arguments: serde_json::json!({}),
1072            };
1073            let result = registry
1074                .execute(&call, &registry.tool_definitions()[0])
1075                .await
1076                .unwrap();
1077            assert_eq!(result.tool_call_id, "failure-id");
1078            assert_eq!(result.error.as_deref(), Some(expected));
1079            assert_eq!(result.result, Some(serde_json::json!({"error":expected})));
1080            assert!(
1081                !serde_json::to_string(&result)
1082                    .unwrap()
1083                    .contains("PRIVATE-DATABASE-TOKEN")
1084            );
1085        }
1086    }
1087
1088    struct RequiresOrgId;
1089
1090    #[async_trait]
1091    impl Tool for RequiresOrgId {
1092        fn name(&self) -> &str {
1093            "requires_org_id"
1094        }
1095
1096        fn description(&self) -> &str {
1097            "Exercises required ToolContext service validation"
1098        }
1099
1100        fn parameters_schema(&self) -> Value {
1101            serde_json::json!({"type": "object", "additionalProperties": false})
1102        }
1103
1104        fn required_context_services(&self) -> &'static [ToolContextService] {
1105            &[ToolContextService::OrgId]
1106        }
1107
1108        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1109            ToolExecutionResult::success(Value::Null)
1110        }
1111    }
1112
1113    #[test]
1114    fn required_context_service_validation_is_structured() {
1115        let mut registry = ToolRegistry::new();
1116        registry.register(RequiresOrgId);
1117
1118        let error = registry
1119            .validate_context_services(&ToolContextServices::default())
1120            .expect_err("missing required service must fail before tool exposure");
1121
1122        assert!(matches!(
1123            error,
1124            crate::AgentLoopError::Configuration(message)
1125                if message.contains("requires_org_id") && message.contains("OrgId")
1126        ));
1127    }
1128
1129    #[test]
1130    fn required_context_service_validation_accepts_supplied_service() {
1131        let mut registry = ToolRegistry::new();
1132        registry.register(RequiresOrgId);
1133        let services = ToolContextServices {
1134            org_id: Some(crate::typed_id::OrgId::from_seed(1)),
1135            ..ToolContextServices::default()
1136        };
1137
1138        registry
1139            .validate_context_services(&services)
1140            .expect("advertised required service should validate");
1141    }
1142
1143    #[test]
1144    fn test_tool_result_conversion() {
1145        // Success
1146        let result = ToolExecutionResult::success(serde_json::json!({"value": 42}));
1147        let tool_result = result.into_tool_result("call_1", "test_tool");
1148        assert_eq!(tool_result.tool_call_id, "call_1");
1149        assert!(tool_result.error.is_none());
1150        assert!(tool_result.images.is_none());
1151        assert!(tool_result.connection_required.is_none());
1152        assert!(tool_result.raw_output.is_none());
1153        assert_eq!(tool_result.result, Some(serde_json::json!({"value": 42})));
1154
1155        // Tool error (packaged as {"error": "..."} in result field, also sets error)
1156        let result = ToolExecutionResult::tool_error("Invalid input");
1157        let tool_result = result.into_tool_result("call_2", "test_tool");
1158        assert_eq!(tool_result.error.as_deref(), Some("Invalid input"));
1159        assert_eq!(
1160            tool_result.result.unwrap(),
1161            serde_json::json!({"error": "Invalid input"})
1162        );
1163
1164        // Internal error (packaged as {"error": "..."} with generic message)
1165        let result = ToolExecutionResult::internal_error_msg("Secret database error");
1166        let tool_result = result.into_tool_result("call_3", "test_tool");
1167        assert_eq!(
1168            tool_result.error.as_deref(),
1169            Some("An internal error occurred while executing the tool")
1170        );
1171        assert_eq!(
1172            tool_result.result.unwrap(),
1173            serde_json::json!({"error": "An internal error occurred while executing the tool"})
1174        );
1175    }
1176
1177    #[test]
1178    fn test_success_with_raw_output_object_preserves_shape() {
1179        let res = ToolExecutionResult::success_with_raw_output(
1180            serde_json::json!({"stdout": "hello"}),
1181            "raw stdout bytes".to_string(),
1182        );
1183        let tr = res.into_tool_result("call_1", "demo");
1184        assert_eq!(tr.result.as_ref().unwrap()["stdout"], "hello");
1185        assert!(
1186            tr.result
1187                .as_ref()
1188                .unwrap()
1189                .as_object()
1190                .unwrap()
1191                .get("_raw_output")
1192                .is_none(),
1193            "sidecar key must not leak to the LLM-visible result"
1194        );
1195        assert_eq!(tr.raw_output.as_deref(), Some("raw stdout bytes"));
1196    }
1197
1198    #[test]
1199    fn raw_output_round_trips_all_nonobject_shapes_without_serializing_sidecar() {
1200        for value in [
1201            serde_json::json!("compact summary"),
1202            Value::Null,
1203            serde_json::json!(false),
1204            serde_json::json!(42),
1205            serde_json::json!(["a", 2]),
1206        ] {
1207            let result =
1208                ToolExecutionResult::success_with_raw_output(value.clone(), "PRIVATE-RAW".into())
1209                    .into_tool_result("raw-id", "demo");
1210            assert_eq!(result.result, Some(value));
1211            assert_eq!(result.raw_output.as_deref(), Some("PRIVATE-RAW"));
1212            assert!(
1213                !serde_json::to_string(&result)
1214                    .unwrap()
1215                    .contains("PRIVATE-RAW")
1216            );
1217        }
1218    }
1219
1220    #[test]
1221    fn test_success_result_with_raw_output_scalar_key_is_not_unwrapped() {
1222        let res = ToolExecutionResult::success(
1223            serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}),
1224        );
1225        let tr = res.into_tool_result("call_1", "demo");
1226        assert_eq!(
1227            tr.result,
1228            Some(serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}))
1229        );
1230        assert_eq!(tr.raw_output, None);
1231    }
1232
1233    #[test]
1234    fn test_success_result_with_only_raw_output_scalar_key_is_not_unwrapped() {
1235        // Single-key object with _raw_output_scalar must not be mistaken for a
1236        // success_with_raw_output carrier when raw_output is absent.
1237        let res = ToolExecutionResult::success(serde_json::json!({"_raw_output_scalar": "v"}));
1238        let tr = res.into_tool_result("call_1", "demo");
1239        assert_eq!(
1240            tr.result,
1241            Some(serde_json::json!({"_raw_output_scalar": "v"}))
1242        );
1243        assert_eq!(tr.raw_output, None);
1244    }
1245
1246    #[tokio::test]
1247    async fn invalid_arguments_never_dispatch_through_either_executor_path() {
1248        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1249        let registry = ToolRegistry::builder()
1250            .tool(CountingTool {
1251                calls: calls.clone(),
1252                label: "validated",
1253            })
1254            .build();
1255        let definition = registry.tool_definitions().remove(0);
1256        let context = ToolContext::new(crate::typed_id::SessionId::new());
1257        for (arguments, instance, keyword) in [
1258            (serde_json::json!({}), "", "required"),
1259            (serde_json::json!({"message":42}), "/message", "type"),
1260            (
1261                serde_json::json!({"message":"ok","unexpected":true}),
1262                "",
1263                "additionalProperties",
1264            ),
1265        ] {
1266            let call = ToolCall {
1267                id: "invalid-id".into(),
1268                name: "counting".into(),
1269                arguments,
1270            };
1271            for with_context in [false, true] {
1272                let result = if with_context {
1273                    registry
1274                        .execute_with_context(&call, &definition, &context)
1275                        .await
1276                        .unwrap()
1277                } else {
1278                    registry.execute(&call, &definition).await.unwrap()
1279                };
1280                assert_eq!(result.tool_call_id, "invalid-id");
1281                let message = result.error.unwrap();
1282                assert_eq!(result.result, Some(serde_json::json!({"error":message})));
1283                let error: Value = serde_json::from_str(&message).unwrap();
1284                assert_eq!(error["code"], "invalid_tool_arguments");
1285                assert_eq!(error["tool"], "counting");
1286                let issues = error["issues"].as_array().unwrap();
1287                assert_eq!(issues.len(), 1);
1288                assert_eq!(issues[0]["instance_path"], instance);
1289                assert!(issues[0]["schema_path"].as_str().unwrap().contains(keyword));
1290                assert!(!issues[0]["message"].as_str().unwrap().is_empty());
1291            }
1292        }
1293        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
1294        let valid = ToolCall {
1295            id: "valid-id".into(),
1296            name: "counting".into(),
1297            arguments: serde_json::json!({"message":"accepted"}),
1298        };
1299        let result = registry
1300            .execute_with_context(&valid, &definition, &context)
1301            .await
1302            .unwrap();
1303        assert_eq!(
1304            result.result,
1305            Some(serde_json::json!({"label":"validated","arguments":{"message":"accepted"}}))
1306        );
1307        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1308    }
1309
1310    #[test]
1311    fn result_variants_keep_images_connections_and_classification_distinct() {
1312        use serde_json::json;
1313        for (result, classification, expected) in [
1314            (
1315                ToolExecutionResult::success_with_images(
1316                    json!({"page":2}),
1317                    vec![ToolResultImage {
1318                        base64: "aW1hZ2U=".into(),
1319                        media_type: "image/jpeg".into(),
1320                    }],
1321                ),
1322                (true, false, false),
1323                json!({"tool_call_id":"variant-id","result":{"page":2},"error":null,"images":[{"base64":"aW1hZ2U=","media_type":"image/jpeg"}]}),
1324            ),
1325            (
1326                ToolExecutionResult::success_with_images(Value::Null, vec![]),
1327                (true, false, false),
1328                json!({"tool_call_id":"variant-id","result":null,"error":null}),
1329            ),
1330            (
1331                ToolExecutionResult::connection_required("daytona"),
1332                (false, false, true),
1333                json!({"tool_call_id":"variant-id","result":{"connection_required":"daytona"},"error":null,"connection_required":"daytona"}),
1334            ),
1335            (
1336                ToolExecutionResult::tool_error("visible"),
1337                (false, true, false),
1338                json!({"tool_call_id":"variant-id","result":{"error":"visible"},"error":"visible"}),
1339            ),
1340            (
1341                ToolExecutionResult::internal_error(std::io::Error::other("PRIVATE-SOURCE")),
1342                (false, true, false),
1343                json!({"tool_call_id":"variant-id","result":{"error":"An internal error occurred while executing the tool"},"error":"An internal error occurred while executing the tool"}),
1344            ),
1345        ] {
1346            assert_eq!(
1347                (
1348                    result.is_success(),
1349                    result.is_error(),
1350                    result.is_connection_required()
1351                ),
1352                classification
1353            );
1354            let result = result.into_tool_result("variant-id", "tool");
1355            assert!(result.raw_output.is_none());
1356            assert_eq!(serde_json::to_value(result).unwrap(), expected);
1357        }
1358    }
1359
1360    #[tokio::test]
1361    async fn test_tool_registry_as_executor() {
1362        let mut registry = ToolRegistry::new();
1363        registry.register(EchoTool);
1364
1365        let tool_call = ToolCall {
1366            id: "call_1".to_string(),
1367            name: "echo".to_string(),
1368            arguments: serde_json::json!({"message": "test"}),
1369        };
1370
1371        let tool_def = registry.get("echo").unwrap().to_definition();
1372        let result = registry.execute(&tool_call, &tool_def).await.unwrap();
1373
1374        assert!(result.error.is_none());
1375        assert_eq!(result.result.unwrap()["echoed"], "test");
1376    }
1377
1378    #[test]
1379    fn test_with_defaults_has_expected_tools() {
1380        let registry = ToolRegistry::with_defaults();
1381        // Exact inventory excludes test doubles and capability-owned tools:
1382        // exposing those here would bypass host composition or capability policy.
1383        assert_eq!(registry.tool_names(), ["report_progress"]);
1384        assert!(registry.tool_definitions()[0].display_name().is_some());
1385    }
1386
1387    #[tokio::test]
1388    async fn test_with_defaults_tools_are_executable() {
1389        let registry = ToolRegistry::with_defaults();
1390
1391        // The neutral progress contract remains executable as a core default.
1392        let tool_call = ToolCall {
1393            id: "call_1".to_string(),
1394            name: "report_progress".to_string(),
1395            arguments: serde_json::json!({
1396                "status": "completed",
1397                "summary": "Boundary audit complete"
1398            }),
1399        };
1400
1401        let tool_def = registry.get("report_progress").unwrap().to_definition();
1402        let result = registry.execute(&tool_call, &tool_def).await.unwrap();
1403
1404        assert!(result.error.is_none());
1405        assert_eq!(result.result.unwrap()["summary"], "Boundary audit complete");
1406    }
1407
1408    /// Regression: with_defaults() must NOT include capability-provided tools like
1409    /// 'bash'. These tools come from capabilities and must be registered separately.
1410    /// If bash were in defaults, the harness capability fallback would be masked.
1411
1412    #[test]
1413    fn raw_output_preserves_object_keys_that_resemble_carriers() {
1414        for value in [
1415            serde_json::json!({"_raw_output_scalar": "user-value"}),
1416            serde_json::json!({"_raw_output": "user-value", "kept": true}),
1417        ] {
1418            let result = ToolExecutionResult::success_with_raw_output(
1419                value.clone(),
1420                "actual raw output".into(),
1421            )
1422            .into_tool_result("call", "tool");
1423            assert_eq!(result.result, Some(value));
1424            assert_eq!(result.raw_output.as_deref(), Some("actual raw output"));
1425        }
1426    }
1427    #[tokio::test]
1428    async fn monitor_probe_registry_rejects_unregistered_tools() {
1429        let registry = ToolRegistry::with_monitor_probe_defaults();
1430        let call = ToolCall {
1431            id: "missing-id".into(),
1432            name: "echo".into(),
1433            arguments: serde_json::json!({"message":"x"}),
1434        };
1435        let definition = EchoTool.to_definition();
1436        let context = ToolContext::new(crate::typed_id::SessionId::new());
1437        for with_context in [false, true] {
1438            let error = if with_context {
1439                registry
1440                    .execute_with_context(&call, &definition, &context)
1441                    .await
1442                    .unwrap_err()
1443            } else {
1444                registry.execute(&call, &definition).await.unwrap_err()
1445            };
1446            assert!(
1447                matches!(error, AgentLoopError::ToolExecution(message) if message.contains("echo"))
1448            );
1449        }
1450    }
1451    #[tokio::test]
1452    async fn invalid_registered_schema_fails_configuration_before_dispatch() {
1453        struct InvalidSchema;
1454        #[async_trait]
1455        impl Tool for InvalidSchema {
1456            fn name(&self) -> &str {
1457                "invalid_schema"
1458            }
1459            fn description(&self) -> &str {
1460                "Invalid schema fixture"
1461            }
1462            fn parameters_schema(&self) -> Value {
1463                serde_json::json!({"type":42})
1464            }
1465            async fn execute(&self, _: Value) -> ToolExecutionResult {
1466                panic!("invalid schema must never dispatch")
1467            }
1468        }
1469        let registry = ToolRegistry::builder().tool(InvalidSchema).build();
1470        let call = ToolCall {
1471            id: "schema-id".into(),
1472            name: "invalid_schema".into(),
1473            arguments: serde_json::json!({}),
1474        };
1475        let context = ToolContext::new(crate::typed_id::SessionId::new());
1476        // The caller-supplied definition cannot replace the registered schema.
1477        let supplied = EchoTool.to_definition();
1478        for with_context in [false, true] {
1479            let error = if with_context {
1480                registry
1481                    .execute_with_context(&call, &supplied, &context)
1482                    .await
1483                    .unwrap_err()
1484            } else {
1485                registry.execute(&call, &supplied).await.unwrap_err()
1486            };
1487            assert!(
1488                matches!(error,AgentLoopError::Configuration(message) if message.contains("invalid_schema") && message.contains("invalid parameters schema"))
1489            );
1490        }
1491    }
1492}