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::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use std::collections::HashMap;
17use std::sync::{Arc, OnceLock};
18use tracing::error;
19
20use crate::background::{
21    BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
22};
23use crate::tool_types::{
24    BuiltinTool, DeferrablePolicy, ToolCall, ToolDefinition, ToolHints, ToolPolicy, ToolResult,
25};
26use crate::traits::ToolContext;
27use crate::typed_id::SessionId;
28use tokio::sync::{OwnedSemaphorePermit, Semaphore};
29
30use crate::error::Result;
31use crate::traits::ToolExecutor;
32
33/// Maximum active immediate background runs allowed for a single session.
34///
35/// This mirrors the scheduled monitor cap so model-visible background execution
36/// cannot be used to queue unbounded active worker jobs for one session.
37pub const MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION: usize = 5;
38
39/// Maximum active immediate background runs allowed in this worker process.
40///
41/// The per-session semaphore limits tenant/session abuse; this process-wide
42/// semaphore keeps concurrent sessions from exhausting worker-local resources.
43const MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER: usize = 64;
44static ACTIVE_BACKGROUND_RUNS_PER_WORKER: Semaphore =
45    Semaphore::const_new(MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER);
46
47/// Per-session semaphores that enforce `MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION`.
48///
49/// Using a semaphore rather than a DB count makes the check atomic: concurrent
50/// `spawn_background` calls for the same session cannot both slip through the
51/// guard (check-then-act race) because `try_acquire` is inherently atomic.
52static SESSION_BACKGROUND_PERMITS: OnceLock<std::sync::Mutex<HashMap<SessionId, Arc<Semaphore>>>> =
53    OnceLock::new();
54
55struct SessionBackgroundPermit {
56    session_id: SessionId,
57    semaphore: Arc<Semaphore>,
58    permit: Option<OwnedSemaphorePermit>,
59}
60
61impl Drop for SessionBackgroundPermit {
62    fn drop(&mut self) {
63        drop(self.permit.take());
64
65        let Some(permits) = SESSION_BACKGROUND_PERMITS.get() else {
66            return;
67        };
68        let mut permits = permits.lock().unwrap();
69        let should_remove = permits.get(&self.session_id).is_some_and(|current| {
70            Arc::ptr_eq(current, &self.semaphore)
71                && self.semaphore.available_permits() == MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION
72                && Arc::strong_count(&self.semaphore) == 2
73        });
74        if should_remove {
75            permits.remove(&self.session_id);
76        }
77    }
78}
79
80fn try_acquire_session_background_permit(
81    session_id: SessionId,
82) -> std::result::Result<SessionBackgroundPermit, tokio::sync::TryAcquireError> {
83    let permits = SESSION_BACKGROUND_PERMITS.get_or_init(Default::default);
84    let mut permits = permits.lock().unwrap();
85    let semaphore = permits
86        .entry(session_id)
87        .or_insert_with(|| Arc::new(Semaphore::new(MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION)))
88        .clone();
89    let permit = semaphore.clone().try_acquire_owned()?;
90
91    Ok(SessionBackgroundPermit {
92        session_id,
93        semaphore,
94        permit: Some(permit),
95    })
96}
97
98#[cfg(test)]
99fn has_session_background_permits(session_id: SessionId) -> bool {
100    SESSION_BACKGROUND_PERMITS
101        .get()
102        .and_then(|permits| permits.lock().unwrap().get(&session_id).cloned())
103        .is_some()
104}
105
106// ============================================================================
107// Tool Execution Result - Error Handling Contract
108// ============================================================================
109
110/// Image data returned by a tool alongside text results.
111///
112/// This allows tools (built-in or MCP) to return images that are sent
113/// to the LLM as native image content blocks, not stringified JSON.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ToolResultImage {
116    /// Base64-encoded image data
117    pub base64: String,
118    /// MIME type (e.g., "image/png", "image/jpeg")
119    pub media_type: String,
120}
121
122/// Result of a tool execution.
123///
124/// This enum distinguishes between different outcomes:
125/// - `Success`: Tool executed successfully, result is returned to LLM
126/// - `SuccessWithImages`: Successful execution with JSON result plus images
127/// - `ToolError`: Tool-level error that should be shown to the LLM
128///   (e.g., "City not found", "Invalid date format")
129/// - `InternalError`: System-level error that should NOT be exposed to the LLM
130///   (e.g., database connection failure, API key issues)
131///
132/// # Security
133///
134/// Internal errors are logged but replaced with a generic message when
135/// returned to the LLM. This prevents leaking sensitive information like
136/// database errors, API keys, or internal system details.
137#[derive(Debug)]
138pub enum ToolExecutionResult {
139    /// Successful execution with a JSON result
140    Success(Value),
141
142    /// Successful execution with a JSON result and images.
143    /// Images are sent to the LLM as native image content blocks
144    /// (not stringified JSON), enabling visual understanding.
145    SuccessWithImages {
146        result: Value,
147        images: Vec<ToolResultImage>,
148    },
149
150    /// Tool-level error that is safe to show to the LLM
151    ///
152    /// Use this for expected error conditions that the LLM should know about,
153    /// such as validation errors, resource not found, etc.
154    ToolError(String),
155
156    /// Internal/system error that should NOT be exposed to the LLM
157    ///
158    /// Use this for unexpected errors like network failures, database errors,
159    /// or other internal issues. The error details will be logged but replaced
160    /// with a generic message when returned to the LLM.
161    InternalError(ToolInternalError),
162
163    /// A user connection is required to execute this tool.
164    ///
165    /// Instead of returning an error, this signals that the workflow should
166    /// pause and ask the client to set up a connection for the given provider.
167    /// The UI renders an inline connection dialog; once the user saves (or
168    /// cancels), a tool result is submitted and execution resumes.
169    ConnectionRequired {
170        /// Connection provider id (e.g. "daytona", "brave_search")
171        provider: String,
172    },
173}
174
175impl ToolExecutionResult {
176    /// Create a successful result
177    pub fn success(value: impl Into<Value>) -> Self {
178        ToolExecutionResult::Success(value.into())
179    }
180
181    /// Create a successful result with pre-truncation raw output for VFS persistence.
182    /// The raw output is transferred to `ToolResult.raw_output` during `into_tool_result()`.
183    pub fn success_with_raw_output(value: impl Into<Value>, raw_output: String) -> Self {
184        let mut value = value.into();
185        // Embed raw output in a sidecar key — extracted in into_tool_result().
186        // Non-object values are wrapped in a scalar carrier so raw_output still
187        // flows through; the carrier is unwrapped on extraction.
188        match value.as_object_mut() {
189            Some(obj) => {
190                obj.insert("_raw_output".to_string(), Value::String(raw_output));
191            }
192            None => {
193                value = serde_json::json!({
194                    "_raw_output_scalar": value,
195                    "_raw_output": raw_output,
196                });
197            }
198        }
199        ToolExecutionResult::Success(value)
200    }
201
202    /// Create a successful result with images
203    pub fn success_with_images(value: impl Into<Value>, images: Vec<ToolResultImage>) -> Self {
204        ToolExecutionResult::SuccessWithImages {
205            result: value.into(),
206            images,
207        }
208    }
209
210    /// Create a tool-level error (safe to show to LLM)
211    pub fn tool_error(message: impl Into<String>) -> Self {
212        ToolExecutionResult::ToolError(message.into())
213    }
214
215    /// Create an internal error (will be hidden from LLM)
216    pub fn internal_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
217        ToolExecutionResult::InternalError(ToolInternalError::new(error))
218    }
219
220    /// Create an internal error from a string message
221    pub fn internal_error_msg(message: impl Into<String>) -> Self {
222        ToolExecutionResult::InternalError(ToolInternalError::from_message(message))
223    }
224
225    /// Signal that a user connection is required before this tool can execute.
226    pub fn connection_required(provider: impl Into<String>) -> Self {
227        ToolExecutionResult::ConnectionRequired {
228            provider: provider.into(),
229        }
230    }
231
232    /// Check if this is a successful result
233    pub fn is_success(&self) -> bool {
234        matches!(
235            self,
236            ToolExecutionResult::Success(_) | ToolExecutionResult::SuccessWithImages { .. }
237        )
238    }
239
240    /// Check if this is an error (either tool error or internal error)
241    pub fn is_error(&self) -> bool {
242        matches!(
243            self,
244            ToolExecutionResult::ToolError(_) | ToolExecutionResult::InternalError(_)
245        )
246    }
247
248    /// Check if this requires a user connection setup
249    pub fn is_connection_required(&self) -> bool {
250        matches!(self, ToolExecutionResult::ConnectionRequired { .. })
251    }
252
253    /// Convert to a ToolResult for the agent loop
254    ///
255    /// Both tool errors and internal errors are packaged as `{"error": "..."}` in the
256    /// result field. This provides a consistent contract where the result field always
257    /// contains the payload, and the agent loop continues the same way for all outcomes.
258    ///
259    /// Internal errors are logged but replaced with a generic message when returned.
260    pub fn into_tool_result(self, tool_call_id: &str, tool_name: &str) -> ToolResult {
261        match self {
262            ToolExecutionResult::Success(mut value) => {
263                // Extract sidecar raw output if present (from success_with_raw_output)
264                let raw_output = value
265                    .as_object_mut()
266                    .and_then(|obj| obj.remove("_raw_output"))
267                    .and_then(|v| v.as_str().map(|s| s.to_string()));
268                // Unwrap scalar carrier only when it matches the exact wrapper shape
269                // set by success_with_raw_output for non-object inputs.
270                let result_value = if let Some(obj) = value.as_object_mut() {
271                    let is_scalar_carrier = raw_output.is_some()
272                        && obj.len() == 1
273                        && obj.contains_key("_raw_output_scalar");
274                    if is_scalar_carrier {
275                        obj.remove("_raw_output_scalar").unwrap_or(Value::Null)
276                    } else {
277                        value
278                    }
279                } else {
280                    value
281                };
282                ToolResult {
283                    tool_call_id: tool_call_id.to_string(),
284                    result: Some(result_value),
285                    images: None,
286                    error: None,
287                    connection_required: None,
288                    raw_output,
289                }
290            }
291            ToolExecutionResult::SuccessWithImages { result, images } => ToolResult {
292                tool_call_id: tool_call_id.to_string(),
293                result: Some(result),
294                images: if images.is_empty() {
295                    None
296                } else {
297                    Some(images)
298                },
299                error: None,
300                connection_required: None,
301                raw_output: None,
302            },
303            ToolExecutionResult::ToolError(message) => ToolResult {
304                tool_call_id: tool_call_id.to_string(),
305                result: Some(serde_json::json!({ "error": &message })),
306                images: None,
307                error: Some(message),
308                connection_required: None,
309                raw_output: None,
310            },
311            ToolExecutionResult::InternalError(err) => {
312                // Log the full error details for debugging
313                error!(
314                    tool_name = %tool_name,
315                    tool_call_id = %tool_call_id,
316                    error = %err.message,
317                    error_chain = %err.chain_string(),
318                    "Tool internal error (details hidden from LLM)"
319                );
320
321                // Return generic error message to LLM, packaged as {"error": "..."}
322                let generic_msg = "An internal error occurred while executing the tool";
323                ToolResult {
324                    tool_call_id: tool_call_id.to_string(),
325                    result: Some(serde_json::json!({
326                        "error": generic_msg
327                    })),
328                    images: None,
329                    error: Some(generic_msg.to_string()),
330                    connection_required: None,
331                    raw_output: None,
332                }
333            }
334            ToolExecutionResult::ConnectionRequired { ref provider } => ToolResult {
335                tool_call_id: tool_call_id.to_string(),
336                result: Some(serde_json::json!({
337                    "connection_required": provider,
338                })),
339                images: None,
340                error: None,
341                connection_required: Some(provider.clone()),
342                raw_output: None,
343            },
344        }
345    }
346}
347
348/// Internal error details (logged but not exposed to LLM)
349#[derive(Debug)]
350pub struct ToolInternalError {
351    /// Error message for logging
352    pub message: String,
353    /// Optional source error
354    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
355}
356
357impl ToolInternalError {
358    /// Create from an error
359    pub fn new(error: impl std::error::Error + Send + Sync + 'static) -> Self {
360        Self {
361            message: error.to_string(),
362            source: Some(Box::new(error)),
363        }
364    }
365
366    /// Create from a string message
367    pub fn from_message(message: impl Into<String>) -> Self {
368        Self {
369            message: message.into(),
370            source: None,
371        }
372    }
373
374    pub fn chain_string(&self) -> String {
375        let mut parts = vec![self.message.clone()];
376        let mut current = <Self as std::error::Error>::source(self);
377        while let Some(source) = current {
378            let message = source.to_string();
379            if parts.last() != Some(&message) {
380                parts.push(message);
381            }
382            current = source.source();
383        }
384        parts.join(": ")
385    }
386}
387
388impl std::fmt::Display for ToolInternalError {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        write!(f, "{}", self.message)
391    }
392}
393
394impl std::error::Error for ToolInternalError {
395    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
396        self.source
397            .as_ref()
398            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
399    }
400}
401
402// ============================================================================
403// Tool Trait - Core Tool Abstraction
404// ============================================================================
405
406/// Trait for implementing tools that can be executed by the agent loop.
407///
408/// # Example
409///
410/// ```ignore
411/// use async_trait::async_trait;
412/// use serde_json::{json, Value};
413///
414/// struct GetCurrentTime;
415///
416/// #[async_trait]
417/// impl Tool for GetCurrentTime {
418///     fn name(&self) -> &str {
419///         "get_current_time"
420///     }
421///
422///     fn description(&self) -> &str {
423///         "Get the current date and time"
424///     }
425///
426///     fn parameters_schema(&self) -> Value {
427///         json!({
428///             "type": "object",
429///             "properties": {
430///                 "timezone": {
431///                     "type": "string",
432///                     "description": "Timezone (e.g., 'UTC', 'America/New_York')"
433///                 }
434///             }
435///         })
436///     }
437///
438///     async fn execute(&self, arguments: Value) -> ToolExecutionResult {
439///         let timezone = arguments.get("timezone")
440///             .and_then(|v| v.as_str())
441///             .unwrap_or("UTC");
442///
443///         ToolExecutionResult::success(json!({
444///             "current_time": chrono::Utc::now().to_rfc3339(),
445///             "timezone": timezone
446///         }))
447///     }
448/// }
449/// ```
450#[async_trait]
451pub trait Tool: Send + Sync {
452    /// Returns the tool's unique name.
453    ///
454    /// This name is used by the LLM to invoke the tool and must be unique
455    /// within a ToolRegistry.
456    fn name(&self) -> &str;
457
458    /// Returns a human-readable display name for UI rendering.
459    ///
460    /// This name is shown to users in the UI instead of the technical tool name.
461    /// For example, "Get Current Time" instead of "get_current_time".
462    /// Returns None if no display name is set, in which case the UI may
463    /// fall back to the technical name.
464    fn display_name(&self) -> Option<&str> {
465        None
466    }
467
468    /// Returns a description of what the tool does.
469    ///
470    /// This description is provided to the LLM to help it understand
471    /// when and how to use the tool.
472    fn description(&self) -> &str;
473
474    /// Returns the JSON schema for the tool's parameters.
475    ///
476    /// This schema follows the JSON Schema specification and describes
477    /// the expected arguments for the tool. The LLM uses this to
478    /// generate valid tool calls.
479    fn parameters_schema(&self) -> Value;
480
481    /// Execute the tool with the given arguments.
482    ///
483    /// # Arguments
484    ///
485    /// * `arguments` - The arguments passed to the tool as a JSON value.
486    ///   These should conform to the schema returned by `parameters_schema()`.
487    ///
488    /// # Returns
489    ///
490    /// A `ToolExecutionResult` indicating success, tool error, or internal error.
491    async fn execute(&self, arguments: Value) -> ToolExecutionResult;
492
493    /// Execute the tool with context.
494    ///
495    /// This method provides access to runtime context like session ID and
496    /// optional stores (file store, etc.). Override this method for tools
497    /// that need access to session context or external resources.
498    ///
499    /// The default implementation simply calls `execute()`, ignoring the context.
500    ///
501    /// # Arguments
502    ///
503    /// * `arguments` - The arguments passed to the tool as a JSON value.
504    /// * `context` - Runtime context containing session ID and optional stores.
505    ///
506    /// # Returns
507    ///
508    /// A `ToolExecutionResult` indicating success, tool error, or internal error.
509    async fn execute_with_context(
510        &self,
511        arguments: Value,
512        _context: &ToolContext,
513    ) -> ToolExecutionResult {
514        // Default: delegate to execute(), ignoring context
515        self.execute(arguments).await
516    }
517
518    /// Returns true if this tool requires context for execution.
519    ///
520    /// Tools that need session context (like filesystem tools) should
521    /// override this to return true.
522    fn requires_context(&self) -> bool {
523        false
524    }
525
526    /// Returns the tool policy (auto or requires_approval).
527    ///
528    /// Default is `Auto` which means the tool executes immediately.
529    /// Override to return `RequiresApproval` for sensitive operations.
530    fn policy(&self) -> ToolPolicy {
531        ToolPolicy::Auto
532    }
533
534    /// Returns semantic hints describing the tool's behavioral properties.
535    ///
536    /// Override to provide hints like readonly, destructive, idempotent, etc.
537    /// Default is empty (all hints unspecified).
538    fn hints(&self) -> ToolHints {
539        ToolHints::default()
540    }
541
542    /// Returns backend-authored narration for a call to this tool, e.g.
543    /// "Read AGENTS.md".
544    ///
545    /// The owning capability's default [`crate::capabilities::Capability::narrate`]
546    /// dispatches here for the tool whose `name()` matches the call. Return
547    /// `None` to accept the generic `narration_noun`/display-name fallback.
548    /// Implementations should use the phrasing helpers in
549    /// [`crate::tool_narration`] (`narrate_read_file`, `narrate_shell_exec`, …)
550    /// so wording and localization stay consistent.
551    fn narrate(
552        &self,
553        _tool_call: &crate::tool_types::ToolCall,
554        _phase: crate::tool_narration::ToolNarrationPhase,
555        _locale: Option<&str>,
556    ) -> Option<String> {
557        None
558    }
559
560    /// Returns native background execution support when this tool opts into
561    /// detached execution via `hints().supports_background`.
562    fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
563        None
564    }
565
566    /// Deferral policy for progressive tool-schema disclosure (tool search).
567    /// Hot-path or "consult-first" tools can return [`DeferrablePolicy::Never`]
568    /// to always keep their full schema directly callable. Defaults to
569    /// [`DeferrablePolicy::Automatic`].
570    fn deferrable_policy(&self) -> DeferrablePolicy {
571        DeferrablePolicy::default()
572    }
573
574    /// Convert this tool to a ToolDefinition for the agent config.
575    ///
576    /// This is used by ToolRegistry to generate tool definitions
577    /// for the LLM provider.
578    fn to_definition(&self) -> ToolDefinition {
579        ToolDefinition::Builtin(BuiltinTool {
580            name: self.name().to_string(),
581            display_name: self.display_name().map(|s| s.to_string()),
582            description: self.description().to_string(),
583            parameters: self.parameters_schema(),
584            policy: self.policy(),
585            category: None,
586            deferrable: self.deferrable_policy(),
587            hints: self.hints(),
588            full_parameters: None,
589        })
590    }
591}
592
593// ============================================================================
594// ToolRegistry - Collection of Tools
595// ============================================================================
596
597/// A registry that holds multiple tools and implements ToolExecutor.
598///
599/// ToolRegistry provides a convenient way to manage multiple tools and
600/// integrate them with the agent loop. It implements `ToolExecutor` so
601/// it can be used directly with `AgentLoop`.
602///
603/// # Example
604///
605/// ```ignore
606/// use everruns_core::tools::{Tool, ToolRegistry};
607///
608/// // Create registry and add tools
609/// let mut registry = ToolRegistry::new();
610/// registry.register(Box::new(GetCurrentTime));
611/// registry.register(Box::new(GetWeather));
612///
613/// // Get tool definitions for agent config
614/// let definitions = registry.tool_definitions();
615///
616/// // Use with agent loop
617/// let agent_loop = AgentLoop::new(config, emitter, store, llm, registry);
618/// ```
619#[derive(Default, Clone)]
620pub struct ToolRegistry {
621    tools: HashMap<String, Arc<dyn Tool>>,
622}
623
624impl ToolRegistry {
625    /// Create a new empty tool registry
626    pub fn new() -> Self {
627        Self {
628            tools: HashMap::new(),
629        }
630    }
631
632    /// Create a tool registry with default built-in tools.
633    ///
634    /// This includes:
635    /// - `get_current_time`: Returns the current date and time
636    /// - `echo`: Echoes back the provided message
637    /// - `report_progress`: Emits deterministic external progress updates
638    /// - TestMath tools: add, subtract, multiply, divide
639    /// - TestWeather tools: get_weather, get_forecast
640    /// - TaskList tools: write_todos
641    /// - FileSystem tools: read_file, write_file, edit_file, list_directory, grep_files, delete_file, stat_file
642    /// - WebFetch tools: web_fetch — only when the `web-fetch` cargo feature is
643    ///   enabled (on by default; absent in provider builds that disable core
644    ///   default features)
645    pub fn with_defaults() -> Self {
646        use crate::capabilities::{
647            AddTool, DeleteFileTool, DivideTool, EditFileTool, GetCurrentTimeTool, GetForecastTool,
648            GetWeatherTool, GrepFilesTool, ListDirectoryTool, MultiplyTool, ReadFileTool,
649            StatFileTool, SubtractTool, WriteFileTool, WriteTodosTool,
650        };
651        use crate::progress_reporting::ReportProgressTool;
652
653        let builder = ToolRegistry::builder()
654            .tool(GetCurrentTimeTool)
655            .tool(EchoTool)
656            // NOTE: `spawn_background` is intentionally NOT a default tool —
657            // it is contributed by the `background_execution` capability,
658            // which is auto-activated by
659            // `collect_capabilities_with_configs` whenever a collected tool
660            // declares `ToolHints::supports_background = Some(true)`. Keeping
661            // it out of defaults preserves the lockstep contract between
662            // model-visible tools and the worker execution registry: the
663            // executor only knows about `spawn_background` when the model
664            // can also see it.
665            .tool(ReportProgressTool)
666            // TestMath capability tools
667            .tool(AddTool)
668            .tool(SubtractTool)
669            .tool(MultiplyTool)
670            .tool(DivideTool)
671            // TestWeather capability tools
672            .tool(GetWeatherTool)
673            .tool(GetForecastTool)
674            // TaskList capability tools
675            .tool(WriteTodosTool)
676            // FileSystem capability tools
677            .tool(ReadFileTool)
678            .tool(WriteFileTool)
679            .tool(EditFileTool)
680            .tool(ListDirectoryTool)
681            .tool(GrepFilesTool)
682            .tool(DeleteFileTool)
683            .tool(StatFileTool);
684
685        // WebFetch capability tools (gated behind the `web-fetch` feature so
686        // provider crates that opt out of core defaults skip the fetchkit tree).
687        #[cfg(feature = "web-fetch")]
688        let builder = builder.tool(crate::capabilities::WebFetchTool::default());
689
690        builder.build()
691    }
692
693    /// Register a tool with the registry.
694    ///
695    /// If a tool with the same name already exists, it will be replaced.
696    pub fn register(&mut self, tool: impl Tool + 'static) {
697        self.tools.insert(tool.name().to_string(), Arc::new(tool));
698    }
699
700    /// Register a boxed tool
701    pub fn register_boxed(&mut self, tool: Box<dyn Tool>) {
702        self.tools.insert(tool.name().to_string(), Arc::from(tool));
703    }
704
705    /// Register an Arc-wrapped tool
706    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
707        self.tools.insert(tool.name().to_string(), tool);
708    }
709
710    /// Get a tool by name
711    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
712        self.tools.get(name)
713    }
714
715    /// Check if a tool is registered
716    pub fn has(&self, name: &str) -> bool {
717        self.tools.contains_key(name)
718    }
719
720    /// Get the number of registered tools
721    pub fn len(&self) -> usize {
722        self.tools.len()
723    }
724
725    /// Check if the registry is empty
726    pub fn is_empty(&self) -> bool {
727        self.tools.is_empty()
728    }
729
730    /// Get all tool names
731    pub fn tool_names(&self) -> Vec<&str> {
732        self.tools.keys().map(|s| s.as_str()).collect()
733    }
734
735    /// Get tool definitions for use in RuntimeAgent.
736    ///
737    /// Returns a Vec of ToolDefinition that can be passed to
738    /// `RuntimeAgent::with_tools()`.
739    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
740        self.tools.values().map(|t| t.to_definition()).collect()
741    }
742
743    /// Remove a tool from the registry
744    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
745        self.tools.remove(name)
746    }
747
748    /// Clear all tools from the registry
749    pub fn clear(&mut self) {
750        self.tools.clear();
751    }
752
753    /// Create a builder for fluent tool registration
754    pub fn builder() -> ToolRegistryBuilder {
755        ToolRegistryBuilder::new()
756    }
757}
758
759impl std::fmt::Debug for ToolRegistry {
760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761        f.debug_struct("ToolRegistry")
762            .field("tools", &self.tool_names())
763            .finish()
764    }
765}
766
767#[async_trait]
768impl ToolExecutor for ToolRegistry {
769    async fn execute(
770        &self,
771        tool_call: &ToolCall,
772        _tool_def: &ToolDefinition,
773    ) -> Result<ToolResult> {
774        let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
775            crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
776        })?;
777
778        let result = tool.execute(tool_call.arguments.clone()).await;
779        Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
780    }
781
782    async fn execute_with_context(
783        &self,
784        tool_call: &ToolCall,
785        _tool_def: &ToolDefinition,
786        context: &ToolContext,
787    ) -> Result<ToolResult> {
788        let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
789            crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
790        })?;
791
792        // Use execute_with_context for all tools - context-aware tools will use it,
793        // regular tools will delegate to execute() via the default implementation
794        let result = tool
795            .execute_with_context(tool_call.arguments.clone(), context)
796            .await;
797        Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
798    }
799}
800
801// ============================================================================
802// ToolRegistryBuilder - Fluent API for Building Registry
803// ============================================================================
804
805/// Builder for creating a ToolRegistry with a fluent API.
806///
807/// # Example
808///
809/// ```ignore
810/// let registry = ToolRegistry::builder()
811///     .tool(GetCurrentTime)
812///     .tool(GetWeather)
813///     .build();
814/// ```
815pub struct ToolRegistryBuilder {
816    registry: ToolRegistry,
817}
818
819impl ToolRegistryBuilder {
820    /// Create a new builder
821    pub fn new() -> Self {
822        Self {
823            registry: ToolRegistry::new(),
824        }
825    }
826
827    /// Add a tool to the registry
828    pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
829        self.registry.register(tool);
830        self
831    }
832
833    /// Add a boxed tool to the registry
834    pub fn tool_boxed(mut self, tool: Box<dyn Tool>) -> Self {
835        self.registry.register_boxed(tool);
836        self
837    }
838
839    /// Add an Arc-wrapped tool to the registry
840    pub fn tool_arc(mut self, tool: Arc<dyn Tool>) -> Self {
841        self.registry.register_arc(tool);
842        self
843    }
844
845    /// Build the registry
846    pub fn build(self) -> ToolRegistry {
847        self.registry
848    }
849}
850
851impl Default for ToolRegistryBuilder {
852    fn default() -> Self {
853        Self::new()
854    }
855}
856
857// ============================================================================
858// Built-in Tools
859// ============================================================================
860
861/// A tool that echoes back its arguments (useful for testing)
862pub struct EchoTool;
863
864#[async_trait]
865impl Tool for EchoTool {
866    fn name(&self) -> &str {
867        "echo"
868    }
869
870    fn display_name(&self) -> Option<&str> {
871        Some("Echo")
872    }
873
874    fn description(&self) -> &str {
875        "Echo back the provided message. Useful for testing tool execution."
876    }
877
878    fn parameters_schema(&self) -> Value {
879        serde_json::json!({
880            "type": "object",
881            "properties": {
882                "message": {
883                    "type": "string",
884                    "description": "The message to echo back"
885                }
886            },
887            "required": ["message"],
888            "additionalProperties": false
889        })
890    }
891
892    fn hints(&self) -> ToolHints {
893        ToolHints::default()
894            .with_readonly(true)
895            .with_idempotent(true)
896    }
897
898    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
899        let message = arguments
900            .get("message")
901            .and_then(|v| v.as_str())
902            .unwrap_or("");
903
904        ToolExecutionResult::success(serde_json::json!({
905            "echoed": message,
906            "length": message.len()
907        }))
908    }
909}
910
911/// Spawn a background-capable tool and return immediately with a run handle.
912pub struct SpawnBackgroundTool;
913
914#[derive(Debug, Clone)]
915struct BackgroundScheduleRequest {
916    cron_expression: Option<String>,
917    scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
918    timezone: String,
919}
920
921fn parse_background_schedule(
922    arguments: &Value,
923) -> std::result::Result<Option<BackgroundScheduleRequest>, String> {
924    let Some(schedule) = arguments.get("schedule") else {
925        return Ok(None);
926    };
927    let Some(schedule) = schedule.as_object() else {
928        return Err("schedule must be an object".to_string());
929    };
930
931    let cron_expression = schedule
932        .get("cron_expression")
933        .and_then(Value::as_str)
934        .map(str::trim)
935        .filter(|value| !value.is_empty())
936        .map(ToString::to_string);
937    let scheduled_at = match schedule.get("scheduled_at").and_then(Value::as_str) {
938        Some(value) => {
939            let value = value.trim();
940            if value.is_empty() {
941                None
942            } else {
943                Some(
944                    chrono::DateTime::parse_from_rfc3339(value)
945                        .map_err(|_| "scheduled_at must be RFC3339".to_string())?
946                        .with_timezone(&chrono::Utc),
947                )
948            }
949        }
950        None => None,
951    };
952
953    match (cron_expression.is_some(), scheduled_at.is_some()) {
954        (false, false) => {
955            return Err(
956                "schedule must include exactly one of cron_expression (recurring) or scheduled_at (one-shot)"
957                    .to_string(),
958            );
959        }
960        (true, true) => {
961            return Err(
962                "schedule must not include both cron_expression and scheduled_at; provide exactly one"
963                    .to_string(),
964            );
965        }
966        _ => {}
967    }
968
969    let timezone = schedule
970        .get("timezone")
971        .and_then(Value::as_str)
972        .map(str::trim)
973        .filter(|value| !value.is_empty())
974        .unwrap_or("UTC")
975        .to_string();
976
977    Ok(Some(BackgroundScheduleRequest {
978        cron_expression,
979        scheduled_at,
980        timezone,
981    }))
982}
983
984fn build_background_schedule_description(
985    tool_name: &str,
986    tool_args: &Value,
987    title: &str,
988    signal_on_completion: bool,
989) -> String {
990    let payload = json!({
991        "tool": tool_name,
992        "title": title,
993        "signal_on_completion": signal_on_completion,
994        "args": tool_args,
995    });
996    let payload_json =
997        serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
998
999    format!(
1000        "Monitor: {title}\n\n\
1001This scheduled monitor fired. Start the background run now.\n\n\
1002spawn_background payload:\n{payload_json}"
1003    )
1004}
1005
1006#[async_trait]
1007impl Tool for SpawnBackgroundTool {
1008    fn name(&self) -> &str {
1009        "spawn_background"
1010    }
1011
1012    fn display_name(&self) -> Option<&str> {
1013        Some("Spawn Background")
1014    }
1015
1016    fn description(&self) -> &str {
1017        "Run a background-capable built-in tool asynchronously. Returns immediately and signals the session when the background run completes."
1018    }
1019
1020    fn parameters_schema(&self) -> Value {
1021        json!({
1022            "type": "object",
1023            "properties": {
1024                "tool": {
1025                    "type": "string",
1026                    "description": "Name of the built-in tool to execute in the background"
1027                },
1028                "args": {
1029                    "type": "object",
1030                    "description": "Arguments to pass to the target tool"
1031                },
1032                "title": {
1033                    "type": "string",
1034                    "description": "Optional human-readable label for the background run"
1035                },
1036                "schedule": {
1037                    "type": "object",
1038                    "description": "Optional session schedule. When provided, this creates a scheduled monitor instead of starting the run immediately.",
1039                    "properties": {
1040                        "cron_expression": {
1041                            "type": "string",
1042                            "description": "Standard 5-field cron expression for recurring runs (e.g. '*/10 * * * *' for every 10 minutes)"
1043                        },
1044                        "scheduled_at": {
1045                            "type": "string",
1046                            "description": "ISO 8601 datetime for a one-shot run (e.g. '2026-04-16T15:30:00Z')"
1047                        },
1048                        "timezone": {
1049                            "type": "string",
1050                            "description": "IANA timezone for the schedule. Default: UTC"
1051                        }
1052                    },
1053                    "additionalProperties": false
1054                },
1055                "signal_on_completion": {
1056                    "type": "boolean",
1057                    "description": "Send a synthetic user message back to the session when the run completes",
1058                    "default": true
1059                }
1060            },
1061            "required": ["tool", "args"],
1062            "additionalProperties": false
1063        })
1064    }
1065
1066    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1067        ToolExecutionResult::tool_error(
1068            "spawn_background requires context. This tool must be executed with session context.",
1069        )
1070    }
1071
1072    async fn execute_with_context(
1073        &self,
1074        arguments: Value,
1075        context: &ToolContext,
1076    ) -> ToolExecutionResult {
1077        let tool_name = match arguments.get("tool").and_then(|v| v.as_str()) {
1078            Some(name) if !name.trim().is_empty() => name.trim(),
1079            _ => return ToolExecutionResult::tool_error("Missing required parameter: tool"),
1080        };
1081        let tool_args = match arguments.get("args") {
1082            Some(args) if args.is_object() => args.clone(),
1083            _ => {
1084                return ToolExecutionResult::tool_error(
1085                    "Missing required parameter: args (object expected)",
1086                );
1087            }
1088        };
1089        let signal_on_completion = arguments
1090            .get("signal_on_completion")
1091            .and_then(|v| v.as_bool())
1092            .unwrap_or(true);
1093        let schedule_request = match parse_background_schedule(&arguments) {
1094            Ok(schedule) => schedule,
1095            Err(message) => return ToolExecutionResult::tool_error(message),
1096        };
1097
1098        let Some(tool_registry) = &context.tool_registry else {
1099            return ToolExecutionResult::tool_error(
1100                "Tool registry not available in this context. spawn_background requires worker-side tool execution.",
1101            );
1102        };
1103
1104        let Some(tool) = tool_registry.get(tool_name).cloned() else {
1105            return ToolExecutionResult::tool_error(format!("Unknown tool: {tool_name}"));
1106        };
1107        if tool_name == self.name() {
1108            return ToolExecutionResult::tool_error(
1109                "spawn_background cannot target itself recursively",
1110            );
1111        }
1112        if tool.hints().supports_background != Some(true) {
1113            return ToolExecutionResult::tool_error(format!(
1114                "Tool does not support background execution: {tool_name}"
1115            ));
1116        }
1117        if tool.as_background_executable().is_none() {
1118            return ToolExecutionResult::tool_error(format!(
1119                "Tool declared background support but has no background executor: {tool_name}"
1120            ));
1121        }
1122        let title = arguments
1123            .get("title")
1124            .and_then(|v| v.as_str())
1125            .map(str::trim)
1126            .filter(|s| !s.is_empty())
1127            .map(|s| s.to_string())
1128            .unwrap_or_else(|| {
1129                tool.display_name()
1130                    .map(ToString::to_string)
1131                    .unwrap_or_else(|| format!("Background {tool_name}"))
1132            });
1133
1134        if let Some(schedule_request) = schedule_request {
1135            let Some(schedule_store) = &context.schedule_store else {
1136                return ToolExecutionResult::tool_error(
1137                    "Schedule store not available in this context. Scheduled monitors require session schedules.",
1138                );
1139            };
1140
1141            // Enforce per-session cap, per-org cap, and minimum recurring cron
1142            // interval (shared with the create_schedule tool).
1143            match crate::session_schedule::enforce_create_limits(
1144                schedule_store.as_ref(),
1145                context.session_id,
1146                schedule_request.cron_expression.as_deref(),
1147            )
1148            .await
1149            {
1150                Ok(()) => {}
1151                Err(crate::session_schedule::ScheduleLimitError::Store(err)) => {
1152                    return ToolExecutionResult::internal_error(err);
1153                }
1154                Err(crate::session_schedule::ScheduleLimitError::Rejected(msg)) => {
1155                    return ToolExecutionResult::tool_error(msg);
1156                }
1157            }
1158
1159            let description = build_background_schedule_description(
1160                tool_name,
1161                &tool_args,
1162                &title,
1163                signal_on_completion,
1164            );
1165
1166            return match schedule_store
1167                .create_schedule(
1168                    context.session_id,
1169                    description,
1170                    schedule_request.cron_expression.clone(),
1171                    schedule_request.scheduled_at,
1172                    schedule_request.timezone.clone(),
1173                )
1174                .await
1175            {
1176                Ok(schedule) => {
1177                    // Best-effort: create a monitor task linked to this schedule.
1178                    // The schedule is the source of truth; task creation failure
1179                    // does not fail the spawn.
1180                    let mut monitor_task_id: Option<String> = None;
1181                    if let Some(ref task_registry) = context.session_task_registry {
1182                        let spec = json!({
1183                            "tool": tool_name,
1184                            "arguments": &tool_args,
1185                            "schedule_id": schedule.id.to_string(),
1186                            "schedule_type": schedule.schedule_type,
1187                            "cron_expression": schedule.cron_expression,
1188                            "scheduled_at": schedule.scheduled_at,
1189                            "timezone": schedule.timezone,
1190                            "signal_on_completion": signal_on_completion,
1191                        });
1192                        match task_registry
1193                            .create(crate::session_task::CreateSessionTask {
1194                                session_id: context.session_id,
1195                                id: None,
1196                                kind: crate::session_task::TASK_KIND_MONITOR.to_string(),
1197                                display_name: title.clone(),
1198                                spec,
1199                                state: crate::session_task::SessionTaskState::Running,
1200                                links: crate::session_task::TaskLinks::default(),
1201                                // Silent: the schedule's injected prompt message already
1202                                // wakes the session; a second wake on every fire would be noise.
1203                                wake_policy: crate::session_task::TaskWakePolicy::Silent,
1204                            })
1205                            .await
1206                        {
1207                            Ok(task) => {
1208                                monitor_task_id = Some(task.id);
1209                            }
1210                            Err(e) => {
1211                                tracing::warn!(
1212                                    session_id = %context.session_id,
1213                                    schedule_id = %schedule.id,
1214                                    error = %e,
1215                                    "Failed to create monitor task for schedule (best-effort)"
1216                                );
1217                            }
1218                        }
1219                    }
1220                    ToolExecutionResult::success(json!({
1221                        "created": true,
1222                        "status": "scheduled",
1223                        "title": title,
1224                        "tool": tool_name,
1225                        "signal_on_completion": signal_on_completion,
1226                        "schedule_id": schedule.id.to_string(),
1227                        "schedule_type": schedule.schedule_type,
1228                        "cron_expression": schedule.cron_expression,
1229                        "scheduled_at": schedule.scheduled_at,
1230                        "timezone": schedule.timezone,
1231                        "next_trigger_at": schedule.next_trigger_at,
1232                        "enabled": schedule.enabled,
1233                        "task_id": monitor_task_id,
1234                    }))
1235                }
1236                Err(err) => ToolExecutionResult::internal_error(err),
1237            };
1238        }
1239
1240        let Some(task_registry) = &context.session_task_registry else {
1241            return ToolExecutionResult::tool_error(
1242                "Session task registry not available in this context. Background runs require task tracking.",
1243            );
1244        };
1245        if context.file_store.is_none() {
1246            return ToolExecutionResult::tool_error(
1247                "Session file store not available in this context. spawn_background requires artifact persistence.",
1248            );
1249        }
1250
1251        let background_run_permit = match ACTIVE_BACKGROUND_RUNS_PER_WORKER.try_acquire() {
1252            Ok(permit) => permit,
1253            Err(_) => {
1254                return ToolExecutionResult::tool_error(format!(
1255                    "Worker is already running the maximum {MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER} active background runs. Try again after an existing run finishes."
1256                ));
1257            }
1258        };
1259
1260        let session_run_permit = match try_acquire_session_background_permit(context.session_id) {
1261            Ok(permit) => permit,
1262            Err(_) => {
1263                return ToolExecutionResult::tool_error(format!(
1264                    "Maximum {MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION} active background runs per session. Wait for an existing run to finish before starting another."
1265                ));
1266            }
1267        };
1268
1269        let run_id = format!("bg_{}", uuid::Uuid::now_v7().simple());
1270        let artifact_dir = format!("/.background/{run_id}");
1271        let log_path = format!("{artifact_dir}/output.log");
1272        let result_path = format!("{artifact_dir}/result.json");
1273
1274        // Create the session task tracking this run (specs/session-tasks.md).
1275        // task_registry is guaranteed Some above.
1276        let (task_id, task_attempt): (Option<String>, i32) = match task_registry
1277            .create(crate::session_task::CreateSessionTask {
1278                session_id: context.session_id,
1279                id: None,
1280                kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
1281                display_name: title.clone(),
1282                spec: json!({
1283                    "tool": tool_name,
1284                    "arguments": &tool_args,
1285                    // Reaper uses this to decide whether to re-run on orphan.
1286                    // Only idempotent/readonly tools are safe to re-execute.
1287                    "reattachable": tool.hints().idempotent.unwrap_or(false)
1288                        || tool.hints().readonly.unwrap_or(false),
1289                    // Persisted so re-attach can restore the original signaling behavior.
1290                    "signal_on_completion": signal_on_completion,
1291                }),
1292                state: crate::session_task::SessionTaskState::Running,
1293                links: crate::session_task::TaskLinks::default(),
1294                wake_policy: crate::session_task::TaskWakePolicy::Silent,
1295            })
1296            .await
1297        {
1298            Ok(task) => (Some(task.id), task.attempt),
1299            Err(e) => {
1300                return ToolExecutionResult::internal_error_msg(format!(
1301                    "Failed to create background run task: {e}"
1302                ));
1303            }
1304        };
1305
1306        let background_context = context.clone().with_tool_registry(tool_registry.clone());
1307        let sink = Arc::new(SessionBackgroundSink::new(
1308            background_context.clone(),
1309            run_id.clone(),
1310            title.clone(),
1311            tool_name.to_string(),
1312            log_path.clone(),
1313            result_path.clone(),
1314            signal_on_completion,
1315            task_id.clone(),
1316        ));
1317        let run_id_for_task = run_id.clone();
1318        let tool_for_task = tool.clone();
1319        let tool_name_for_task = tool_name.to_string();
1320
1321        // Clone registry/ids for the cancel-watcher inside the spawned task.
1322        let cancel_registry = context.session_task_registry.clone();
1323        let cancel_session_id = context.session_id;
1324        let cancel_task_id = task_id.clone();
1325        // Attempt captured at task creation for stale-attempt fencing.
1326        let cancel_task_attempt = task_attempt;
1327
1328        tokio::spawn(async move {
1329            let _background_run_permit = background_run_permit;
1330            let _session_run_permit = session_run_permit;
1331            let _ = sink.status("Starting").await;
1332
1333            // Run the tool future inside a select! against a cancel-watch loop
1334            // when a task registry and task_id are wired up.  The watcher sends
1335            // a heartbeat every ~2 s and checks cancel_requested_at; when set,
1336            // it wins the select and the tool future is dropped.
1337            //
1338            // Rationale: cancel intent is recorded in shared storage so this
1339            // design works even when cancel_task executes on a different worker.
1340            let outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult> = match (
1341                cancel_registry.as_ref(),
1342                cancel_task_id.as_deref(),
1343            ) {
1344                (Some(registry), Some(task_id_str)) => {
1345                    let registry = registry.clone();
1346                    let task_id_str = task_id_str.to_string();
1347                    let tool_fut = async {
1348                        match tool_for_task.as_background_executable() {
1349                            Some(background_tool) => {
1350                                background_tool
1351                                    .execute_background(tool_args, background_context, sink.clone())
1352                                    .await
1353                            }
1354                            None => Err(ToolExecutionResult::tool_error(format!(
1355                                "Tool declared background support but has no background executor: {}",
1356                                tool_name_for_task
1357                            ))),
1358                        }
1359                    };
1360                    let watch_fut = async {
1361                        loop {
1362                            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1363                            // Send heartbeat with stale-attempt fencing so a
1364                            // superseded executor's heartbeats are rejected once
1365                            // the reaper increments the attempt counter.
1366                            let _ = registry
1367                                .update(
1368                                    cancel_session_id,
1369                                    &task_id_str,
1370                                    crate::session_task::SessionTaskUpdate {
1371                                        heartbeat_at: Some(chrono::Utc::now()),
1372                                        expected_attempt: Some(cancel_task_attempt),
1373                                        ..Default::default()
1374                                    },
1375                                )
1376                                .await;
1377                            // Check for cooperative cancel intent.
1378                            if let Ok(Some(task)) =
1379                                registry.get(cancel_session_id, &task_id_str).await
1380                                && task.cancel_requested_at.is_some()
1381                            {
1382                                break;
1383                            }
1384                        }
1385                    };
1386                    tokio::select! {
1387                        result = tool_fut => result,
1388                        () = watch_fut => {
1389                            // Cancel was requested; the tool future is dropped.
1390                            Err(ToolExecutionResult::ToolError(
1391                                BACKGROUND_CANCEL_SENTINEL.to_string(),
1392                            ))
1393                        }
1394                    }
1395                }
1396                // No registry or no task_id: run without cancel watch (unchanged behaviour).
1397                _ => match tool_for_task.as_background_executable() {
1398                    Some(background_tool) => {
1399                        background_tool
1400                            .execute_background(tool_args, background_context, sink.clone())
1401                            .await
1402                    }
1403                    None => Err(ToolExecutionResult::tool_error(format!(
1404                        "Tool declared background support but has no background executor: {}",
1405                        tool_name_for_task
1406                    ))),
1407                },
1408            };
1409
1410            // Route canceled outcome through finalize_canceled so file/resource
1411            // cleanup is consistent with the success/fail paths.
1412            let finalize_result = if is_canceled_outcome(&outcome) {
1413                sink.finalize_canceled().await
1414            } else {
1415                sink.finalize(outcome).await
1416            };
1417            if let Err(err) = finalize_result {
1418                tracing::warn!(
1419                    run_id = run_id_for_task,
1420                    error = %err,
1421                    "Background run finalization failed"
1422                );
1423            }
1424        });
1425
1426        ToolExecutionResult::success(json!({
1427            "run_id": run_id,
1428            "resource_id": run_id,
1429            "task_id": task_id,
1430            "title": title,
1431            "tool": tool_name,
1432            "status": "running",
1433            "signal_on_completion": signal_on_completion,
1434            "artifact_dir": artifact_dir,
1435            "log_path": log_path,
1436            "result_path": result_path
1437        }))
1438    }
1439
1440    fn requires_context(&self) -> bool {
1441        true
1442    }
1443}
1444
1445#[derive(Debug, Default)]
1446struct SessionBackgroundState {
1447    status_text: String,
1448    progress: Option<BackgroundProgress>,
1449    output_tail: String,
1450    output_log: String,
1451    output_log_chars: usize,
1452    output_log_truncated: bool,
1453}
1454
1455const MAX_BACKGROUND_OUTPUT_LOG_CHARS: usize = 256 * 1024;
1456
1457struct SessionBackgroundSink {
1458    context: ToolContext,
1459    run_id: String,
1460    display_name: String,
1461    tool_name: String,
1462    log_path: String,
1463    result_path: String,
1464    signal_on_completion: bool,
1465    /// Session task mirroring this run; None when no task registry is wired.
1466    task_id: Option<String>,
1467    state: tokio::sync::Mutex<SessionBackgroundState>,
1468}
1469
1470impl SessionBackgroundSink {
1471    #[allow(clippy::too_many_arguments)]
1472    fn new(
1473        context: ToolContext,
1474        run_id: String,
1475        display_name: String,
1476        tool_name: String,
1477        log_path: String,
1478        result_path: String,
1479        signal_on_completion: bool,
1480        task_id: Option<String>,
1481    ) -> Self {
1482        Self {
1483            context,
1484            run_id,
1485            display_name,
1486            tool_name,
1487            log_path,
1488            result_path,
1489            signal_on_completion,
1490            task_id,
1491            state: tokio::sync::Mutex::new(SessionBackgroundState {
1492                status_text: "Queued".to_string(),
1493                ..Default::default()
1494            }),
1495        }
1496    }
1497
1498    /// Mirror an update onto the session task (best-effort).
1499    async fn mirror_task(&self, update: crate::session_task::SessionTaskUpdate) {
1500        let (Some(registry), Some(task_id)) = (&self.context.session_task_registry, &self.task_id)
1501        else {
1502            return;
1503        };
1504        let _ = registry
1505            .update(self.context.session_id, task_id, update)
1506            .await;
1507    }
1508
1509    async fn finalize_canceled(&self) -> Result<()> {
1510        let output_log = {
1511            let state = self.state.lock().await;
1512            let mut log = Self::final_output_log(&state);
1513            log.push_str("\nCanceled by request.\n");
1514            log
1515        };
1516        self.write_text_file(&self.log_path, &output_log).await?;
1517        let result_json = serde_json::to_string_pretty(&serde_json::json!({"status": "canceled"}))
1518            .unwrap_or_else(|_| r#"{"status":"canceled"}"#.to_string());
1519        self.write_text_file(&self.result_path, &result_json)
1520            .await?;
1521
1522        let mut state = self.state.lock().await;
1523        state.status_text = "Canceled".to_string();
1524        drop(state);
1525
1526        self.mirror_task(crate::session_task::SessionTaskUpdate {
1527            state: Some(crate::session_task::SessionTaskState::Canceled),
1528            summary: Some("Canceled by request.".to_string()),
1529            result_path: Some(self.result_path.clone()),
1530            ..Default::default()
1531        })
1532        .await;
1533        if self.signal_on_completion {
1534            self.signal_session("canceled", "Canceled by request.")
1535                .await?;
1536        }
1537        Ok(())
1538    }
1539
1540    async fn finalize(
1541        &self,
1542        outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult>,
1543    ) -> Result<()> {
1544        match outcome {
1545            Ok(outcome) => {
1546                let output_log = if let Some(raw_output) = &outcome.raw_output {
1547                    raw_output.clone()
1548                } else {
1549                    let state = self.state.lock().await;
1550                    Self::final_output_log(&state)
1551                };
1552                self.write_text_file(&self.log_path, &output_log).await?;
1553                let result_json = serde_json::to_string_pretty(&outcome.result)
1554                    .unwrap_or_else(|_| outcome.result.to_string());
1555                self.write_text_file(&self.result_path, &result_json)
1556                    .await?;
1557
1558                let mut state = self.state.lock().await;
1559                state.status_text = "Completed".to_string();
1560                drop(state);
1561                self.mirror_task(crate::session_task::SessionTaskUpdate {
1562                    state: Some(crate::session_task::SessionTaskState::Succeeded),
1563                    summary: Some(outcome.summary.clone()),
1564                    result_path: Some(self.result_path.clone()),
1565                    ..Default::default()
1566                })
1567                .await;
1568                if self.signal_on_completion {
1569                    self.signal_session("completed", &outcome.summary).await?;
1570                }
1571            }
1572            Err(err) => {
1573                let message = match err {
1574                    ToolExecutionResult::ToolError(msg) => msg,
1575                    ToolExecutionResult::InternalError(inner) => inner.message,
1576                    ToolExecutionResult::ConnectionRequired { provider } => {
1577                        format!("Background tool requires connection setup: {provider}")
1578                    }
1579                    ToolExecutionResult::Success(_)
1580                    | ToolExecutionResult::SuccessWithImages { .. } => {
1581                        "Background run ended unexpectedly".to_string()
1582                    }
1583                };
1584                let output_log = {
1585                    let state = self.state.lock().await;
1586                    Self::final_output_log(&state)
1587                };
1588                self.write_text_file(&self.log_path, &output_log).await?;
1589                let error_json = serde_json::to_string_pretty(&json!({
1590                    "status": "failed",
1591                    "error": &message,
1592                }))
1593                .unwrap_or_else(|_| {
1594                    json!({
1595                        "status": "failed",
1596                        "error": &message,
1597                    })
1598                    .to_string()
1599                });
1600                self.write_text_file(&self.result_path, &error_json).await?;
1601                let mut state = self.state.lock().await;
1602                state.status_text = "Failed".to_string();
1603                drop(state);
1604                self.mirror_task(crate::session_task::SessionTaskUpdate {
1605                    state: Some(crate::session_task::SessionTaskState::Failed),
1606                    summary: Some(message.clone()),
1607                    result_path: Some(self.result_path.clone()),
1608                    error: Some(crate::session_task::TaskError {
1609                        kind: "error".to_string(),
1610                        message: message.clone(),
1611                    }),
1612                    ..Default::default()
1613                })
1614                .await;
1615                if self.signal_on_completion {
1616                    self.signal_session("failed", &message).await?;
1617                }
1618            }
1619        }
1620
1621        Ok(())
1622    }
1623
1624    async fn signal_session(&self, status: &str, summary: &str) -> Result<()> {
1625        let Some(platform_store) = &self.context.platform_store else {
1626            return Ok(());
1627        };
1628        let message = format!(
1629            "Background run {status}.\n- run_id: {}\n- title: {}\n- tool: {}\n- summary: {}\n- result_path: {}\n- log_path: {}",
1630            self.run_id,
1631            self.display_name,
1632            self.tool_name,
1633            summary,
1634            self.result_path,
1635            self.log_path
1636        );
1637        platform_store
1638            .send_message(self.context.session_id, &message)
1639            .await
1640    }
1641
1642    async fn write_text_file(&self, path: &str, content: &str) -> Result<()> {
1643        let file_store = self.context.file_store.as_ref().ok_or_else(|| {
1644            anyhow::anyhow!(
1645                "background run {} cannot persist artifact {} because no session file store is configured",
1646                self.run_id,
1647                path
1648            )
1649        })?;
1650
1651        ensure_directory(file_store.as_ref(), self.context.session_id, "/.background").await?;
1652        let run_dir = format!("/.background/{}", self.run_id);
1653        ensure_directory(file_store.as_ref(), self.context.session_id, &run_dir).await?;
1654        file_store
1655            .write_file(self.context.session_id, path, content, "text")
1656            .await?;
1657        Ok(())
1658    }
1659}
1660
1661#[async_trait]
1662impl BackgroundEventSink for SessionBackgroundSink {
1663    async fn status(&self, message: &str) -> Result<()> {
1664        let mut state = self.state.lock().await;
1665        state.status_text = message.to_string();
1666        drop(state);
1667        self.mirror_task(crate::session_task::SessionTaskUpdate {
1668            state_detail: Some(message.to_string()),
1669            ..Default::default()
1670        })
1671        .await;
1672        Ok(())
1673    }
1674
1675    async fn output(&self, stream: &str, delta: &str) -> Result<()> {
1676        let mut state = self.state.lock().await;
1677        if !delta.is_empty() {
1678            let prefix = format!("[{stream}] ");
1679            state.output_tail.push_str(&prefix);
1680            state.output_tail.push_str(delta);
1681            Self::append_to_output_log(&mut state, &prefix, delta);
1682            if state.output_tail.chars().count() > 2048 {
1683                state.output_tail = state
1684                    .output_tail
1685                    .chars()
1686                    .rev()
1687                    .take(2048)
1688                    .collect::<Vec<_>>()
1689                    .into_iter()
1690                    .rev()
1691                    .collect();
1692            }
1693        }
1694        Ok(())
1695    }
1696
1697    async fn progress(&self, progress: BackgroundProgress) -> Result<()> {
1698        let mut state = self.state.lock().await;
1699        state.progress = Some(progress.clone());
1700        drop(state);
1701        self.mirror_task(crate::session_task::SessionTaskUpdate {
1702            progress: Some(progress),
1703            ..Default::default()
1704        })
1705        .await;
1706        Ok(())
1707    }
1708}
1709
1710impl SessionBackgroundSink {
1711    fn append_to_output_log(state: &mut SessionBackgroundState, prefix: &str, delta: &str) {
1712        if state.output_log_chars >= MAX_BACKGROUND_OUTPUT_LOG_CHARS {
1713            state.output_log_truncated = true;
1714            return;
1715        }
1716
1717        let chunk = format!("{prefix}{delta}");
1718        let remaining = MAX_BACKGROUND_OUTPUT_LOG_CHARS - state.output_log_chars;
1719        let chunk_chars = chunk.chars().count();
1720
1721        if chunk_chars <= remaining {
1722            state.output_log.push_str(&chunk);
1723            state.output_log_chars += chunk_chars;
1724            return;
1725        }
1726
1727        let truncated_chunk: String = chunk.chars().take(remaining).collect();
1728        state.output_log.push_str(&truncated_chunk);
1729        state.output_log_chars += truncated_chunk.chars().count();
1730        state.output_log_truncated = true;
1731    }
1732
1733    fn final_output_log(state: &SessionBackgroundState) -> String {
1734        if !state.output_log_truncated {
1735            return state.output_log.clone();
1736        }
1737
1738        format!(
1739            "{}\n[system] background output truncated at {} characters\n",
1740            state.output_log, MAX_BACKGROUND_OUTPUT_LOG_CHARS
1741        )
1742    }
1743}
1744
1745/// Internal sentinel injected by the cancel-watch select branch. Namespaced
1746/// so a background tool that legitimately fails with "canceled" is never
1747/// misclassified as a cooperative cancel.
1748const BACKGROUND_CANCEL_SENTINEL: &str = "__everruns_background_cancel__";
1749
1750/// Returns true when an outcome from the cancel-watch select is the sentinel
1751/// cancel signal.  Factored out so the condition is testable without a
1752/// running async runtime.
1753fn is_canceled_outcome(
1754    outcome: &std::result::Result<BackgroundOutcome, ToolExecutionResult>,
1755) -> bool {
1756    matches!(outcome, Err(ToolExecutionResult::ToolError(msg)) if msg == BACKGROUND_CANCEL_SENTINEL)
1757}
1758
1759/// Re-attach a `background_tool` task after worker loss.
1760///
1761/// Called by `BackgroundToolTaskExecutor::start()` when the reaper decides the
1762/// task is safe to restart. Reads `spec["tool"]` and `spec["arguments"]` from
1763/// the task, looks up the tool in the built-in default registry, and spawns a
1764/// fresh background run with `task.attempt` as the heartbeat fence. New artifact
1765/// paths are generated so old partial artifacts do not conflict.
1766///
1767/// Returns an error (→ reaper falls back to orphaned-fail) when:
1768/// - `context.file_store` or `context.session_task_registry` is absent
1769/// - `spec["tool"]` is absent or empty
1770/// - the tool is not in the built-in default registry
1771/// - the tool does not implement `BackgroundExecutable`
1772/// - the tool's current hints are not `idempotent` or `readonly`
1773/// - per-worker or per-session background concurrency caps are exhausted
1774pub(crate) async fn reattach_background_run(
1775    task: &crate::session_task::SessionTask,
1776    context: &crate::traits::ToolContext,
1777) -> crate::error::Result<()> {
1778    // Fail fast before spawning a tokio task so the reaper can fall back to
1779    // orphaned-fail rather than leaving the task stuck in Running forever.
1780    if context.file_store.is_none() {
1781        return Err(crate::error::AgentLoopError::tool(
1782            "file store not available; cannot re-attach background run",
1783        ));
1784    }
1785    if context.session_task_registry.is_none() {
1786        return Err(crate::error::AgentLoopError::tool(
1787            "task registry not available; cannot re-attach background run",
1788        ));
1789    }
1790
1791    let tool_name: String = task
1792        .spec
1793        .get("tool")
1794        .and_then(|v| v.as_str())
1795        .filter(|s| !s.is_empty())
1796        .map(str::to_owned)
1797        .ok_or_else(|| {
1798            crate::error::AgentLoopError::tool(
1799                "background_tool spec missing 'tool' field; cannot re-attach",
1800            )
1801        })?;
1802
1803    let tool_args = task
1804        .spec
1805        .get("arguments")
1806        .cloned()
1807        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
1808
1809    let registry = std::sync::Arc::new(ToolRegistry::with_defaults());
1810
1811    let Some(tool) = registry.get(&tool_name).cloned() else {
1812        return Err(crate::error::AgentLoopError::tool(format!(
1813            "tool '{tool_name}' not found in built-in registry; cannot re-attach"
1814        )));
1815    };
1816
1817    if tool.as_background_executable().is_none() {
1818        return Err(crate::error::AgentLoopError::tool(format!(
1819            "tool '{tool_name}' does not support background execution; cannot re-attach"
1820        )));
1821    }
1822
1823    // Re-verify tool hints from the live registry rather than trusting
1824    // spec["reattachable"], which could be forged via task creation APIs.
1825    let hints = tool.hints();
1826    if !hints.idempotent.unwrap_or(false) && !hints.readonly.unwrap_or(false) {
1827        return Err(crate::error::AgentLoopError::tool(format!(
1828            "tool '{tool_name}' is not idempotent or readonly; re-attach declined",
1829        )));
1830    }
1831
1832    // Enforce the same concurrency caps as spawn_background so many concurrent
1833    // re-attaches cannot exhaust worker or session limits.
1834    let background_run_permit = ACTIVE_BACKGROUND_RUNS_PER_WORKER
1835        .try_acquire()
1836        .map_err(|_| {
1837            crate::error::AgentLoopError::tool(
1838                "worker background run limit reached; re-attach deferred",
1839            )
1840        })?;
1841    let session_run_permit =
1842        try_acquire_session_background_permit(task.session_id).map_err(|_| {
1843            crate::error::AgentLoopError::tool(
1844                "session background run limit reached; re-attach deferred",
1845            )
1846        })?;
1847
1848    // Restore original signaling behavior; default true for tasks created before
1849    // this field was persisted.
1850    let signal_on_completion = task
1851        .spec
1852        .get("signal_on_completion")
1853        .and_then(|v| v.as_bool())
1854        .unwrap_or(true);
1855
1856    let run_id = format!("bg_{}", uuid::Uuid::now_v7().simple());
1857    let artifact_dir = format!("/.background/{run_id}");
1858    let log_path = format!("{artifact_dir}/output.log");
1859    let result_path = format!("{artifact_dir}/result.json");
1860
1861    let task_id = task.id.clone();
1862    let task_attempt = task.attempt;
1863    let session_id = task.session_id;
1864
1865    let sink_context = context.clone().with_tool_registry(registry);
1866    let sink = std::sync::Arc::new(SessionBackgroundSink::new(
1867        sink_context.clone(),
1868        run_id.clone(),
1869        task.display_name.clone(),
1870        tool_name.to_string(),
1871        log_path,
1872        result_path,
1873        signal_on_completion,
1874        Some(task_id.clone()),
1875    ));
1876
1877    let cancel_registry = context.session_task_registry.clone();
1878    let run_id_for_log = run_id.clone();
1879
1880    tokio::spawn(async move {
1881        // Hold permits for the duration of the re-attached run.
1882        let _background_run_permit = background_run_permit;
1883        let _session_run_permit = session_run_permit;
1884        let _ = sink.status("Re-attaching").await;
1885
1886        let outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
1887            match (cancel_registry.as_ref(), Some(task_id.as_str())) {
1888                (Some(registry), Some(task_id_str)) => {
1889                    let registry = registry.clone();
1890                    let task_id_str = task_id_str.to_string();
1891                    let tool_fut = async {
1892                        match tool.as_background_executable() {
1893                            Some(bg) => {
1894                                bg.execute_background(tool_args, sink_context.clone(), sink.clone())
1895                                    .await
1896                            }
1897                            None => Err(ToolExecutionResult::tool_error(format!(
1898                                "tool '{tool_name}' lost background support during re-attach"
1899                            ))),
1900                        }
1901                    };
1902                    let watch_fut = async {
1903                        loop {
1904                            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1905                            let _ = registry
1906                                .update(
1907                                    session_id,
1908                                    &task_id_str,
1909                                    crate::session_task::SessionTaskUpdate {
1910                                        heartbeat_at: Some(chrono::Utc::now()),
1911                                        expected_attempt: Some(task_attempt),
1912                                        ..Default::default()
1913                                    },
1914                                )
1915                                .await;
1916                            if let Ok(Some(t)) = registry.get(session_id, &task_id_str).await
1917                                && t.cancel_requested_at.is_some()
1918                            {
1919                                break;
1920                            }
1921                        }
1922                    };
1923                    tokio::select! {
1924                        result = tool_fut => result,
1925                        () = watch_fut => Err(ToolExecutionResult::ToolError(
1926                            BACKGROUND_CANCEL_SENTINEL.to_string(),
1927                        )),
1928                    }
1929                }
1930                _ => match tool.as_background_executable() {
1931                    Some(bg) => {
1932                        bg.execute_background(tool_args, sink_context, sink.clone())
1933                            .await
1934                    }
1935                    None => Err(ToolExecutionResult::tool_error(format!(
1936                        "tool '{tool_name}' lost background support during re-attach"
1937                    ))),
1938                },
1939            };
1940
1941        let finalize_result = if is_canceled_outcome(&outcome) {
1942            sink.finalize_canceled().await
1943        } else {
1944            sink.finalize(outcome).await
1945        };
1946        if let Err(err) = finalize_result {
1947            tracing::warn!(
1948                run_id = run_id_for_log,
1949                error = %err,
1950                "Background run re-attach finalization failed"
1951            );
1952        }
1953    });
1954
1955    Ok(())
1956}
1957
1958async fn ensure_directory(
1959    file_store: &dyn crate::traits::SessionFileSystem,
1960    session_id: crate::SessionId,
1961    path: &str,
1962) -> Result<()> {
1963    if let Some(entry) = file_store.stat_file(session_id, path).await? {
1964        if entry.is_directory {
1965            return Ok(());
1966        }
1967        return Err(anyhow::anyhow!("path exists but is not a directory: {path}").into());
1968    }
1969    let _ = file_store.create_directory(session_id, path).await?;
1970    Ok(())
1971}
1972
1973/// A tool that always fails (useful for testing error handling)
1974pub struct FailingTool {
1975    error_message: String,
1976    use_internal_error: bool,
1977}
1978
1979impl FailingTool {
1980    /// Create a failing tool with a tool-level error
1981    pub fn with_tool_error(message: impl Into<String>) -> Self {
1982        Self {
1983            error_message: message.into(),
1984            use_internal_error: false,
1985        }
1986    }
1987
1988    /// Create a failing tool with an internal error
1989    pub fn with_internal_error(message: impl Into<String>) -> Self {
1990        Self {
1991            error_message: message.into(),
1992            use_internal_error: true,
1993        }
1994    }
1995}
1996
1997impl Default for FailingTool {
1998    fn default() -> Self {
1999        Self::with_tool_error("Tool execution failed")
2000    }
2001}
2002
2003#[async_trait]
2004impl Tool for FailingTool {
2005    fn name(&self) -> &str {
2006        "failing_tool"
2007    }
2008
2009    fn display_name(&self) -> Option<&str> {
2010        Some("Failing Tool")
2011    }
2012
2013    fn description(&self) -> &str {
2014        "A tool that always fails (for testing error handling)"
2015    }
2016
2017    fn parameters_schema(&self) -> Value {
2018        serde_json::json!({
2019            "type": "object",
2020            "properties": {},
2021            "additionalProperties": false
2022        })
2023    }
2024
2025    fn hints(&self) -> ToolHints {
2026        ToolHints::default()
2027            .with_readonly(true)
2028            .with_idempotent(true)
2029    }
2030
2031    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2032        if self.use_internal_error {
2033            ToolExecutionResult::internal_error_msg(&self.error_message)
2034        } else {
2035            ToolExecutionResult::tool_error(&self.error_message)
2036        }
2037    }
2038}
2039
2040// ============================================================================
2041// Tests
2042// ============================================================================
2043
2044#[cfg(test)]
2045mod tests {
2046    use super::*;
2047    use crate::capabilities::GetCurrentTimeTool;
2048    use crate::platform_store::PlatformStore;
2049    use crate::session_file::{FileInfo, FileStat, SessionFile};
2050    use crate::session_task::SessionTaskRegistry;
2051    use crate::traits::{SessionFileSystem, SessionScheduleStore};
2052    use crate::typed_id::{HarnessId, SessionId};
2053    use crate::{AgentId, KeyInfo, PlatformMessage, SecretInfo};
2054    use async_trait::async_trait;
2055    use std::sync::{
2056        Arc as StdArc, Mutex,
2057        atomic::{AtomicBool, Ordering},
2058    };
2059
2060    #[derive(Default)]
2061    struct TestBackgroundTool;
2062
2063    #[async_trait]
2064    impl BackgroundExecutableTool for TestBackgroundTool {
2065        async fn execute_background(
2066            &self,
2067            arguments: Value,
2068            _context: ToolContext,
2069            sink: Arc<dyn BackgroundEventSink>,
2070        ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2071            sink.status("Waiting for test result")
2072                .await
2073                .map_err(ToolExecutionResult::internal_error)?;
2074            sink.output("stdout", "hello from background")
2075                .await
2076                .map_err(ToolExecutionResult::internal_error)?;
2077            sink.progress(BackgroundProgress {
2078                current: Some(1),
2079                total: Some(1),
2080                unit: Some("step".to_string()),
2081                label: Some("done".to_string()),
2082            })
2083            .await
2084            .map_err(ToolExecutionResult::internal_error)?;
2085
2086            Ok(BackgroundOutcome {
2087                summary: arguments["summary"].as_str().unwrap_or("done").to_string(),
2088                result: json!({"ok": true}),
2089                raw_output: None,
2090            })
2091        }
2092    }
2093
2094    #[async_trait]
2095    impl Tool for TestBackgroundTool {
2096        fn name(&self) -> &str {
2097            "test_background"
2098        }
2099
2100        fn display_name(&self) -> Option<&str> {
2101            Some("Test Background")
2102        }
2103
2104        fn description(&self) -> &str {
2105            "test tool"
2106        }
2107
2108        fn parameters_schema(&self) -> Value {
2109            json!({
2110                "type": "object",
2111                "properties": {
2112                    "summary": { "type": "string" }
2113                }
2114            })
2115        }
2116
2117        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2118            ToolExecutionResult::tool_error("foreground unsupported")
2119        }
2120
2121        fn hints(&self) -> ToolHints {
2122            ToolHints::default().with_supports_background(true)
2123        }
2124
2125        fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2126            Some(self)
2127        }
2128    }
2129
2130    #[derive(Default)]
2131    struct TestFailingBackgroundTool;
2132
2133    #[async_trait]
2134    impl BackgroundExecutableTool for TestFailingBackgroundTool {
2135        async fn execute_background(
2136            &self,
2137            _arguments: Value,
2138            _context: ToolContext,
2139            sink: Arc<dyn BackgroundEventSink>,
2140        ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2141            sink.status("Running failing test")
2142                .await
2143                .map_err(ToolExecutionResult::internal_error)?;
2144            sink.output("stderr", "background failed")
2145                .await
2146                .map_err(ToolExecutionResult::internal_error)?;
2147            Err(ToolExecutionResult::tool_error("boom"))
2148        }
2149    }
2150
2151    #[async_trait]
2152    impl Tool for TestFailingBackgroundTool {
2153        fn name(&self) -> &str {
2154            "test_background_fail"
2155        }
2156
2157        fn display_name(&self) -> Option<&str> {
2158            Some("Test Background Fail")
2159        }
2160
2161        fn description(&self) -> &str {
2162            "failing background test tool"
2163        }
2164
2165        fn parameters_schema(&self) -> Value {
2166            json!({
2167                "type": "object",
2168                "properties": {}
2169            })
2170        }
2171
2172        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2173            ToolExecutionResult::tool_error("foreground unsupported")
2174        }
2175
2176        fn hints(&self) -> ToolHints {
2177            ToolHints::default().with_supports_background(true)
2178        }
2179
2180        fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2181            Some(self)
2182        }
2183    }
2184
2185    #[derive(Default)]
2186    struct TestLargeOutputBackgroundTool;
2187
2188    #[async_trait]
2189    impl BackgroundExecutableTool for TestLargeOutputBackgroundTool {
2190        async fn execute_background(
2191            &self,
2192            _arguments: Value,
2193            _context: ToolContext,
2194            sink: Arc<dyn BackgroundEventSink>,
2195        ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2196            let large_chunk = "x".repeat(MAX_BACKGROUND_OUTPUT_LOG_CHARS + 4096);
2197            sink.output("stdout", &large_chunk)
2198                .await
2199                .map_err(ToolExecutionResult::internal_error)?;
2200            Ok(BackgroundOutcome {
2201                summary: "large output complete".to_string(),
2202                result: json!({"ok": true}),
2203                raw_output: None,
2204            })
2205        }
2206    }
2207
2208    #[async_trait]
2209    impl Tool for TestLargeOutputBackgroundTool {
2210        fn name(&self) -> &str {
2211            "test_background_large_output"
2212        }
2213
2214        fn display_name(&self) -> Option<&str> {
2215            Some("Test Background Large Output")
2216        }
2217
2218        fn description(&self) -> &str {
2219            "background test tool with huge output"
2220        }
2221
2222        fn parameters_schema(&self) -> Value {
2223            json!({
2224                "type": "object",
2225                "properties": {}
2226            })
2227        }
2228
2229        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2230            ToolExecutionResult::tool_error("foreground unsupported")
2231        }
2232
2233        fn hints(&self) -> ToolHints {
2234            ToolHints::default().with_supports_background(true)
2235        }
2236
2237        fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2238            Some(self)
2239        }
2240    }
2241
2242    struct BlockingBackgroundTool {
2243        release: StdArc<AtomicBool>,
2244    }
2245
2246    #[async_trait]
2247    impl BackgroundExecutableTool for BlockingBackgroundTool {
2248        async fn execute_background(
2249            &self,
2250            _arguments: Value,
2251            _context: ToolContext,
2252            sink: Arc<dyn BackgroundEventSink>,
2253        ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2254            sink.status("Blocking until released")
2255                .await
2256                .map_err(ToolExecutionResult::internal_error)?;
2257            while !self.release.load(Ordering::SeqCst) {
2258                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2259            }
2260            Ok(BackgroundOutcome {
2261                summary: "released".to_string(),
2262                result: json!({"ok": true}),
2263                raw_output: None,
2264            })
2265        }
2266    }
2267
2268    #[async_trait]
2269    impl Tool for BlockingBackgroundTool {
2270        fn name(&self) -> &str {
2271            "test_background_blocking"
2272        }
2273
2274        fn display_name(&self) -> Option<&str> {
2275            Some("Test Background Blocking")
2276        }
2277
2278        fn description(&self) -> &str {
2279            "background test tool that waits for test release"
2280        }
2281
2282        fn parameters_schema(&self) -> Value {
2283            json!({
2284                "type": "object",
2285                "properties": {}
2286            })
2287        }
2288
2289        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2290            ToolExecutionResult::tool_error("foreground unsupported")
2291        }
2292
2293        fn hints(&self) -> ToolHints {
2294            ToolHints::default().with_supports_background(true)
2295        }
2296
2297        fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2298            Some(self)
2299        }
2300    }
2301
2302    #[derive(Default)]
2303    struct TestFileStore {
2304        files: Mutex<HashMap<String, SessionFile>>,
2305    }
2306
2307    #[async_trait]
2308    impl crate::traits::SessionFileSystem for TestFileStore {
2309        async fn read_file(
2310            &self,
2311            _session_id: SessionId,
2312            path: &str,
2313        ) -> crate::Result<Option<SessionFile>> {
2314            Ok(self.files.lock().unwrap().get(path).cloned())
2315        }
2316
2317        async fn write_file(
2318            &self,
2319            session_id: SessionId,
2320            path: &str,
2321            content: &str,
2322            encoding: &str,
2323        ) -> crate::Result<SessionFile> {
2324            let now = chrono::Utc::now();
2325            let file = SessionFile {
2326                id: uuid::Uuid::now_v7(),
2327                session_id: session_id.uuid(),
2328                path: path.to_string(),
2329                name: FileInfo::name_from_path(path),
2330                content: Some(content.to_string()),
2331                encoding: encoding.to_string(),
2332                is_directory: false,
2333                is_readonly: false,
2334                size_bytes: content.len() as i64,
2335                created_at: now,
2336                updated_at: now,
2337            };
2338            self.files
2339                .lock()
2340                .unwrap()
2341                .insert(path.to_string(), file.clone());
2342            Ok(file)
2343        }
2344
2345        async fn delete_file(
2346            &self,
2347            _session_id: SessionId,
2348            _path: &str,
2349            _recursive: bool,
2350        ) -> crate::Result<bool> {
2351            Ok(false)
2352        }
2353
2354        async fn list_directory(
2355            &self,
2356            _session_id: SessionId,
2357            _path: &str,
2358        ) -> crate::Result<Vec<FileInfo>> {
2359            Ok(Vec::new())
2360        }
2361
2362        async fn stat_file(
2363            &self,
2364            _session_id: SessionId,
2365            path: &str,
2366        ) -> crate::Result<Option<FileStat>> {
2367            let file = self.files.lock().unwrap().get(path).cloned();
2368            Ok(file.map(|entry| FileStat {
2369                path: entry.path,
2370                name: entry.name,
2371                is_directory: entry.is_directory,
2372                is_readonly: entry.is_readonly,
2373                size_bytes: entry.size_bytes,
2374                created_at: entry.created_at,
2375                updated_at: entry.updated_at,
2376            }))
2377        }
2378
2379        async fn grep_files(
2380            &self,
2381            _session_id: SessionId,
2382            _pattern: &str,
2383            _path_pattern: Option<&str>,
2384        ) -> crate::Result<Vec<crate::session_file::GrepMatch>> {
2385            Ok(Vec::new())
2386        }
2387
2388        async fn create_directory(
2389            &self,
2390            session_id: SessionId,
2391            path: &str,
2392        ) -> crate::Result<FileInfo> {
2393            let now = chrono::Utc::now();
2394            let id = uuid::Uuid::now_v7();
2395            let dir = SessionFile {
2396                id,
2397                session_id: session_id.uuid(),
2398                path: path.to_string(),
2399                name: FileInfo::name_from_path(path),
2400                content: None,
2401                encoding: "text".to_string(),
2402                is_directory: true,
2403                is_readonly: false,
2404                size_bytes: 0,
2405                created_at: now,
2406                updated_at: now,
2407            };
2408            self.files.lock().unwrap().insert(path.to_string(), dir);
2409            Ok(FileInfo {
2410                id,
2411                session_id: session_id.uuid(),
2412                path: path.to_string(),
2413                name: FileInfo::name_from_path(path),
2414                is_directory: true,
2415                is_readonly: false,
2416                size_bytes: 0,
2417                created_at: now,
2418                updated_at: now,
2419            })
2420        }
2421    }
2422
2423    #[derive(Default)]
2424    struct TestPlatformStore {
2425        sent_messages: Mutex<Vec<String>>,
2426    }
2427
2428    #[async_trait]
2429    impl PlatformStore for TestPlatformStore {
2430        async fn list_harnesses(&self) -> crate::Result<Vec<crate::Harness>> {
2431            Ok(Vec::new())
2432        }
2433        async fn get_harness(&self, _id: HarnessId) -> crate::Result<Option<crate::Harness>> {
2434            Ok(None)
2435        }
2436        async fn create_harness(
2437            &self,
2438            _name: &str,
2439            _display_name: Option<&str>,
2440            _description: Option<&str>,
2441            _system_prompt: Option<&str>,
2442            _parent_harness_id: Option<HarnessId>,
2443            _capabilities: &[String],
2444        ) -> crate::Result<crate::Harness> {
2445            unreachable!()
2446        }
2447        async fn update_harness(
2448            &self,
2449            _id: HarnessId,
2450            _name: Option<&str>,
2451            _display_name: Option<&str>,
2452            _description: Option<&str>,
2453            _system_prompt: Option<&str>,
2454            _parent_harness_id: Option<Option<HarnessId>>,
2455        ) -> crate::Result<crate::Harness> {
2456            unreachable!()
2457        }
2458        async fn delete_harness(&self, _id: HarnessId) -> crate::Result<()> {
2459            Ok(())
2460        }
2461        async fn copy_harness(
2462            &self,
2463            _id: HarnessId,
2464            _new_name: Option<&str>,
2465        ) -> crate::Result<crate::Harness> {
2466            unreachable!()
2467        }
2468        async fn list_agents(&self) -> crate::Result<Vec<crate::Agent>> {
2469            Ok(Vec::new())
2470        }
2471        async fn get_agent_by_id(&self, _id: AgentId) -> crate::Result<Option<crate::Agent>> {
2472            Ok(None)
2473        }
2474        async fn create_agent(
2475            &self,
2476            _name: &str,
2477            _display_name: Option<&str>,
2478            _description: Option<&str>,
2479            _system_prompt: &str,
2480            _capabilities: &[String],
2481        ) -> crate::Result<crate::Agent> {
2482            unreachable!()
2483        }
2484        async fn update_agent(
2485            &self,
2486            _id: AgentId,
2487            _name: Option<&str>,
2488            _display_name: Option<&str>,
2489            _description: Option<&str>,
2490            _system_prompt: Option<&str>,
2491        ) -> crate::Result<crate::Agent> {
2492            unreachable!()
2493        }
2494        async fn delete_agent(&self, _id: AgentId) -> crate::Result<()> {
2495            Ok(())
2496        }
2497        async fn list_apps(
2498            &self,
2499            _search: Option<&str>,
2500            _include_archived: bool,
2501        ) -> crate::Result<Vec<crate::App>> {
2502            Ok(Vec::new())
2503        }
2504        async fn get_app(&self, _id: crate::AppId) -> crate::Result<Option<crate::App>> {
2505            Ok(None)
2506        }
2507        async fn create_app(
2508            &self,
2509            _name: &str,
2510            _description: Option<&str>,
2511            _harness_id: HarnessId,
2512            _agent_id: Option<AgentId>,
2513            _agent_identity_id: Option<crate::AgentIdentityId>,
2514            _channel_type: Option<crate::ChannelType>,
2515            _channel_config: Option<&serde_json::Value>,
2516        ) -> crate::Result<crate::App> {
2517            unreachable!()
2518        }
2519        async fn update_app(
2520            &self,
2521            _id: crate::AppId,
2522            _name: Option<&str>,
2523            _description: Option<&str>,
2524            _harness_id: Option<HarnessId>,
2525            _agent_id: Option<AgentId>,
2526            _agent_identity_id: Option<Option<crate::AgentIdentityId>>,
2527        ) -> crate::Result<crate::App> {
2528            unreachable!()
2529        }
2530        async fn delete_app(&self, _id: crate::AppId) -> crate::Result<()> {
2531            Ok(())
2532        }
2533        async fn destroy_app(&self, _id: crate::AppId) -> crate::Result<()> {
2534            Ok(())
2535        }
2536        async fn publish_app(&self, _id: crate::AppId) -> crate::Result<crate::App> {
2537            unreachable!()
2538        }
2539        async fn unpublish_app(&self, _id: crate::AppId) -> crate::Result<crate::App> {
2540            unreachable!()
2541        }
2542        async fn add_app_channel(
2543            &self,
2544            _app_id: crate::AppId,
2545            _channel_type: crate::ChannelType,
2546            _channel_config: Option<&serde_json::Value>,
2547            _enabled: Option<bool>,
2548        ) -> crate::Result<crate::AppChannel> {
2549            unreachable!()
2550        }
2551        async fn update_app_channel(
2552            &self,
2553            _app_id: crate::AppId,
2554            _channel_id: crate::AppChannelId,
2555            _channel_type: Option<crate::ChannelType>,
2556            _channel_config: Option<&serde_json::Value>,
2557            _enabled: Option<bool>,
2558        ) -> crate::Result<crate::AppChannel> {
2559            unreachable!()
2560        }
2561        async fn delete_app_channel(
2562            &self,
2563            _app_id: crate::AppId,
2564            _channel_id: crate::AppChannelId,
2565        ) -> crate::Result<()> {
2566            Ok(())
2567        }
2568        async fn list_sessions(
2569            &self,
2570            _limit: Option<usize>,
2571            _agent_id: Option<AgentId>,
2572        ) -> crate::Result<Vec<crate::Session>> {
2573            Ok(Vec::new())
2574        }
2575        async fn create_session(
2576            &self,
2577            _harness_id: HarnessId,
2578            _agent_id: Option<AgentId>,
2579            _title: Option<&str>,
2580            _locale: Option<&str>,
2581            _blueprint_id: Option<&str>,
2582            _blueprint_config: Option<&serde_json::Value>,
2583            _parent_session_id: Option<SessionId>,
2584        ) -> crate::Result<crate::Session> {
2585            unreachable!()
2586        }
2587        async fn get_session_by_id(&self, _id: SessionId) -> crate::Result<Option<crate::Session>> {
2588            Ok(None)
2589        }
2590        async fn get_session_context_report(
2591            &self,
2592            id: SessionId,
2593        ) -> crate::Result<crate::SessionContextReport> {
2594            Ok(crate::SessionContextReport {
2595                session_id: id.to_string(),
2596                model: "llmsim".to_string(),
2597                context_window_tokens: None,
2598                estimated_input_tokens: 0,
2599                sections: vec![],
2600                contributions: vec![],
2601                cumulative_usage: None,
2602            })
2603        }
2604        async fn delete_session(&self, _id: SessionId) -> crate::Result<()> {
2605            Ok(())
2606        }
2607        async fn send_message(&self, _session_id: SessionId, content: &str) -> crate::Result<()> {
2608            self.sent_messages.lock().unwrap().push(content.to_string());
2609            Ok(())
2610        }
2611        async fn get_messages(
2612            &self,
2613            _session_id: SessionId,
2614            _limit: Option<usize>,
2615        ) -> crate::Result<Vec<PlatformMessage>> {
2616            Ok(Vec::new())
2617        }
2618        async fn wait_for_idle(
2619            &self,
2620            _session_id: SessionId,
2621            _timeout_secs: Option<u64>,
2622        ) -> crate::Result<String> {
2623            Ok("idle".to_string())
2624        }
2625        async fn list_capabilities(
2626            &self,
2627            _search: Option<&str>,
2628        ) -> crate::Result<Vec<crate::CapabilityInfo>> {
2629            Ok(Vec::new())
2630        }
2631        fn base_url(&self) -> &str {
2632            "http://localhost:9300"
2633        }
2634    }
2635
2636    #[derive(Default)]
2637    struct NoopStorageStore;
2638
2639    #[derive(Default)]
2640    struct TestScheduleStore {
2641        schedules: Mutex<Vec<crate::session_schedule::SessionSchedule>>,
2642    }
2643
2644    #[async_trait]
2645    impl crate::traits::SessionScheduleStore for TestScheduleStore {
2646        async fn create_schedule(
2647            &self,
2648            session_id: SessionId,
2649            description: String,
2650            cron_expression: Option<String>,
2651            scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
2652            timezone: String,
2653        ) -> crate::Result<crate::session_schedule::SessionSchedule> {
2654            let schedule = crate::session_schedule::SessionSchedule {
2655                id: crate::typed_id::ScheduleId::new(),
2656                session_id,
2657                owner_principal_id: crate::PrincipalId::from_seed(1),
2658                resolved_owner_user_id: None,
2659                owner: None,
2660                effective_owner: None,
2661                description,
2662                cron_expression: cron_expression.clone(),
2663                scheduled_at,
2664                timezone,
2665                enabled: true,
2666                schedule_type: crate::session_schedule::SessionSchedule::derive_type(
2667                    &cron_expression,
2668                ),
2669                next_trigger_at: Some(chrono::Utc::now() + chrono::Duration::minutes(10)),
2670                last_triggered_at: None,
2671                trigger_count: 0,
2672                created_at: chrono::Utc::now(),
2673                updated_at: chrono::Utc::now(),
2674            };
2675            self.schedules.lock().unwrap().push(schedule.clone());
2676            Ok(schedule)
2677        }
2678
2679        async fn cancel_schedule(
2680            &self,
2681            _session_id: SessionId,
2682            schedule_id: crate::ScheduleId,
2683        ) -> crate::Result<crate::session_schedule::SessionSchedule> {
2684            let mut schedules = self.schedules.lock().unwrap();
2685            let schedule = schedules
2686                .iter_mut()
2687                .find(|schedule| schedule.id == schedule_id)
2688                .ok_or_else(|| crate::AgentLoopError::tool("Schedule not found".to_string()))?;
2689            schedule.enabled = false;
2690            Ok(schedule.clone())
2691        }
2692
2693        async fn list_schedules(
2694            &self,
2695            session_id: SessionId,
2696        ) -> crate::Result<Vec<crate::session_schedule::SessionSchedule>> {
2697            Ok(self
2698                .schedules
2699                .lock()
2700                .unwrap()
2701                .iter()
2702                .filter(|schedule| schedule.session_id == session_id)
2703                .cloned()
2704                .collect())
2705        }
2706
2707        async fn count_active_schedules(&self, session_id: SessionId) -> crate::Result<u32> {
2708            Ok(self
2709                .schedules
2710                .lock()
2711                .unwrap()
2712                .iter()
2713                .filter(|schedule| schedule.session_id == session_id && schedule.enabled)
2714                .count() as u32)
2715        }
2716
2717        async fn count_active_org_schedules(&self) -> crate::Result<u32> {
2718            // Test store is single-org; count all enabled schedules.
2719            Ok(self
2720                .schedules
2721                .lock()
2722                .unwrap()
2723                .iter()
2724                .filter(|schedule| schedule.enabled)
2725                .count() as u32)
2726        }
2727    }
2728
2729    #[async_trait]
2730    impl crate::traits::SessionStorageStore for NoopStorageStore {
2731        async fn set_value(
2732            &self,
2733            _session_id: SessionId,
2734            _key: &str,
2735            _value: &str,
2736        ) -> crate::Result<()> {
2737            Ok(())
2738        }
2739        async fn get_value(
2740            &self,
2741            _session_id: SessionId,
2742            _key: &str,
2743        ) -> crate::Result<Option<String>> {
2744            Ok(None)
2745        }
2746        async fn delete_value(&self, _session_id: SessionId, _key: &str) -> crate::Result<bool> {
2747            Ok(false)
2748        }
2749        async fn list_keys(&self, _session_id: SessionId) -> crate::Result<Vec<KeyInfo>> {
2750            Ok(Vec::new())
2751        }
2752        async fn set_secret(
2753            &self,
2754            _session_id: SessionId,
2755            _name: &str,
2756            _value: &str,
2757        ) -> crate::Result<()> {
2758            Ok(())
2759        }
2760        async fn get_secret(
2761            &self,
2762            _session_id: SessionId,
2763            _name: &str,
2764        ) -> crate::Result<Option<String>> {
2765            Ok(None)
2766        }
2767        async fn delete_secret(&self, _session_id: SessionId, _name: &str) -> crate::Result<bool> {
2768            Ok(false)
2769        }
2770        async fn list_secrets(&self, _session_id: SessionId) -> crate::Result<Vec<SecretInfo>> {
2771            Ok(Vec::new())
2772        }
2773    }
2774
2775    #[tokio::test]
2776    async fn test_echo_tool() {
2777        let tool = EchoTool;
2778
2779        let result = tool
2780            .execute(serde_json::json!({"message": "Hello, world!"}))
2781            .await;
2782
2783        if let ToolExecutionResult::Success(value) = result {
2784            assert_eq!(
2785                value.get("echoed").unwrap().as_str().unwrap(),
2786                "Hello, world!"
2787            );
2788            assert_eq!(value.get("length").unwrap().as_u64().unwrap(), 13);
2789        } else {
2790            panic!("Expected success");
2791        }
2792    }
2793
2794    #[tokio::test]
2795    async fn test_failing_tool_with_tool_error() {
2796        let tool = FailingTool::with_tool_error("Something went wrong");
2797
2798        let result = tool.execute(serde_json::json!({})).await;
2799
2800        if let ToolExecutionResult::ToolError(msg) = result {
2801            assert_eq!(msg, "Something went wrong");
2802        } else {
2803            panic!("Expected tool error");
2804        }
2805    }
2806
2807    #[tokio::test]
2808    async fn test_failing_tool_with_internal_error() {
2809        let tool = FailingTool::with_internal_error("Database connection failed");
2810
2811        let result = tool.execute(serde_json::json!({})).await;
2812
2813        if let ToolExecutionResult::InternalError(err) = result {
2814            assert_eq!(err.message, "Database connection failed");
2815        } else {
2816            panic!("Expected internal error");
2817        }
2818    }
2819
2820    #[tokio::test]
2821    async fn test_tool_result_conversion() {
2822        // Success
2823        let result = ToolExecutionResult::success(serde_json::json!({"value": 42}));
2824        let tool_result = result.into_tool_result("call_1", "test_tool");
2825        assert!(tool_result.error.is_none());
2826        assert_eq!(tool_result.result.unwrap()["value"], 42);
2827
2828        // Tool error (packaged as {"error": "..."} in result field, also sets error)
2829        let result = ToolExecutionResult::tool_error("Invalid input");
2830        let tool_result = result.into_tool_result("call_2", "test_tool");
2831        assert_eq!(tool_result.error.as_deref(), Some("Invalid input"));
2832        assert_eq!(
2833            tool_result.result.unwrap(),
2834            serde_json::json!({"error": "Invalid input"})
2835        );
2836
2837        // Internal error (packaged as {"error": "..."} with generic message)
2838        let result = ToolExecutionResult::internal_error_msg("Secret database error");
2839        let tool_result = result.into_tool_result("call_3", "test_tool");
2840        assert_eq!(
2841            tool_result.error.as_deref(),
2842            Some("An internal error occurred while executing the tool")
2843        );
2844        assert_eq!(
2845            tool_result.result.unwrap(),
2846            serde_json::json!({"error": "An internal error occurred while executing the tool"})
2847        );
2848    }
2849
2850    #[tokio::test]
2851    async fn test_tool_registry() {
2852        let mut registry = ToolRegistry::new();
2853        registry.register(GetCurrentTimeTool);
2854        registry.register(EchoTool);
2855
2856        assert_eq!(registry.len(), 2);
2857        assert!(registry.has("get_current_time"));
2858        assert!(registry.has("echo"));
2859        assert!(!registry.has("nonexistent"));
2860
2861        let definitions = registry.tool_definitions();
2862        assert_eq!(definitions.len(), 2);
2863    }
2864
2865    #[tokio::test]
2866    async fn test_tool_registry_builder() {
2867        let registry = ToolRegistry::builder()
2868            .tool(GetCurrentTimeTool)
2869            .tool(EchoTool)
2870            .build();
2871
2872        assert_eq!(registry.len(), 2);
2873    }
2874
2875    #[test]
2876    fn test_tool_display_name_in_definition() {
2877        // GetCurrentTimeTool has display_name "Get Current Time"
2878        let tool = GetCurrentTimeTool;
2879        assert_eq!(tool.display_name(), Some("Get Current Time"));
2880
2881        let def = tool.to_definition();
2882        assert_eq!(def.display_name(), Some("Get Current Time"));
2883    }
2884
2885    #[test]
2886    fn test_success_with_raw_output_object_preserves_shape() {
2887        let res = ToolExecutionResult::success_with_raw_output(
2888            serde_json::json!({"stdout": "hello"}),
2889            "raw stdout bytes".to_string(),
2890        );
2891        let tr = res.into_tool_result("call_1", "demo");
2892        assert_eq!(tr.result.as_ref().unwrap()["stdout"], "hello");
2893        assert!(
2894            tr.result
2895                .as_ref()
2896                .unwrap()
2897                .as_object()
2898                .unwrap()
2899                .get("_raw_output")
2900                .is_none(),
2901            "sidecar key must not leak to the LLM-visible result"
2902        );
2903        assert_eq!(tr.raw_output.as_deref(), Some("raw stdout bytes"));
2904    }
2905
2906    #[test]
2907    fn test_success_with_raw_output_scalar_unwraps_to_string() {
2908        let res = ToolExecutionResult::success_with_raw_output(
2909            "compact summary".to_string(),
2910            "full output bytes".to_string(),
2911        );
2912        let tr = res.into_tool_result("call_1", "demo");
2913        assert_eq!(
2914            tr.result,
2915            Some(serde_json::Value::String("compact summary".into()))
2916        );
2917        assert_eq!(tr.raw_output.as_deref(), Some("full output bytes"));
2918    }
2919
2920    #[test]
2921    fn test_success_result_with_raw_output_scalar_key_is_not_unwrapped() {
2922        let res = ToolExecutionResult::success(
2923            serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}),
2924        );
2925        let tr = res.into_tool_result("call_1", "demo");
2926        assert_eq!(
2927            tr.result,
2928            Some(serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}))
2929        );
2930        assert_eq!(tr.raw_output, None);
2931    }
2932
2933    #[test]
2934    fn test_success_result_with_only_raw_output_scalar_key_is_not_unwrapped() {
2935        // Single-key object with _raw_output_scalar must not be mistaken for a
2936        // success_with_raw_output carrier when raw_output is absent.
2937        let res = ToolExecutionResult::success(serde_json::json!({"_raw_output_scalar": "v"}));
2938        let tr = res.into_tool_result("call_1", "demo");
2939        assert_eq!(
2940            tr.result,
2941            Some(serde_json::json!({"_raw_output_scalar": "v"}))
2942        );
2943        assert_eq!(tr.raw_output, None);
2944    }
2945
2946    #[test]
2947    fn test_echo_tool_display_name() {
2948        let tool = EchoTool;
2949        assert_eq!(tool.display_name(), Some("Echo"));
2950
2951        let def = tool.to_definition();
2952        assert_eq!(def.display_name(), Some("Echo"));
2953    }
2954
2955    #[test]
2956    fn test_all_default_tools_have_display_names() {
2957        let registry = ToolRegistry::with_defaults();
2958        let definitions = registry.tool_definitions();
2959
2960        for def in &definitions {
2961            assert!(
2962                def.display_name().is_some(),
2963                "Tool '{}' should have a display_name",
2964                def.name()
2965            );
2966        }
2967    }
2968
2969    #[tokio::test]
2970    async fn test_tool_registry_as_executor() {
2971        let mut registry = ToolRegistry::new();
2972        registry.register(EchoTool);
2973
2974        let tool_call = ToolCall {
2975            id: "call_1".to_string(),
2976            name: "echo".to_string(),
2977            arguments: serde_json::json!({"message": "test"}),
2978        };
2979
2980        let tool_def = registry.get("echo").unwrap().to_definition();
2981        let result = registry.execute(&tool_call, &tool_def).await.unwrap();
2982
2983        assert!(result.error.is_none());
2984        assert_eq!(result.result.unwrap()["echoed"], "test");
2985    }
2986
2987    #[test]
2988    fn test_tool_to_definition() {
2989        let tool = GetCurrentTimeTool;
2990        let def = tool.to_definition();
2991
2992        let ToolDefinition::Builtin(builtin) = def else {
2993            panic!("expected Builtin variant");
2994        };
2995        assert_eq!(builtin.name, "get_current_time");
2996        assert_eq!(builtin.policy, ToolPolicy::Auto);
2997    }
2998
2999    #[test]
3000    fn test_with_defaults_has_expected_tools() {
3001        let registry = ToolRegistry::with_defaults();
3002
3003        // Core tools
3004        assert!(
3005            registry.has("get_current_time"),
3006            "should have get_current_time"
3007        );
3008        assert!(registry.has("echo"), "should have echo");
3009        // spawn_background is contributed by the background_execution
3010        // capability (auto-activated) — it must NOT be in defaults.
3011        assert!(
3012            !registry.has("spawn_background"),
3013            "spawn_background must NOT be in defaults — it comes from the \
3014             background_execution capability"
3015        );
3016        assert!(
3017            registry.has("report_progress"),
3018            "should have report_progress"
3019        );
3020
3021        // TestMath capability tools
3022        assert!(registry.has("add"), "should have add");
3023        assert!(registry.has("subtract"), "should have subtract");
3024        assert!(registry.has("multiply"), "should have multiply");
3025        assert!(registry.has("divide"), "should have divide");
3026
3027        // TestWeather capability tools
3028        assert!(registry.has("get_weather"), "should have get_weather");
3029        assert!(registry.has("get_forecast"), "should have get_forecast");
3030
3031        // TaskList capability tools
3032        assert!(registry.has("write_todos"), "should have write_todos");
3033
3034        // FileSystem capability tools
3035        assert!(registry.has("read_file"), "should have read_file");
3036        assert!(registry.has("write_file"), "should have write_file");
3037        assert!(registry.has("edit_file"), "should have edit_file");
3038        assert!(registry.has("list_directory"), "should have list_directory");
3039        assert!(registry.has("grep_files"), "should have grep_files");
3040        assert!(registry.has("delete_file"), "should have delete_file");
3041        assert!(registry.has("stat_file"), "should have stat_file");
3042
3043        // WebFetch capability tools
3044        assert!(registry.has("web_fetch"), "should have web_fetch");
3045
3046        // Total count: 19 - 1 (spawn_background, moved to capability) = 18
3047        assert_eq!(registry.len(), 18, "should have 18 default tools");
3048    }
3049
3050    #[tokio::test]
3051    async fn test_with_defaults_tools_are_executable() {
3052        let registry = ToolRegistry::with_defaults();
3053
3054        // Test echo tool execution
3055        let tool_call = ToolCall {
3056            id: "call_1".to_string(),
3057            name: "echo".to_string(),
3058            arguments: serde_json::json!({"message": "hello from defaults"}),
3059        };
3060
3061        let tool_def = registry.get("echo").unwrap().to_definition();
3062        let result = registry.execute(&tool_call, &tool_def).await.unwrap();
3063
3064        assert!(result.error.is_none());
3065        assert_eq!(result.result.unwrap()["echoed"], "hello from defaults");
3066    }
3067
3068    #[tokio::test]
3069    async fn test_with_defaults_math_tools() {
3070        let registry = ToolRegistry::with_defaults();
3071
3072        // Test add tool
3073        let tool_call = ToolCall {
3074            id: "call_add".to_string(),
3075            name: "add".to_string(),
3076            arguments: serde_json::json!({"a": 5, "b": 3}),
3077        };
3078
3079        let tool_def = registry.get("add").unwrap().to_definition();
3080        let result = registry.execute(&tool_call, &tool_def).await.unwrap();
3081
3082        assert!(result.error.is_none());
3083        // AddTool returns floats, so compare as f64
3084        assert_eq!(result.result.unwrap()["result"].as_f64().unwrap(), 8.0);
3085    }
3086
3087    /// Regression: with_defaults() must NOT include capability-provided tools like
3088    /// 'bash'. These tools come from capabilities and must be registered separately.
3089    /// If bash were in defaults, the harness capability fallback would be masked.
3090    #[test]
3091    fn test_with_defaults_excludes_capability_only_tools() {
3092        let registry = ToolRegistry::with_defaults();
3093
3094        // bash comes from bashkit_shell capability, not defaults
3095        assert!(
3096            !registry.has("bash"),
3097            "bash must not be in defaults — it comes from bashkit_shell capability"
3098        );
3099        // kv_store/secret_store come from session_storage capability
3100        assert!(
3101            !registry.has("kv_store"),
3102            "kv_store must not be in defaults — it comes from session_storage capability"
3103        );
3104        // spawn_background comes from background_execution capability and is
3105        // auto-activated by `collect_capabilities_with_configs` when a
3106        // background-capable tool is present (see EVE-501).
3107        assert!(
3108            !registry.has("spawn_background"),
3109            "spawn_background must not be in defaults — it comes from the \
3110             background_execution capability (auto-activated by tool hints)"
3111        );
3112    }
3113
3114    #[tokio::test]
3115    async fn test_spawn_background_executes_and_signals_session() {
3116        let session_id = SessionId::new();
3117        let file_store = Arc::new(TestFileStore::default());
3118        let platform_store = Arc::new(TestPlatformStore::default());
3119        let storage_store = Arc::new(NoopStorageStore);
3120        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3121        let tool_registry = ToolRegistry::builder()
3122            .tool(SpawnBackgroundTool)
3123            .tool(TestBackgroundTool)
3124            .build();
3125
3126        let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3127            .with_tool_registry(Arc::new(tool_registry))
3128            .with_platform_store(platform_store.clone())
3129            .with_session_task_registry(task_registry.clone());
3130
3131        let tool = SpawnBackgroundTool;
3132        let result = tool
3133            .execute_with_context(
3134                json!({
3135                    "tool": "test_background",
3136                    "args": { "summary": "Background complete" }
3137                }),
3138                &context,
3139            )
3140            .await;
3141
3142        let ToolExecutionResult::Success(value) = result else {
3143            panic!("spawn_background should succeed");
3144        };
3145        let run_id = value["run_id"].as_str().unwrap().to_string();
3146        let task_id = value["task_id"].as_str().unwrap().to_string();
3147
3148        tokio::time::timeout(std::time::Duration::from_secs(2), async {
3149            loop {
3150                if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3151                    && task.state == crate::session_task::SessionTaskState::Succeeded
3152                {
3153                    break task;
3154                }
3155                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3156            }
3157        })
3158        .await
3159        .expect("background run should complete");
3160        let _ = run_id; // still available in result json
3161
3162        let messages = platform_store.sent_messages.lock().unwrap().clone();
3163        assert_eq!(messages.len(), 1);
3164        assert!(messages[0].contains("Background run completed"));
3165
3166        let log_file = file_store
3167            .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3168            .await
3169            .unwrap()
3170            .expect("log file");
3171        assert!(
3172            log_file
3173                .content
3174                .as_deref()
3175                .unwrap_or_default()
3176                .contains("hello from background")
3177        );
3178    }
3179
3180    #[tokio::test]
3181    async fn test_spawn_background_persists_failure_artifacts() {
3182        let session_id = SessionId::new();
3183        let file_store = Arc::new(TestFileStore::default());
3184        let storage_store = Arc::new(NoopStorageStore);
3185        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3186        let tool_registry = ToolRegistry::builder()
3187            .tool(SpawnBackgroundTool)
3188            .tool(TestFailingBackgroundTool)
3189            .build();
3190
3191        let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3192            .with_tool_registry(Arc::new(tool_registry))
3193            .with_session_task_registry(task_registry.clone());
3194
3195        let result = SpawnBackgroundTool
3196            .execute_with_context(
3197                json!({
3198                    "tool": "test_background_fail",
3199                    "args": {}
3200                }),
3201                &context,
3202            )
3203            .await;
3204
3205        let ToolExecutionResult::Success(value) = result else {
3206            panic!("spawn_background should succeed");
3207        };
3208        let run_id = value["run_id"].as_str().unwrap().to_string();
3209        let task_id = value["task_id"].as_str().unwrap().to_string();
3210
3211        tokio::time::timeout(std::time::Duration::from_secs(2), async {
3212            loop {
3213                if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3214                    && task.state == crate::session_task::SessionTaskState::Failed
3215                {
3216                    break task;
3217                }
3218                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3219            }
3220        })
3221        .await
3222        .expect("background run should fail");
3223        let _ = run_id;
3224
3225        let log_file = file_store
3226            .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3227            .await
3228            .unwrap()
3229            .expect("log file");
3230        assert!(
3231            log_file
3232                .content
3233                .as_deref()
3234                .unwrap_or_default()
3235                .contains("background failed")
3236        );
3237
3238        let result_file = file_store
3239            .read_file(session_id, &format!("/.background/{run_id}/result.json"))
3240            .await
3241            .unwrap()
3242            .expect("result file");
3243        let result_json: Value =
3244            serde_json::from_str(result_file.content.as_deref().unwrap_or_default())
3245                .expect("valid json");
3246        assert_eq!(result_json["status"], "failed");
3247        assert_eq!(result_json["error"], "boom");
3248    }
3249
3250    #[tokio::test]
3251    async fn test_spawn_background_rejects_when_session_active_run_limit_reached() {
3252        let session_id = SessionId::new();
3253        let file_store = Arc::new(TestFileStore::default());
3254        let storage_store = Arc::new(NoopStorageStore);
3255        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3256        let release = StdArc::new(AtomicBool::new(false));
3257        let tool_registry = ToolRegistry::builder()
3258            .tool(SpawnBackgroundTool)
3259            .tool(BlockingBackgroundTool {
3260                release: release.clone(),
3261            })
3262            .build();
3263
3264        let context = ToolContext::with_stores(session_id, file_store, storage_store)
3265            .with_tool_registry(Arc::new(tool_registry))
3266            .with_session_task_registry(task_registry.clone());
3267
3268        let mut task_ids = Vec::new();
3269        for _ in 0..MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3270            let result = SpawnBackgroundTool
3271                .execute_with_context(
3272                    json!({
3273                        "tool": "test_background_blocking",
3274                        "args": {}
3275                    }),
3276                    &context,
3277                )
3278                .await;
3279
3280            let ToolExecutionResult::Success(value) = result else {
3281                panic!("background run below the session limit should start");
3282            };
3283            task_ids.push(value["task_id"].as_str().unwrap().to_string());
3284        }
3285
3286        // Wait for all tasks to be running (semaphore acquired).
3287        tokio::time::timeout(std::time::Duration::from_secs(2), async {
3288            loop {
3289                let running = task_registry
3290                    .list(
3291                        session_id,
3292                        Some(&crate::session_task::SessionTaskFilter {
3293                            kind: Some(crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string()),
3294                            state: Some(crate::session_task::SessionTaskState::Running),
3295                        }),
3296                    )
3297                    .await
3298                    .unwrap();
3299                if running.len() == MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3300                    break;
3301                }
3302                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3303            }
3304        })
3305        .await
3306        .expect("background runs should become running");
3307
3308        let result = SpawnBackgroundTool
3309            .execute_with_context(
3310                json!({
3311                    "tool": "test_background_blocking",
3312                    "args": {}
3313                }),
3314                &context,
3315            )
3316            .await;
3317
3318        let ToolExecutionResult::ToolError(message) = result else {
3319            release.store(true, Ordering::SeqCst);
3320            panic!("spawn_background should reject once the session limit is reached");
3321        };
3322        assert!(message.contains("active background runs per session"));
3323
3324        release.store(true, Ordering::SeqCst);
3325        tokio::time::timeout(std::time::Duration::from_secs(2), async {
3326            for task_id in task_ids {
3327                loop {
3328                    if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3329                        && task.state == crate::session_task::SessionTaskState::Succeeded
3330                    {
3331                        break;
3332                    }
3333                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3334                }
3335            }
3336        })
3337        .await
3338        .expect("blocking background runs should complete after release");
3339
3340        // The permit drop is enqueued in the spawned task after it marks the
3341        // resource Completed, so the cache entry may still exist briefly once
3342        // we observe Completed status.  Poll until pruned rather than asserting
3343        // immediately to avoid a race.
3344        tokio::time::timeout(std::time::Duration::from_secs(1), async {
3345            loop {
3346                if !has_session_background_permits(session_id) {
3347                    break;
3348                }
3349                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3350            }
3351        })
3352        .await
3353        .expect("completed background runs should prune their per-session permit cache entry");
3354    }
3355
3356    #[tokio::test]
3357    async fn test_spawn_background_requires_task_registry() {
3358        let session_id = SessionId::new();
3359        let file_store = Arc::new(TestFileStore::default());
3360        let storage_store = Arc::new(NoopStorageStore);
3361        let tool_registry = ToolRegistry::builder()
3362            .tool(SpawnBackgroundTool)
3363            .tool(TestBackgroundTool)
3364            .build();
3365
3366        // No task_registry wired — should fail.
3367        let context = ToolContext::with_stores(session_id, file_store, storage_store)
3368            .with_tool_registry(Arc::new(tool_registry));
3369
3370        let result = SpawnBackgroundTool
3371            .execute_with_context(
3372                json!({
3373                    "tool": "test_background",
3374                    "args": {}
3375                }),
3376                &context,
3377            )
3378            .await;
3379
3380        let ToolExecutionResult::ToolError(message) = result else {
3381            panic!("spawn_background should reject missing task registry");
3382        };
3383        assert!(message.contains("Session task registry not available"));
3384    }
3385
3386    #[tokio::test]
3387    async fn test_spawn_background_requires_file_store() {
3388        let session_id = SessionId::new();
3389        let storage_store = Arc::new(NoopStorageStore);
3390        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3391        let tool_registry = ToolRegistry::builder()
3392            .tool(SpawnBackgroundTool)
3393            .tool(TestBackgroundTool)
3394            .build();
3395
3396        // No file_store wired — should fail.
3397        let context = ToolContext::with_storage_store(session_id, storage_store)
3398            .with_tool_registry(Arc::new(tool_registry))
3399            .with_session_task_registry(task_registry);
3400
3401        let result = SpawnBackgroundTool
3402            .execute_with_context(
3403                json!({
3404                    "tool": "test_background",
3405                    "args": {}
3406                }),
3407                &context,
3408            )
3409            .await;
3410
3411        let ToolExecutionResult::ToolError(message) = result else {
3412            panic!("spawn_background should reject missing file store");
3413        };
3414        assert!(message.contains("Session file store not available"));
3415    }
3416
3417    #[tokio::test]
3418    async fn test_spawn_background_caps_output_log_size() {
3419        let session_id = SessionId::new();
3420        let file_store = Arc::new(TestFileStore::default());
3421        let storage_store = Arc::new(NoopStorageStore);
3422        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3423        let tool_registry = ToolRegistry::builder()
3424            .tool(SpawnBackgroundTool)
3425            .tool(TestLargeOutputBackgroundTool)
3426            .build();
3427
3428        let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3429            .with_tool_registry(Arc::new(tool_registry))
3430            .with_session_task_registry(task_registry.clone());
3431
3432        let result = SpawnBackgroundTool
3433            .execute_with_context(
3434                json!({
3435                    "tool": "test_background_large_output",
3436                    "args": {}
3437                }),
3438                &context,
3439            )
3440            .await;
3441
3442        let ToolExecutionResult::Success(value) = result else {
3443            panic!("spawn_background should succeed");
3444        };
3445        let run_id = value["run_id"].as_str().unwrap().to_string();
3446        let task_id = value["task_id"].as_str().unwrap().to_string();
3447
3448        tokio::time::timeout(std::time::Duration::from_secs(2), async {
3449            loop {
3450                if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3451                    && task.state == crate::session_task::SessionTaskState::Succeeded
3452                {
3453                    break;
3454                }
3455                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3456            }
3457        })
3458        .await
3459        .expect("background run should complete");
3460        let _ = run_id;
3461
3462        let log_content = file_store
3463            .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3464            .await
3465            .unwrap()
3466            .expect("log file")
3467            .content
3468            .unwrap_or_default();
3469
3470        assert!(log_content.contains("[system] background output truncated"));
3471        assert!(log_content.chars().count() <= MAX_BACKGROUND_OUTPUT_LOG_CHARS + 128);
3472    }
3473
3474    #[tokio::test]
3475    async fn test_spawn_background_can_create_scheduled_monitor() {
3476        let session_id = SessionId::new();
3477        let schedule_store = Arc::new(TestScheduleStore::default());
3478        let storage_store = Arc::new(NoopStorageStore);
3479        let tool_registry = ToolRegistry::builder()
3480            .tool(SpawnBackgroundTool)
3481            .tool(TestBackgroundTool)
3482            .build();
3483
3484        let context = ToolContext::with_storage_store(session_id, storage_store)
3485            .with_tool_registry(Arc::new(tool_registry))
3486            .with_schedule_store(schedule_store.clone());
3487
3488        let result = SpawnBackgroundTool
3489            .execute_with_context(
3490                json!({
3491                    "tool": "test_background",
3492                    "title": "Watch PR 1319",
3493                    "args": { "summary": "Background complete" },
3494                    "schedule": {
3495                        "cron_expression": "*/10 * * * *",
3496                        "timezone": "America/Chicago"
3497                    }
3498                }),
3499                &context,
3500            )
3501            .await;
3502
3503        let ToolExecutionResult::Success(value) = result else {
3504            panic!("spawn_background should create a schedule: {result:?}");
3505        };
3506
3507        assert_eq!(value["status"], "scheduled");
3508        assert_eq!(value["title"], "Watch PR 1319");
3509        assert_eq!(value["cron_expression"], "*/10 * * * *");
3510        assert_eq!(value["timezone"], "America/Chicago");
3511
3512        let schedules = schedule_store.list_schedules(session_id).await.unwrap();
3513        assert_eq!(schedules.len(), 1);
3514        assert_eq!(
3515            schedules[0].cron_expression.as_deref(),
3516            Some("*/10 * * * *")
3517        );
3518        assert!(schedules[0].description.contains("Monitor: Watch PR 1319"));
3519        assert!(
3520            schedules[0]
3521                .description
3522                .contains("\"summary\": \"Background complete\"")
3523        );
3524    }
3525
3526    #[tokio::test]
3527    async fn test_spawn_background_rejects_invalid_scheduled_at() {
3528        let session_id = SessionId::new();
3529        let storage_store = Arc::new(NoopStorageStore);
3530        let tool_registry = ToolRegistry::builder()
3531            .tool(SpawnBackgroundTool)
3532            .tool(TestBackgroundTool)
3533            .build();
3534        let context = ToolContext::with_storage_store(session_id, storage_store)
3535            .with_tool_registry(Arc::new(tool_registry));
3536
3537        let result = SpawnBackgroundTool
3538            .execute_with_context(
3539                json!({
3540                    "tool": "test_background",
3541                    "args": {},
3542                    "schedule": {
3543                        "scheduled_at": "tomorrow at noon"
3544                    }
3545                }),
3546                &context,
3547            )
3548            .await;
3549
3550        let ToolExecutionResult::ToolError(message) = result else {
3551            panic!("spawn_background should reject invalid scheduled_at");
3552        };
3553        assert!(message.contains("scheduled_at must be RFC3339"));
3554    }
3555
3556    #[tokio::test]
3557    async fn test_spawn_background_rejects_ambiguous_schedule_shape() {
3558        let session_id = SessionId::new();
3559        let storage_store = Arc::new(NoopStorageStore);
3560        let tool_registry = ToolRegistry::builder()
3561            .tool(SpawnBackgroundTool)
3562            .tool(TestBackgroundTool)
3563            .build();
3564        let context = ToolContext::with_storage_store(session_id, storage_store)
3565            .with_tool_registry(Arc::new(tool_registry));
3566
3567        let result = SpawnBackgroundTool
3568            .execute_with_context(
3569                json!({
3570                    "tool": "test_background",
3571                    "args": {},
3572                    "schedule": {
3573                        "cron_expression": "*/10 * * * *",
3574                        "scheduled_at": "2026-04-16T15:30:00Z"
3575                    }
3576                }),
3577                &context,
3578            )
3579            .await;
3580
3581        let ToolExecutionResult::ToolError(message) = result else {
3582            panic!("spawn_background should reject ambiguous schedule shape");
3583        };
3584        assert!(message.contains("must not include both cron_expression and scheduled_at"));
3585    }
3586
3587    // =========================================================================
3588    // Cooperative cancellation tests
3589    // =========================================================================
3590
3591    #[test]
3592    fn test_is_canceled_outcome_detects_sentinel() {
3593        // The sentinel produced by the cancel-watcher branch.
3594        let sentinel: std::result::Result<BackgroundOutcome, ToolExecutionResult> = Err(
3595            ToolExecutionResult::ToolError(BACKGROUND_CANCEL_SENTINEL.to_string()),
3596        );
3597        assert!(is_canceled_outcome(&sentinel));
3598    }
3599
3600    #[test]
3601    fn test_is_canceled_outcome_does_not_match_other_errors() {
3602        let other_err: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
3603            Err(ToolExecutionResult::ToolError("boom".to_string()));
3604        assert!(!is_canceled_outcome(&other_err));
3605
3606        let success: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
3607            Ok(BackgroundOutcome {
3608                summary: "ok".to_string(),
3609                result: json!({"ok": true}),
3610                raw_output: None,
3611            });
3612        assert!(!is_canceled_outcome(&success));
3613    }
3614
3615    /// A background tool that sleeps indefinitely, allowing the test to exercise
3616    /// cancel via the cancel-watcher without actually waiting forever.
3617    #[derive(Default)]
3618    struct SleepingBackgroundTool;
3619
3620    #[async_trait]
3621    impl BackgroundExecutableTool for SleepingBackgroundTool {
3622        async fn execute_background(
3623            &self,
3624            _arguments: Value,
3625            _context: ToolContext,
3626            sink: Arc<dyn BackgroundEventSink>,
3627        ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
3628            sink.status("Sleeping forever")
3629                .await
3630                .map_err(ToolExecutionResult::internal_error)?;
3631            // Sleep for a very long time; the cancel-watcher will win the select.
3632            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
3633            Ok(BackgroundOutcome {
3634                summary: "should not reach here".to_string(),
3635                result: json!({}),
3636                raw_output: None,
3637            })
3638        }
3639    }
3640
3641    #[async_trait]
3642    impl Tool for SleepingBackgroundTool {
3643        fn name(&self) -> &str {
3644            "test_background_sleeping"
3645        }
3646
3647        fn display_name(&self) -> Option<&str> {
3648            Some("Test Background Sleeping")
3649        }
3650
3651        fn description(&self) -> &str {
3652            "background test tool that sleeps indefinitely"
3653        }
3654
3655        fn parameters_schema(&self) -> Value {
3656            json!({
3657                "type": "object",
3658                "properties": {}
3659            })
3660        }
3661
3662        async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
3663            ToolExecutionResult::tool_error("foreground unsupported")
3664        }
3665
3666        fn hints(&self) -> ToolHints {
3667            ToolHints::default().with_supports_background(true)
3668        }
3669
3670        fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
3671            Some(self)
3672        }
3673    }
3674
3675    // Minimal in-memory SessionTaskRegistry for cancel tests.
3676    // (Mirrors the double in capabilities/session_tasks.rs — kept local because
3677    //  that module is private.)
3678    #[derive(Default)]
3679    struct InMemoryTaskRegistry {
3680        tasks: Mutex<HashMap<String, crate::session_task::SessionTask>>,
3681    }
3682
3683    #[async_trait]
3684    impl crate::session_task::SessionTaskRegistry for InMemoryTaskRegistry {
3685        async fn create(
3686            &self,
3687            input: crate::session_task::CreateSessionTask,
3688        ) -> crate::Result<crate::session_task::SessionTask> {
3689            let mut tasks = self.tasks.lock().unwrap();
3690            if let Some(id) = &input.id
3691                && let Some(existing) = tasks.get(id)
3692            {
3693                return Ok(existing.clone());
3694            }
3695            let task = crate::session_task::new_session_task(input, chrono::Utc::now());
3696            tasks.insert(task.id.clone(), task.clone());
3697            Ok(task)
3698        }
3699
3700        async fn update(
3701            &self,
3702            _session_id: SessionId,
3703            task_id: &str,
3704            update: crate::session_task::SessionTaskUpdate,
3705        ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3706            let mut tasks = self.tasks.lock().unwrap();
3707            let Some(task) = tasks.get_mut(task_id) else {
3708                return Ok(None);
3709            };
3710            crate::session_task::apply_task_update(task, update, chrono::Utc::now());
3711            Ok(Some(task.clone()))
3712        }
3713
3714        async fn get(
3715            &self,
3716            _session_id: SessionId,
3717            task_id: &str,
3718        ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3719            Ok(self.tasks.lock().unwrap().get(task_id).cloned())
3720        }
3721
3722        async fn list(
3723            &self,
3724            _session_id: SessionId,
3725            filter: Option<&crate::session_task::SessionTaskFilter>,
3726        ) -> crate::Result<Vec<crate::session_task::SessionTask>> {
3727            let tasks = self.tasks.lock().unwrap();
3728            Ok(tasks
3729                .values()
3730                .filter(|task| {
3731                    filter.is_none_or(|f| {
3732                        f.kind.as_deref().is_none_or(|kind| task.kind == kind)
3733                            && f.state.is_none_or(|state| task.state == state)
3734                    })
3735                })
3736                .cloned()
3737                .collect())
3738        }
3739
3740        async fn request_cancel(
3741            &self,
3742            _session_id: SessionId,
3743            task_id: &str,
3744        ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3745            let mut tasks = self.tasks.lock().unwrap();
3746            let Some(task) = tasks.get_mut(task_id) else {
3747                return Ok(None);
3748            };
3749            task.cancel_requested_at
3750                .get_or_insert_with(chrono::Utc::now);
3751            task.updated_at = chrono::Utc::now();
3752            Ok(Some(task.clone()))
3753        }
3754
3755        async fn record_message(
3756            &self,
3757            _session_id: SessionId,
3758            task_id: &str,
3759            message: crate::session_task::NewTaskMessage,
3760        ) -> crate::Result<crate::session_task::TaskMessage> {
3761            let tasks = self.tasks.lock().unwrap();
3762            let _task = tasks
3763                .get(task_id)
3764                .ok_or_else(|| crate::AgentLoopError::tool(format!("no task {task_id}")))?;
3765            Ok(crate::session_task::TaskMessage {
3766                id: crate::session_task::generate_task_message_id(),
3767                task_id: task_id.to_string(),
3768                direction: message.direction,
3769                content: message.content,
3770                in_reply_to: message.in_reply_to,
3771                created_at: chrono::Utc::now(),
3772            })
3773        }
3774
3775        async fn list_messages(
3776            &self,
3777            _session_id: SessionId,
3778            _task_id: &str,
3779            _limit: Option<u32>,
3780            _after_id: Option<&str>,
3781        ) -> crate::Result<Vec<crate::session_task::TaskMessage>> {
3782            Ok(Vec::new())
3783        }
3784    }
3785
3786    /// End-to-end cancel test: spawn a long-sleeping background tool, then call
3787    /// `request_cancel` on the task registry, and assert the task ends Canceled.
3788    #[tokio::test]
3789    async fn test_cancel_background_run_via_task_registry() {
3790        let session_id = SessionId::new();
3791        let file_store = Arc::new(TestFileStore::default());
3792        let storage_store = Arc::new(NoopStorageStore);
3793        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3794
3795        let tool_registry = ToolRegistry::builder()
3796            .tool(SpawnBackgroundTool)
3797            .tool(SleepingBackgroundTool)
3798            .build();
3799
3800        let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3801            .with_tool_registry(Arc::new(tool_registry))
3802            .with_session_task_registry(task_registry.clone());
3803
3804        let result = SpawnBackgroundTool
3805            .execute_with_context(
3806                json!({
3807                    "tool": "test_background_sleeping",
3808                    "args": {},
3809                    "signal_on_completion": false
3810                }),
3811                &context,
3812            )
3813            .await;
3814
3815        let ToolExecutionResult::Success(value) = result else {
3816            panic!("spawn_background should succeed");
3817        };
3818        let run_id = value["run_id"].as_str().unwrap().to_string();
3819        let task_id = value["task_id"].as_str().unwrap().to_string();
3820
3821        // Wait until the background task is Running (heartbeat loop started).
3822        tokio::time::timeout(std::time::Duration::from_secs(5), async {
3823            loop {
3824                // Wait for a heartbeat to confirm the watcher is live.
3825                if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3826                    && task.heartbeat_at.is_some()
3827                {
3828                    break;
3829                }
3830                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3831            }
3832        })
3833        .await
3834        .expect("background run should start and send at least one heartbeat");
3835
3836        // Request cancel.
3837        task_registry
3838            .request_cancel(session_id, &task_id)
3839            .await
3840            .expect("request_cancel should succeed");
3841
3842        // Wait for the task to reach Canceled state.
3843        tokio::time::timeout(std::time::Duration::from_secs(10), async {
3844            loop {
3845                if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3846                    && task.state == crate::session_task::SessionTaskState::Canceled
3847                {
3848                    break task;
3849                }
3850                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3851            }
3852        })
3853        .await
3854        .expect("background task should reach Canceled state");
3855
3856        // Verify result.json and output.log were written.
3857        let result_file = file_store
3858            .read_file(session_id, &format!("/.background/{run_id}/result.json"))
3859            .await
3860            .unwrap()
3861            .expect("result.json should exist");
3862        let result_json: Value =
3863            serde_json::from_str(result_file.content.as_deref().unwrap_or_default())
3864                .expect("valid json");
3865        assert_eq!(result_json["status"], "canceled");
3866
3867        let log_file = file_store
3868            .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3869            .await
3870            .unwrap()
3871            .expect("output.log should exist");
3872        assert!(
3873            log_file
3874                .content
3875                .as_deref()
3876                .unwrap_or_default()
3877                .contains("Canceled by request.")
3878        );
3879    }
3880
3881    // -------------------------------------------------------------------------
3882    // reattach_background_run early-guard tests
3883    // -------------------------------------------------------------------------
3884
3885    fn make_reattach_task(spec: serde_json::Value) -> crate::session_task::SessionTask {
3886        use crate::session_task::{SessionTaskState, TaskLinks, TaskWakePolicy};
3887        crate::session_task::SessionTask {
3888            id: "t-reattach".to_string(),
3889            session_id: SessionId::new(),
3890            kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
3891            display_name: "Reattach test".to_string(),
3892            spec,
3893            state: SessionTaskState::Running,
3894            state_detail: None,
3895            progress: None,
3896            input_request: None,
3897            cancel_requested_at: None,
3898            summary: None,
3899            result_path: None,
3900            artifacts: vec![],
3901            error: None,
3902            attempt: 2,
3903            worker_id: None,
3904            heartbeat_at: None,
3905            links: TaskLinks::default(),
3906            wake_policy: TaskWakePolicy::Silent,
3907            created_at: chrono::Utc::now(),
3908            started_at: None,
3909            finished_at: None,
3910            updated_at: chrono::Utc::now(),
3911        }
3912    }
3913
3914    #[tokio::test]
3915    async fn reattach_fails_with_missing_file_store() {
3916        let session_id = SessionId::new();
3917        // Context with no file_store — only session_task_registry is wired.
3918        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3919        let context =
3920            crate::traits::ToolContext::new(session_id).with_session_task_registry(task_registry);
3921        let task = make_reattach_task(serde_json::json!({
3922            "tool": "get_current_time",
3923            "arguments": {},
3924            "reattachable": true,
3925            "signal_on_completion": true,
3926        }));
3927        let err = reattach_background_run(&task, &context)
3928            .await
3929            .expect_err("should fail without file store");
3930        assert!(
3931            err.to_string().contains("file store"),
3932            "error should mention file store, got: {err}"
3933        );
3934    }
3935
3936    #[tokio::test]
3937    async fn reattach_fails_with_missing_task_registry() {
3938        let session_id = SessionId::new();
3939        let file_store = Arc::new(TestFileStore::default());
3940        let storage_store = Arc::new(NoopStorageStore);
3941        // Context has a file_store but no session_task_registry.
3942        let context =
3943            crate::traits::ToolContext::with_stores(session_id, file_store, storage_store);
3944        let task = make_reattach_task(serde_json::json!({
3945            "tool": "get_current_time",
3946            "arguments": {},
3947            "reattachable": true,
3948            "signal_on_completion": true,
3949        }));
3950        let err = reattach_background_run(&task, &context)
3951            .await
3952            .expect_err("should fail without task registry");
3953        assert!(
3954            err.to_string().contains("task registry"),
3955            "error should mention task registry, got: {err}"
3956        );
3957    }
3958
3959    #[tokio::test]
3960    async fn reattach_fails_with_unknown_tool_name() {
3961        let session_id = SessionId::new();
3962        let file_store = Arc::new(TestFileStore::default());
3963        let storage_store = Arc::new(NoopStorageStore);
3964        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3965        let context =
3966            crate::traits::ToolContext::with_stores(session_id, file_store, storage_store)
3967                .with_session_task_registry(task_registry);
3968        // "test_background" is not in ToolRegistry::with_defaults().
3969        let task = make_reattach_task(serde_json::json!({
3970            "tool": "test_background",
3971            "arguments": {},
3972            "reattachable": true,
3973            "signal_on_completion": true,
3974        }));
3975        let err = reattach_background_run(&task, &context)
3976            .await
3977            .expect_err("should fail for unknown tool");
3978        assert!(
3979            err.to_string().contains("not found in built-in registry"),
3980            "error should mention built-in registry, got: {err}"
3981        );
3982    }
3983
3984    #[tokio::test]
3985    async fn reattach_fails_with_missing_tool_spec_field() {
3986        let session_id = SessionId::new();
3987        let file_store = Arc::new(TestFileStore::default());
3988        let storage_store = Arc::new(NoopStorageStore);
3989        let task_registry = Arc::new(InMemoryTaskRegistry::default());
3990        let context =
3991            crate::traits::ToolContext::with_stores(session_id, file_store, storage_store)
3992                .with_session_task_registry(task_registry);
3993        // Spec has no "tool" field.
3994        let task = make_reattach_task(serde_json::json!({ "reattachable": true }));
3995        let err = reattach_background_run(&task, &context)
3996            .await
3997            .expect_err("should fail with missing tool field");
3998        assert!(
3999            err.to_string().contains("missing 'tool' field"),
4000            "error should mention missing tool field, got: {err}"
4001        );
4002    }
4003}