Skip to main content

scv_tools/delegate/
background.rs

1//! Background delegations: an `agent` call with `background: true` returns a
2//! job handle at once while the agent keeps working; `agent_status` and
3//! `agent_wait` observe the job, `agent_cancel` stops it, and the session is
4//! told when it finishes so the server can report it in a turn of its own.
5//!
6//! Each call that starts a job, or shows the model a job's result, leaves a
7//! [`JobChange`] under its call ID, which the server hands to the session's
8//! clients with the call's `tool.completed` ([`BackgroundJobs::take_changes`]).
9
10use std::{
11    path::PathBuf,
12    sync::{Arc, Mutex, Weak},
13    time::{Duration, Instant},
14};
15
16use async_trait::async_trait;
17use scv_core::{
18    ApprovalGate, ProgressSink, Tool, ToolApprovals, ToolContext, ToolError, ToolOutput, ToolRisk,
19    ToolSpec,
20};
21use scv_protocol::{JobChange, JobStatus};
22use serde::Deserialize;
23use serde_json::{Map, Value, json};
24use tokio::sync::{mpsc, watch};
25use tokio_util::sync::CancellationToken;
26
27use crate::{
28    args::{Timeouts, bounded, parse_args, timeout_schema},
29    delegate::{
30        agent::{AGENT_TOOL, AgentTool},
31        output::AgentReply,
32    },
33    sync::lock,
34};
35
36/// Finished jobs a session keeps for `agent_status` beyond the running ones.
37const MAX_FINISHED: usize = 16;
38/// Characters of a finished job's reply quoted in a server-started report turn.
39const REPORT_REPLY_CHARS: usize = 6000;
40/// Jobs one report turn covers; any more wait for the next.
41const REPORT_MAX_JOBS: usize = 4;
42/// How long `agent_cancel` waits for a stopped job to settle.
43const CANCEL_SETTLE: Duration = Duration::from_secs(10);
44/// Job changes kept for calls whose `tool.completed` has not taken them,
45/// such as a call whose turn was aborted; the oldest go first.
46const MAX_PENDING_CHANGES: usize = 64;
47/// Characters of a delegated prompt's first line kept as its job's task.
48const TASK_CHARS: usize = 80;
49
50/// One session's background jobs. Dropping the store (with the session's
51/// tools) cancels every job still running.
52pub struct BackgroundJobs {
53    limit: usize,
54    state: Mutex<JobsState>,
55    cancellation: CancellationToken,
56    /// Woken whenever a job finishes, so the session can report it.
57    finished: Option<mpsc::UnboundedSender<()>>,
58    /// Decides a running job's nested approval requests, since no turn is
59    /// left to carry them to a person. Without it they are denied.
60    approvals: Option<Arc<dyn ApprovalGate>>,
61}
62
63impl std::fmt::Debug for BackgroundJobs {
64    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        formatter
66            .debug_struct("BackgroundJobs")
67            .field("limit", &self.limit)
68            .finish_non_exhaustive()
69    }
70}
71
72#[derive(Default)]
73struct JobsState {
74    next: u64,
75    jobs: Vec<Job>,
76    /// Changes by the ID of the call that made them, oldest first.
77    changes: Vec<(String, JobChange)>,
78}
79
80impl JobsState {
81    /// Keep `change` for the call `call_id`, which a client learns of with
82    /// that call's `tool.completed`. A call without an ID has no event.
83    fn record(&mut self, call_id: &str, change: JobChange) {
84        if call_id.is_empty() {
85            return;
86        }
87        if self.changes.len() >= MAX_PENDING_CHANGES {
88            self.changes.remove(0);
89        }
90        self.changes.push((call_id.to_owned(), change));
91    }
92}
93
94struct Job {
95    id: String,
96    /// The tool that started it, `agent`.
97    tool: String,
98    /// The agent that runs it, such as `codex`.
99    agent: String,
100    /// The first line of the delegated prompt, shortened.
101    task: String,
102    started: Instant,
103    progress: ProgressSink,
104    last_progress: Option<String>,
105    /// Stops this job alone.
106    cancel: CancellationToken,
107    /// `agent_cancel` stopped it.
108    cancelled: bool,
109    outcome: Option<Outcome>,
110    /// The model has seen the result (through `agent_wait`, `agent_status`,
111    /// or a report turn), or asked for the stop with `agent_cancel`, so it
112    /// needs no report turn.
113    reported: bool,
114    done: watch::Receiver<bool>,
115}
116
117struct Outcome {
118    output: ToolOutput,
119    elapsed: Duration,
120}
121
122/// A finished job not yet seen by the model, for a server-started report turn.
123#[derive(Debug, Clone)]
124pub struct JobReport {
125    pub job: String,
126    /// The agent that ran it, such as `codex`.
127    pub(crate) agent: String,
128    pub(crate) status: JobStatus,
129    pub(crate) session: Option<String>,
130    pub(crate) reply: String,
131}
132
133impl Drop for BackgroundJobs {
134    fn drop(&mut self) {
135        self.cancellation.cancel();
136    }
137}
138
139impl BackgroundJobs {
140    /// At most `limit` jobs run at once. `finished` is woken as jobs finish.
141    pub fn new(limit: usize, finished: Option<mpsc::UnboundedSender<()>>) -> Self {
142        Self {
143            limit,
144            state: Mutex::default(),
145            cancellation: CancellationToken::new(),
146            finished,
147            approvals: None,
148        }
149    }
150
151    /// Decide running jobs' nested approval requests with `gate`, which must
152    /// never grant more than the session's foreground would.
153    #[must_use]
154    pub fn with_approvals(mut self, gate: Arc<dyn ApprovalGate>) -> Self {
155        self.approvals = Some(gate);
156        self
157    }
158
159    pub(crate) fn limit(&self) -> usize {
160        self.limit
161    }
162
163    fn state(&self) -> std::sync::MutexGuard<'_, JobsState> {
164        lock(&self.state)
165    }
166
167    /// Start `tool` with `arguments` in the background for the call
168    /// `call_id`, on `agent`, and return its job's
169    /// `{"job","agent","status":"running","background":true}` description.
170    fn start(
171        self: &Arc<Self>,
172        tool: Arc<dyn Tool>,
173        name: &str,
174        agent: &str,
175        arguments: Value,
176        workspace: PathBuf,
177        call_id: &str,
178    ) -> Result<Value, ToolError> {
179        let task = task_line(
180            arguments
181                .get("prompt")
182                .and_then(Value::as_str)
183                .unwrap_or_default(),
184        );
185        let (id, progress, done_tx, cancellation) = {
186            let mut state = self.state();
187            let running = state
188                .jobs
189                .iter()
190                .filter(|job| job.outcome.is_none())
191                .count();
192            if running >= self.limit {
193                return Err(ToolError::limit(format!(
194                    "{running} background jobs are already running, the limit \
195                     (agent.max_background). Start this one after a job finishes, or \
196                     stop one with agent_cancel if the user no longer needs it."
197                )));
198            }
199            state.next += 1;
200            let id = format!("job-{}", state.next);
201            let progress = ProgressSink::buffered();
202            let (done_tx, done) = watch::channel(false);
203            let cancel = self.cancellation.child_token();
204            state.record(
205                call_id,
206                JobChange {
207                    job: id.clone(),
208                    tool: name.to_owned(),
209                    agent: agent.to_owned(),
210                    status: JobStatus::Running,
211                    task: task.clone(),
212                },
213            );
214            state.jobs.push(Job {
215                id: id.clone(),
216                tool: name.to_owned(),
217                agent: agent.to_owned(),
218                task,
219                started: Instant::now(),
220                progress: progress.clone(),
221                last_progress: None,
222                cancel: cancel.clone(),
223                cancelled: false,
224                outcome: None,
225                reported: false,
226                done,
227            });
228            (id, progress, done_tx, cancel)
229        };
230        let jobs = Arc::downgrade(self);
231        let job = id.clone();
232        let approvals = self.approvals.clone();
233        tokio::spawn(async move {
234            let started = Instant::now();
235            let mut context = ToolContext::new(workspace, cancellation);
236            // No turn is left to ask a person, so nested approval requests
237            // get the session's unattended answer, or are denied.
238            if let Some(gate) = &approvals {
239                context.approvals = ToolApprovals::new(Arc::clone(gate), job.clone());
240            }
241            context.progress = progress;
242            let output = tool
243                .execute(arguments, context)
244                .await
245                .unwrap_or_else(ToolOutput::from);
246            finish(&jobs, &job, output, started.elapsed());
247            let _ = done_tx.send(true);
248        });
249        Ok(json!({
250            "job": id,
251            "agent": agent,
252            "status": "running",
253            "background": true,
254            "note": "The agent is working in the background. SCV reports the result in a new \
255                     turn when it finishes. agent_status shows its progress and agent_cancel \
256                     stops it."
257        }))
258    }
259
260    /// Stop `job` for the call `call_id` and wait briefly for it to settle,
261    /// then describe it. A stopped job needs no report turn: the model asked
262    /// for the stop.
263    async fn cancel(&self, job: &str, call_id: &str) -> Result<Value, ToolError> {
264        let mut done = {
265            let mut state = self.state();
266            let index = state
267                .jobs
268                .iter()
269                .position(|candidate| candidate.id == job)
270                .ok_or_else(|| unknown_job(job))?;
271            let entry = &mut state.jobs[index];
272            if entry.outcome.is_some() {
273                let (mut value, change) = entry.describe();
274                value["note"] = "The job had already finished.".into();
275                if let Some(change) = change {
276                    state.record(call_id, change);
277                }
278                return Ok(value);
279            }
280            entry.cancelled = true;
281            entry.cancel.cancel();
282            let done = entry.done.clone();
283            if !entry.reported {
284                entry.reported = true;
285                let change = entry.change(JobStatus::Cancelled);
286                state.record(call_id, change);
287            }
288            done
289        };
290        let _ = tokio::time::timeout(CANCEL_SETTLE, done.wait_for(|finished| *finished)).await;
291        self.describe(Some(job), call_id)
292    }
293
294    /// Wait up to `limit` for `job` and describe it for the call `call_id`;
295    /// a finished job is then marked seen.
296    async fn wait(
297        &self,
298        job: &str,
299        limit: Duration,
300        cancellation: &CancellationToken,
301        call_id: &str,
302    ) -> Result<Value, ToolError> {
303        let mut done = self
304            .state()
305            .jobs
306            .iter()
307            .find(|candidate| candidate.id == job)
308            .map(|candidate| candidate.done.clone())
309            .ok_or_else(|| unknown_job(job))?;
310        tokio::select! {
311            () = cancellation.cancelled() => return Err(ToolError::cancelled("wait cancelled")),
312            _ = tokio::time::timeout(limit, done.wait_for(|finished| *finished)) => {}
313        }
314        self.describe(Some(job), call_id)
315    }
316
317    /// Describe one job, or every job this session remembers, for the call
318    /// `call_id`; finished jobs described are marked seen.
319    fn describe(&self, job: Option<&str>, call_id: &str) -> Result<Value, ToolError> {
320        let mut state = self.state();
321        let mut changes = Vec::new();
322        let value = if let Some(job) = job {
323            let entry = state
324                .jobs
325                .iter_mut()
326                .find(|candidate| candidate.id == job)
327                .ok_or_else(|| unknown_job(job))?;
328            let (value, change) = entry.describe();
329            changes.extend(change);
330            value
331        } else {
332            let jobs: Vec<Value> = state
333                .jobs
334                .iter_mut()
335                .map(|entry| {
336                    let (value, change) = entry.describe();
337                    changes.extend(change);
338                    value
339                })
340                .collect();
341            json!({ "jobs": jobs })
342        };
343        for change in changes {
344            state.record(call_id, change);
345        }
346        Ok(value)
347    }
348
349    /// The jobs the call `call_id` started or showed the model the result of,
350    /// for its `tool.completed`.
351    pub fn take_changes(&self, call_id: &str) -> Vec<JobChange> {
352        let mut state = self.state();
353        let mut taken = Vec::new();
354        state.changes.retain(|(call, change)| {
355            if call == call_id {
356                taken.push(change.clone());
357                false
358            } else {
359                true
360            }
361        });
362        taken
363    }
364
365    /// Finished jobs the model has not seen yet, marked seen, for a report
366    /// turn. At most a few per call; the rest stay for the next.
367    pub fn take_unreported(&self) -> Vec<JobReport> {
368        let mut state = self.state();
369        state
370            .jobs
371            .iter_mut()
372            .filter(|job| job.outcome.is_some() && !job.reported)
373            .take(REPORT_MAX_JOBS)
374            .map(|job| {
375                job.reported = true;
376                let outcome = job.outcome.as_ref().expect("filtered on outcome");
377                let reply = AgentReply::read(&result_value(&outcome.output)).unwrap_or_default();
378                JobReport {
379                    job: job.id.clone(),
380                    agent: job.agent.clone(),
381                    status: job_status(&outcome.output, &reply),
382                    session: reply.session,
383                    reply: bounded(
384                        reply
385                            .reply
386                            .as_deref()
387                            .unwrap_or(outcome.output.content.as_str()),
388                        REPORT_REPLY_CHARS,
389                    ),
390                }
391            })
392            .collect()
393    }
394
395    /// Whether any job is still running.
396    pub fn running(&self) -> usize {
397        self.state()
398            .jobs
399            .iter()
400            .filter(|job| job.outcome.is_none())
401            .count()
402    }
403}
404
405fn finish(jobs: &Weak<BackgroundJobs>, id: &str, output: ToolOutput, elapsed: Duration) {
406    // The session ended first: its jobs were cancelled with it.
407    let Some(jobs) = jobs.upgrade() else {
408        return;
409    };
410    {
411        let mut state = jobs.state();
412        if let Some(job) = state.jobs.iter_mut().find(|job| job.id == id) {
413            job.last_progress = job.progress.take().or(job.last_progress.take());
414            job.outcome = Some(Outcome { output, elapsed });
415            // Stopped on request: the model already knows.
416            job.reported |= job.cancelled;
417        }
418        // Keep every running job and the newest finished ones.
419        let finished = state
420            .jobs
421            .iter()
422            .filter(|job| job.outcome.is_some())
423            .count();
424        let mut excess = finished.saturating_sub(MAX_FINISHED);
425        state.jobs.retain(|job| {
426            if excess > 0 && job.outcome.is_some() && job.reported {
427                excess -= 1;
428                false
429            } else {
430                true
431            }
432        });
433    }
434    if let Some(finished) = &jobs.finished {
435        let _ = finished.send(());
436    }
437}
438
439impl Job {
440    /// This job with `status`, as its clients learn of it.
441    fn change(&self, status: JobStatus) -> JobChange {
442        JobChange {
443            job: self.id.clone(),
444            tool: self.tool.clone(),
445            agent: self.agent.clone(),
446            status,
447            task: self.task.clone(),
448        }
449    }
450
451    /// The job as the model reads it, and its change when this is the first
452    /// time the model sees its result.
453    fn describe(&mut self) -> (Value, Option<JobChange>) {
454        if let Some(line) = self.progress.take() {
455            self.last_progress = Some(line);
456        }
457        let mut change = None;
458        let mut value = Map::new();
459        value.insert("job".into(), self.id.clone().into());
460        value.insert("agent".into(), self.agent.clone().into());
461        match &self.outcome {
462            None => {
463                value.insert("status".into(), "running".into());
464                value.insert(
465                    "elapsed_seconds".into(),
466                    self.started.elapsed().as_secs().into(),
467                );
468                if let Some(progress) = &self.last_progress {
469                    value.insert("progress".into(), progress.clone().into());
470                }
471            }
472            Some(outcome) => {
473                let result = result_value(&outcome.output);
474                let status = if self.cancelled {
475                    JobStatus::Cancelled
476                } else {
477                    job_status(
478                        &outcome.output,
479                        &AgentReply::read(&result).unwrap_or_default(),
480                    )
481                };
482                value.insert("status".into(), status.as_str().into());
483                value.insert("elapsed_seconds".into(), outcome.elapsed.as_secs().into());
484                value.insert("result".into(), result);
485                if !self.reported {
486                    self.reported = true;
487                    change = Some(self.change(status));
488                }
489            }
490        }
491        (Value::Object(value), change)
492    }
493}
494
495/// The agent tool's structured result, or its text when it was not JSON.
496fn result_value(output: &ToolOutput) -> Value {
497    match serde_json::from_str::<Value>(&output.content) {
498        Ok(value @ Value::Object(_)) => value,
499        _ => json!({ "reply": output.content, "is_error": output.is_error() }),
500    }
501}
502
503/// How a finished job ended: its agent's reported status, else whether its
504/// call failed.
505fn job_status(output: &ToolOutput, reply: &AgentReply) -> JobStatus {
506    reply.status.unwrap_or(if output.is_error() {
507        JobStatus::Failed
508    } else {
509        JobStatus::Completed
510    })
511}
512
513/// The first non-empty line of `prompt`, at most [`TASK_CHARS`] characters,
514/// which names a job to people.
515fn task_line(prompt: &str) -> String {
516    let line = prompt
517        .lines()
518        .map(str::trim)
519        .find(|line| !line.is_empty())
520        .unwrap_or_default();
521    let mut task: String = line
522        .chars()
523        .filter(|character| !character.is_control())
524        .take(TASK_CHARS)
525        .collect();
526    if line.chars().count() > TASK_CHARS {
527        task.push('…');
528    }
529    task
530}
531
532fn unknown_job(job: &str) -> ToolError {
533    ToolError::invalid_arguments(format!(
534        "unknown background job {:?}; agent_status lists this session's jobs",
535        bounded(job, 64)
536    ))
537}
538
539/// The server-started turn that reports finished jobs to the model.
540pub fn report_prompt(reports: &[JobReport]) -> String {
541    let mut prompt = String::from(
542        "[SCV background report] Delegated work you started in the background has \
543         finished. The user did not send this message: tell them briefly what \
544         happened and the key result.\n",
545    );
546    for report in reports {
547        prompt.push_str(&format!(
548            "\n{} ({}{}): {}\n{}\n",
549            report.job,
550            report.agent,
551            report
552                .session
553                .as_deref()
554                .map_or_else(String::new, |session| format!(", conversation {session}")),
555            report.status,
556            report.reply.trim()
557        ));
558    }
559    prompt
560}
561
562/// The `agent` tool, able to run a call in the background as well.
563pub(crate) struct BackgroundCapable {
564    pub(crate) inner: Arc<AgentTool>,
565    pub(crate) jobs: Arc<BackgroundJobs>,
566}
567
568/// Split `background` off an agent call's arguments.
569fn split_background(arguments: &Value) -> (Value, bool) {
570    let mut arguments = arguments.clone();
571    let background = arguments
572        .as_object_mut()
573        .and_then(|object| object.remove("background"))
574        .is_some_and(|value| value.as_bool() == Some(true));
575    (arguments, background)
576}
577
578#[async_trait]
579impl Tool for BackgroundCapable {
580    fn spec(&self) -> ToolSpec {
581        let mut spec = self.inner.spec();
582        if let Some(properties) = spec
583            .parameters
584            .get_mut("properties")
585            .and_then(Value::as_object_mut)
586        {
587            properties.insert(
588                "background".into(),
589                json!({
590                    "type":"boolean",
591                    "description":"Run in the background: the call returns a job handle at once, \
592                        the user can keep talking to you while the agent works, and SCV reports \
593                        the result in a new turn when it finishes. Use it for any substantial \
594                        task; run in the foreground only for quick work whose result you need \
595                        within this turn."
596                }),
597            );
598        }
599        spec.description.push_str(&format!(
600            " Set background to true for anything beyond a quick task: the call returns a job \
601             handle at once (at most {} running per session), SCV reports the result when the \
602             job finishes, agent_status shows progress, and agent_cancel stops it. A background \
603             job's own approval requests get only the answer this session would give without \
604             asking a person.",
605            self.jobs.limit()
606        ));
607        spec
608    }
609
610    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
611        self.inner.risk(&split_background(arguments).0)
612    }
613
614    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
615        let (arguments, background) = split_background(arguments);
616        let mut summary = self.inner.approval_summary(&arguments)?;
617        if background {
618            summary.push_str(
619                " Runs in the background: the call returns at once and the result is \
620                 reported when the agent finishes.",
621            );
622        }
623        Ok(summary)
624    }
625
626    async fn execute(
627        &self,
628        arguments: Value,
629        context: ToolContext,
630    ) -> Result<ToolOutput, ToolError> {
631        let (arguments, background) = split_background(&arguments);
632        if !background {
633            return self.inner.execute(arguments, context).await;
634        }
635        // Validate before returning a job handle, so a bad call fails now.
636        let agent = self.inner.route(&arguments)?;
637        agent.backend.risk(&arguments)?;
638        let agent = agent.name.clone();
639        let started = self.jobs.start(
640            Arc::clone(&self.inner) as Arc<dyn Tool>,
641            AGENT_TOOL,
642            &agent,
643            arguments,
644            context.workspace,
645            &context.call_id,
646        )?;
647        Ok(ToolOutput::success(started.to_string()))
648    }
649}
650
651#[derive(Deserialize)]
652#[serde(deny_unknown_fields)]
653struct WaitArgs {
654    job: String,
655    timeout_seconds: Option<u64>,
656}
657
658#[derive(Deserialize)]
659#[serde(deny_unknown_fields)]
660struct StatusArgs {
661    job: Option<String>,
662}
663
664/// `agent_wait`: block until a background job finishes, or the timeout.
665pub(crate) struct WaitTool {
666    pub(crate) jobs: Arc<BackgroundJobs>,
667    pub(crate) timeouts: Timeouts,
668}
669
670#[async_trait]
671impl Tool for WaitTool {
672    fn spec(&self) -> ToolSpec {
673        let mut timeout = timeout_schema(self.timeouts);
674        timeout["description"] = format!(
675            "Seconds to wait before returning the job still running. Defaults to {}; at most {}.",
676            self.timeouts.default.min(self.timeouts.max).as_secs(),
677            self.timeouts.max.as_secs()
678        )
679        .into();
680        ToolSpec {
681            name: "agent_wait".into(),
682            description: "Wait for a background agent job (the `job` handle an agent call with \
683                background: true returned) to finish, and return its result. Returns early with \
684                status running when the timeout passes. Waiting holds your turn open, so the \
685                user cannot reach you meanwhile; usually let SCV report the result instead."
686                .into(),
687            parameters: json!({
688                "type":"object",
689                "properties":{"job":{"type":"string"},"timeout_seconds":timeout},
690                "required":["job"],
691                "additionalProperties":false
692            }),
693        }
694    }
695
696    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
697        let args: WaitArgs = parse_args(arguments)?;
698        self.timeouts.resolve(args.timeout_seconds)?;
699        Ok(ToolRisk::ReadOnly)
700    }
701
702    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
703        let args: WaitArgs = parse_args(arguments)?;
704        Ok(format!(
705            "Wait for background job {}",
706            bounded(&args.job, 64)
707        ))
708    }
709
710    async fn execute(
711        &self,
712        arguments: Value,
713        context: ToolContext,
714    ) -> Result<ToolOutput, ToolError> {
715        let args: WaitArgs = parse_args(&arguments)?;
716        let limit = self.timeouts.resolve(args.timeout_seconds)?;
717        let value = self
718            .jobs
719            .wait(&args.job, limit, &context.cancellation, &context.call_id)
720            .await?;
721        Ok(ToolOutput::success(value.to_string()))
722    }
723}
724
725/// `agent_status`: describe one background job or all of them.
726pub(crate) struct StatusTool {
727    pub(crate) jobs: Arc<BackgroundJobs>,
728}
729
730#[async_trait]
731impl Tool for StatusTool {
732    fn spec(&self) -> ToolSpec {
733        ToolSpec {
734            name: "agent_status".into(),
735            description: "Show this session's background agent jobs: running ones with their \
736                latest progress, finished ones with their result. Pass job for one job."
737                .into(),
738            parameters: json!({
739                "type":"object",
740                "properties":{"job":{"type":"string"}},
741                "additionalProperties":false
742            }),
743        }
744    }
745
746    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
747        let _: StatusArgs = parse_args(arguments)?;
748        Ok(ToolRisk::ReadOnly)
749    }
750
751    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
752        let args: StatusArgs = parse_args(arguments)?;
753        Ok(args.job.map_or_else(
754            || "List background jobs".into(),
755            |job| format!("Show background job {}", bounded(&job, 64)),
756        ))
757    }
758
759    async fn execute(
760        &self,
761        arguments: Value,
762        context: ToolContext,
763    ) -> Result<ToolOutput, ToolError> {
764        let args: StatusArgs = parse_args(&arguments)?;
765        let value = self.jobs.describe(args.job.as_deref(), &context.call_id)?;
766        Ok(ToolOutput::success(value.to_string()))
767    }
768}
769
770#[derive(Deserialize)]
771#[serde(deny_unknown_fields)]
772struct CancelArgs {
773    job: String,
774}
775
776/// `agent_cancel`: stop one running background job.
777pub(crate) struct CancelTool {
778    pub(crate) jobs: Arc<BackgroundJobs>,
779}
780
781#[async_trait]
782impl Tool for CancelTool {
783    fn spec(&self) -> ToolSpec {
784        ToolSpec {
785            name: "agent_cancel".into(),
786            description: "Stop a running background agent job (the `job` handle an agent call \
787                with background: true returned), for example when the user no longer wants \
788                it. The agent and every process it started are stopped; work it already wrote \
789                stays. Returns the job with status cancelled, and no report turn follows."
790                .into(),
791            parameters: json!({
792                "type":"object",
793                "properties":{"job":{"type":"string"}},
794                "required":["job"],
795                "additionalProperties":false
796            }),
797        }
798    }
799
800    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
801        let _: CancelArgs = parse_args(arguments)?;
802        Ok(ToolRisk::Process)
803    }
804
805    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
806        let args: CancelArgs = parse_args(arguments)?;
807        Ok(format!("Stop background job {}", bounded(&args.job, 64)))
808    }
809
810    async fn execute(
811        &self,
812        arguments: Value,
813        context: ToolContext,
814    ) -> Result<ToolOutput, ToolError> {
815        let args: CancelArgs = parse_args(&arguments)?;
816        let value = self.jobs.cancel(&args.job, &context.call_id).await?;
817        Ok(ToolOutput::success(value.to_string()))
818    }
819}
820
821#[cfg(test)]
822mod tests;