Skip to main content

defect_agent/tool/
background_tasks.rs

1//! Background task control-surface tools: `inspect_background_task` and
2//! `cancel_background_task`.
3//!
4//! After the main agent fire-and-forgets a subagent via `spawn_agent { run_in_background:
5//! true }`, these two tools let it **inspect** and **preemptively cancel** that subagent:
6//!
7//! - [`InspectBackgroundTaskTool`]: without `task_id`, lists all background tasks (id /
8//!   label / status / progress block count); with `task_id`, returns the task's status
9//!   and **most recent blocks** (assistant text / thinking / tool-call boundaries), i.e.
10//!   the subagent's current context and progress.
11//! - [`CancelBackgroundTaskTool`]: preemptively cancels a single background task by
12//!   `task_id`, without affecting other tasks.
13//!
14//! Both read the session-level task table from [`ToolContext::background`]. That handle
15//! is injected only in the **top-level turn** (it is `None` in nested subagent turns), so
16//! these tools—like `spawn_agent`—are capabilities of the top-level agent: they are
17//! layered into the overlay but not into the subagent's tool subset, making them
18//! structurally inaccessible to subagents (same reasoning as recursion prevention).
19//!
20//! Progress data comes from the progress forwarder attached to `spawn_agent`'s background
21//! path: it subscribes to sub-turn events and feeds the "most recent blocks" into the
22//! task's progress ring ([`ProgressBlock`](crate::session::ProgressBlock)). This module
23//! is read-only on that ring.
24
25use std::pin::Pin;
26
27use agent_client_protocol_schema::{
28    Content, ContentBlock, TextContent, ToolCallContent, ToolCallUpdateFields, ToolKind,
29};
30use futures::future::BoxFuture;
31use serde::Deserialize;
32use serde_json::json;
33
34use crate::error::BoxError;
35use crate::session::TaskSnapshot;
36use crate::tool::{
37    SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
38    ToolStream,
39};
40
41/// The name of the `inspect_background_task` tool.
42pub(crate) const INSPECT_BACKGROUND_TASK_TOOL_NAME: &str = "inspect_background_task";
43/// The name of the `cancel_background_task` tool.
44pub(crate) const CANCEL_BACKGROUND_TASK_TOOL_NAME: &str = "cancel_background_task";
45
46fn io_err(msg: String) -> std::io::Error {
47    std::io::Error::other(msg)
48}
49
50/// A shared fail-loud error for both tools when `ctx.background` is `None` — do not
51/// silently degrade, otherwise the model may think the query/cancel succeeded when
52/// nothing actually happened.
53fn no_background_err() -> ToolEvent {
54    ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(
55        "background tasks are not available in this context (only the top-level agent can \
56         inspect or cancel background tasks)"
57            .to_string(),
58    ))))
59}
60
61/// Renders a task snapshot as a single line for the model (for listing, without block
62/// details).
63fn render_summary_line(s: &TaskSnapshot) -> String {
64    format!(
65        "- {} ({}) [{}] — {} progress block(s)",
66        s.task_id,
67        s.label,
68        s.status.as_str(),
69        s.block_count
70    )
71}
72
73/// Render a task snapshot as multi-line text with recent block details (for peek).
74fn render_detail(s: &TaskSnapshot) -> String {
75    let mut out = format!(
76        "background task {} ({}) [{}], {} progress block(s) total",
77        s.task_id,
78        s.label,
79        s.status.as_str(),
80        s.block_count
81    );
82    if s.recent.is_empty() {
83        out.push_str("\n(no progress blocks recorded yet)");
84    } else {
85        out.push_str(&format!("\nmost recent {} block(s):", s.recent.len()));
86        for b in &s.recent {
87            // The body text was already truncated or cleared by `block_text_limit` when
88            // writing to the progress ring, so just render it directly.
89            // When the body is empty (the default bird's-eye mode with `limit=0`), show
90            // only the category label without a dangling colon.
91            if b.text.is_empty() {
92                out.push_str(&format!("\n  [{}]", b.kind.as_str()));
93            } else {
94                out.push_str(&format!("\n  [{}] {}", b.kind.as_str(), b.text));
95            }
96        }
97    }
98    out
99}
100
101/// Wraps text into a `Completed` tool event, with `content` and `raw_output` sharing the
102/// same source.
103fn completed_text(text: String) -> ToolEvent {
104    let mut fields = ToolCallUpdateFields::default();
105    fields.content = Some(vec![ToolCallContent::Content(Content::new(
106        ContentBlock::Text(TextContent::new(text.clone())),
107    ))]);
108    fields.raw_output = Some(serde_json::Value::String(text));
109    ToolEvent::Completed(fields)
110}
111
112// ===================== inspect_background_task =====================
113
114/// Query the status and progress of a background task. Without `task_id`, list all tasks;
115/// with it, query the most recent message block for a single task.
116pub struct InspectBackgroundTaskTool {
117    schema: ToolSchema,
118}
119
120impl Default for InspectBackgroundTaskTool {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl InspectBackgroundTaskTool {
127    #[must_use]
128    pub fn new() -> Self {
129        let schema = ToolSchema {
130            name: INSPECT_BACKGROUND_TASK_TOOL_NAME.to_string(),
131            description: "Inspect background tasks you started with `spawn_agent \
132                          { run_in_background: true }`. Omit `task_id` to list all background \
133                          tasks with their id, label, and status. Pass a `task_id` to see that \
134                          task's status and its most recent conversation blocks — these are the \
135                          subagent's committed messages (the same blocks sent to the model: its \
136                          assistant text, thoughts, tool calls and tool results), NOT raw \
137                          streaming fragments. Use this to check a running subagent's context \
138                          and progress before deciding whether to wait, cancel, or move on."
139                .to_string(),
140            input_schema: json!({
141                "type": "object",
142                "properties": {
143                    "task_id": {
144                        "type": "string",
145                        "description": "Optional. The id of a background task (as returned by \
146                                        spawn_agent, e.g. `bg-0`). When omitted, all background \
147                                        tasks are listed instead."
148                    },
149                    "recent_blocks": {
150                        "type": "integer",
151                        "minimum": 1,
152                        "description": "Optional. When inspecting a single task, how many of the \
153                                        most recent conversation blocks to return. Defaults to a \
154                                        configured value (10 unless overridden)."
155                    }
156                },
157                "required": []
158            }),
159        };
160        Self { schema }
161    }
162}
163
164#[derive(Debug, Deserialize)]
165struct InspectArgs {
166    #[serde(default)]
167    task_id: Option<String>,
168    #[serde(default)]
169    recent_blocks: Option<usize>,
170}
171
172impl Tool for InspectBackgroundTaskTool {
173    fn schema(&self) -> &ToolSchema {
174        &self.schema
175    }
176
177    fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
178        SafetyClass::ReadOnly
179    }
180
181    fn describe<'a>(
182        &'a self,
183        args: &'a serde_json::Value,
184        _ctx: ToolContext<'a>,
185    ) -> BoxFuture<'a, ToolCallDescription> {
186        Box::pin(async move {
187            let title = match args.get("task_id").and_then(|v| v.as_str()) {
188                Some(id) => format!("Inspect background task `{id}`"),
189                None => "List background tasks".to_string(),
190            };
191            let mut fields = ToolCallUpdateFields::default();
192            fields.title = Some(title);
193            fields.kind = Some(ToolKind::Read);
194            ToolCallDescription { fields }
195        })
196    }
197
198    fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
199        let background = ctx.background.clone();
200        let fut = async move {
201            let Some(bg) = background else {
202                return no_background_err();
203            };
204            let parsed: InspectArgs = match serde_json::from_value(args) {
205                Ok(p) => p,
206                Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
207            };
208            match parsed.task_id {
209                None => {
210                    let tasks = bg.list();
211                    if tasks.is_empty() {
212                        return completed_text("No background tasks.".to_string());
213                    }
214                    let body = tasks
215                        .iter()
216                        .map(render_summary_line)
217                        .collect::<Vec<_>>()
218                        .join("\n");
219                    completed_text(format!("{} background task(s):\n{body}", tasks.len()))
220                }
221                Some(id) => {
222                    // When `recent_blocks` is `None`, `peek` uses the configured default
223                    // (10).
224                    match bg.peek(&id, parsed.recent_blocks) {
225                        Some(snap) => completed_text(render_detail(&snap)),
226                        None => ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(
227                            format!("no background task with id `{id}`"),
228                        )))),
229                    }
230                }
231            }
232        };
233        let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
234            Box::pin(futures::stream::once(fut));
235        s
236    }
237}
238
239// ===================== cancel_background_task =====================
240
241/// Cancel a single background task early.
242pub struct CancelBackgroundTaskTool {
243    schema: ToolSchema,
244}
245
246impl Default for CancelBackgroundTaskTool {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl CancelBackgroundTaskTool {
253    #[must_use]
254    pub fn new() -> Self {
255        let schema = ToolSchema {
256            name: CANCEL_BACKGROUND_TASK_TOOL_NAME.to_string(),
257            description: "Interrupt a background task you started with `spawn_agent \
258                          { run_in_background: true }`, by its `task_id`. Cancellation is \
259                          cooperative: the subagent is signalled to stop and the task ends \
260                          shortly after; its (partial/cancelled) result still flows back to you \
261                          on a later turn. Cancelling one task does not affect any other. Use \
262                          `inspect_background_task` first if you need to check a task's progress \
263                          before deciding to cancel it."
264                .to_string(),
265            input_schema: json!({
266                "type": "object",
267                "properties": {
268                    "task_id": {
269                        "type": "string",
270                        "description": "The id of the background task to cancel (as returned by \
271                                        spawn_agent, e.g. `bg-0`)."
272                    }
273                },
274                "required": ["task_id"]
275            }),
276        };
277        Self { schema }
278    }
279}
280
281#[derive(Debug, Deserialize)]
282struct CancelArgs {
283    task_id: String,
284}
285
286impl Tool for CancelBackgroundTaskTool {
287    fn schema(&self) -> &ToolSchema {
288        &self.schema
289    }
290
291    fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
292        // Cancellation is a control action with side effects (terminates a running task);
293        // mark as Mutating.
294        SafetyClass::Mutating
295    }
296
297    fn describe<'a>(
298        &'a self,
299        args: &'a serde_json::Value,
300        _ctx: ToolContext<'a>,
301    ) -> BoxFuture<'a, ToolCallDescription> {
302        Box::pin(async move {
303            let id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
304            let mut fields = ToolCallUpdateFields::default();
305            fields.title = Some(format!("Cancel background task `{id}`"));
306            fields.kind = Some(ToolKind::Other);
307            ToolCallDescription { fields }
308        })
309    }
310
311    fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
312        let background = ctx.background.clone();
313        let fut = async move {
314            let Some(bg) = background else {
315                return no_background_err();
316            };
317            let parsed: CancelArgs = match serde_json::from_value(args) {
318                Ok(p) => p,
319                Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
320            };
321            match bg.cancel_task(&parsed.task_id) {
322                Some(true) => completed_text(format!(
323                    "Requested cancellation of background task `{}`. It will stop shortly; its \
324                     result arrives on a later turn.",
325                    parsed.task_id
326                )),
327                Some(false) => completed_text(format!(
328                    "Background task `{}` has already finished — nothing to cancel.",
329                    parsed.task_id
330                )),
331                None => ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(format!(
332                    "no background task with id `{}`",
333                    parsed.task_id
334                ))))),
335            }
336        };
337        let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
338            Box::pin(futures::stream::once(fut));
339        s
340    }
341}
342
343#[cfg(test)]
344mod tests;