Skip to main content

everruns_core/capabilities/
background_execution.rs

1//! BackgroundExecution Capability — exposes `spawn_background` to the model
2//! whenever the active tool set contains at least one built-in tool that opts
3//! into detached execution via `ToolHints::supports_background`.
4//!
5//! This capability is auto-activated by
6//! [`super::collect_capabilities_with_configs`] after the explicit-capability
7//! collection loop. It can also be selected explicitly by id
8//! (`"background_execution"`) — the auto-activator skips it in that case.
9//!
10//! See `specs/background-execution.md` for the cross-cutting / meta-tool
11//! capability contract this implements.
12
13use super::{Capability, CapabilityLocalization, CapabilityStatus};
14use crate::tools::{SpawnBackgroundTool, Tool};
15
16/// Capability id used by the auto-activator in
17/// `collect_capabilities_with_configs`.
18pub const BACKGROUND_EXECUTION_CAPABILITY_ID: &str = "background_execution";
19
20/// Cross-cutting capability that contributes `spawn_background`.
21///
22/// Auto-activation rule: any collected tool with
23/// `hints.supports_background == Some(true)` causes this capability to be
24/// added to the agent's tool set, exposing `spawn_background` to the model
25/// and registering it in the worker tool registry.
26pub struct BackgroundExecutionCapability;
27
28impl Capability for BackgroundExecutionCapability {
29    fn id(&self) -> &str {
30        BACKGROUND_EXECUTION_CAPABILITY_ID
31    }
32
33    fn name(&self) -> &str {
34        "Background Execution"
35    }
36
37    fn description(&self) -> &str {
38        "Run any background-capable built-in tool asynchronously via \
39         `spawn_background`. Auto-activated whenever the agent has a tool \
40         that declares `supports_background=true`."
41    }
42
43    fn localizations(&self) -> Vec<CapabilityLocalization> {
44        vec![CapabilityLocalization::text(
45            "uk",
46            "Фонове виконання",
47            "Запускайте будь-який вбудований інструмент із підтримкою фонового режиму асинхронно через `spawn_background`. Активується автоматично, щойно агент має інструмент, який оголошує `supports_background=true`.",
48        )]
49    }
50
51    fn status(&self) -> CapabilityStatus {
52        CapabilityStatus::Available
53    }
54
55    fn icon(&self) -> Option<&str> {
56        Some("zap")
57    }
58
59    fn category(&self) -> Option<&str> {
60        Some("Execution")
61    }
62
63    fn tools(&self) -> Vec<Box<dyn Tool>> {
64        vec![Box::new(SpawnBackgroundTool)]
65    }
66}
67
68/// Task executor for `background_tool` tasks.
69pub struct BackgroundToolTaskExecutor;
70
71#[async_trait::async_trait]
72impl crate::session_task::TaskExecutor for BackgroundToolTaskExecutor {
73    fn kind(&self) -> &str {
74        crate::session_task::TASK_KIND_BACKGROUND_TOOL
75    }
76
77    /// Re-attach when `spec["reattachable"]` is true (set at spawn time when
78    /// the tool declared `idempotent` or `readonly` hints). Tools without this
79    /// flag are still failed as orphaned so side-effecting runs are not doubled.
80    fn can_reattach_task(&self, task: &crate::session_task::SessionTask) -> bool {
81        task.spec
82            .get("reattachable")
83            .and_then(|v| v.as_bool())
84            .unwrap_or(false)
85    }
86
87    /// Re-execute the tool from spec after worker loss.
88    async fn start(
89        &self,
90        task: &crate::session_task::SessionTask,
91        context: &crate::traits::ToolContext,
92    ) -> crate::error::Result<()> {
93        crate::tools::reattach_background_run(task, context).await
94    }
95
96    /// Cooperative cancellation: `cancel_task` records `cancel_requested_at`
97    /// in the registry; the background run's heartbeat loop polls it every ~2 s
98    /// and winds down when set.  No in-process token is needed — the record-
99    /// polling design works even when this call executes on a different worker.
100    async fn cancel(
101        &self,
102        _task: &crate::session_task::SessionTask,
103        _context: &crate::traits::ToolContext,
104    ) -> crate::error::Result<()> {
105        Ok(())
106    }
107}
108
109inventory::submit! {
110    crate::session_task::TaskExecutorPlugin {
111        executor: || std::sync::Arc::new(BackgroundToolTaskExecutor),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    // Metadata/tool-list/registration constants covered by builtin_capabilities_satisfy_registry_invariants.
120
121    #[test]
122    fn can_reattach_task_true_when_spec_reattachable_true() {
123        use crate::session_task::{SessionTask, SessionTaskState, TaskExecutor, TaskWakePolicy};
124        use chrono::Utc;
125
126        let exec = BackgroundToolTaskExecutor;
127        let task = SessionTask {
128            id: "t1".to_string(),
129            session_id: crate::SessionId::new(),
130            root_session_id: None,
131            kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
132            display_name: "test".to_string(),
133            spec: serde_json::json!({ "tool": "get_current_time", "reattachable": true }),
134            state: SessionTaskState::Running,
135            state_detail: None,
136            progress: None,
137            input_request: None,
138            cancel_requested_at: None,
139            summary: None,
140            result_path: None,
141            artifacts: vec![],
142            error: None,
143            attempt: 1,
144            worker_id: None,
145            heartbeat_at: None,
146            links: crate::session_task::TaskLinks::default(),
147            wake_policy: TaskWakePolicy::Silent,
148            created_at: Utc::now(),
149            started_at: None,
150            finished_at: None,
151            updated_at: Utc::now(),
152        };
153        assert!(exec.can_reattach_task(&task));
154    }
155
156    #[test]
157    fn can_reattach_task_false_when_spec_reattachable_false() {
158        use crate::session_task::{SessionTask, SessionTaskState, TaskExecutor, TaskWakePolicy};
159        use chrono::Utc;
160
161        let exec = BackgroundToolTaskExecutor;
162        let task = SessionTask {
163            id: "t2".to_string(),
164            session_id: crate::SessionId::new(),
165            root_session_id: None,
166            kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
167            display_name: "test".to_string(),
168            spec: serde_json::json!({ "tool": "some_side_effecting_tool", "reattachable": false }),
169            state: SessionTaskState::Running,
170            state_detail: None,
171            progress: None,
172            input_request: None,
173            cancel_requested_at: None,
174            summary: None,
175            result_path: None,
176            artifacts: vec![],
177            error: None,
178            attempt: 1,
179            worker_id: None,
180            heartbeat_at: None,
181            links: crate::session_task::TaskLinks::default(),
182            wake_policy: TaskWakePolicy::Silent,
183            created_at: Utc::now(),
184            started_at: None,
185            finished_at: None,
186            updated_at: Utc::now(),
187        };
188        assert!(!exec.can_reattach_task(&task));
189    }
190
191    #[test]
192    fn can_reattach_task_false_when_spec_has_no_reattachable_field() {
193        use crate::session_task::{SessionTask, SessionTaskState, TaskExecutor, TaskWakePolicy};
194        use chrono::Utc;
195
196        let exec = BackgroundToolTaskExecutor;
197        let task = SessionTask {
198            id: "t3".to_string(),
199            session_id: crate::SessionId::new(),
200            root_session_id: None,
201            kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
202            display_name: "test".to_string(),
203            spec: serde_json::json!({ "tool": "old_task_without_reattachable_flag" }),
204            state: SessionTaskState::Running,
205            state_detail: None,
206            progress: None,
207            input_request: None,
208            cancel_requested_at: None,
209            summary: None,
210            result_path: None,
211            artifacts: vec![],
212            error: None,
213            attempt: 1,
214            worker_id: None,
215            heartbeat_at: None,
216            links: crate::session_task::TaskLinks::default(),
217            wake_policy: TaskWakePolicy::Silent,
218            created_at: Utc::now(),
219            started_at: None,
220            finished_at: None,
221            updated_at: Utc::now(),
222        };
223        // Old tasks without the flag are conservatively treated as non-reattachable.
224        assert!(!exec.can_reattach_task(&task));
225    }
226}