1use 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
41pub(crate) const INSPECT_BACKGROUND_TASK_TOOL_NAME: &str = "inspect_background_task";
43pub(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
50fn 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
61fn 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
73fn 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 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
101fn 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
112pub 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 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
239pub 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 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;