Skip to main content

scv_tools/
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
6use std::{
7    path::PathBuf,
8    sync::{Arc, Mutex, PoisonError, Weak},
9    time::{Duration, Instant},
10};
11
12use async_trait::async_trait;
13use scv_core::{
14    ApprovalGate, ProgressSink, Tool, ToolApprovals, ToolContext, ToolError, ToolOutput, ToolRisk,
15    ToolSpec,
16};
17use serde::Deserialize;
18use serde_json::{Map, Value, json};
19use tokio::sync::{mpsc, watch};
20use tokio_util::sync::CancellationToken;
21
22use crate::{Timeouts, bounded, parse_args, timeout_schema};
23
24/// Finished jobs a session keeps for `agent_status` beyond the running ones.
25const MAX_FINISHED: usize = 16;
26/// Characters of a finished job's reply quoted in a server-started report turn.
27const REPORT_REPLY_CHARS: usize = 6000;
28/// Jobs one report turn covers; any more wait for the next.
29const REPORT_MAX_JOBS: usize = 4;
30/// How long `agent_cancel` waits for a stopped job to settle.
31const CANCEL_SETTLE: Duration = Duration::from_secs(10);
32
33/// One session's background jobs. Dropping the store (with the session's
34/// tools) cancels every job still running.
35pub struct BackgroundJobs {
36    limit: usize,
37    state: Mutex<JobsState>,
38    cancellation: CancellationToken,
39    /// Woken whenever a job finishes, so the session can report it.
40    finished: Option<mpsc::UnboundedSender<()>>,
41    /// Decides a running job's nested approval requests, since no turn is
42    /// left to carry them to a person. Without it they are denied.
43    approvals: Option<Arc<dyn ApprovalGate>>,
44}
45
46impl std::fmt::Debug for BackgroundJobs {
47    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        formatter
49            .debug_struct("BackgroundJobs")
50            .field("limit", &self.limit)
51            .finish_non_exhaustive()
52    }
53}
54
55#[derive(Default)]
56struct JobsState {
57    next: u64,
58    jobs: Vec<Job>,
59}
60
61struct Job {
62    id: String,
63    tool: String,
64    started: Instant,
65    progress: ProgressSink,
66    last_progress: Option<String>,
67    /// Stops this job alone.
68    cancel: CancellationToken,
69    /// `agent_cancel` stopped it.
70    cancelled: bool,
71    outcome: Option<Outcome>,
72    /// The model has seen the result (through `agent_wait`, `agent_status`,
73    /// or a report turn), so it needs no report turn.
74    reported: bool,
75    done: watch::Receiver<bool>,
76}
77
78struct Outcome {
79    output: ToolOutput,
80    elapsed: Duration,
81}
82
83/// A finished job not yet seen by the model, for a server-started report turn.
84#[derive(Debug, Clone)]
85pub struct JobReport {
86    pub job: String,
87    pub tool: String,
88    pub status: String,
89    pub session: Option<String>,
90    pub reply: String,
91}
92
93impl Drop for BackgroundJobs {
94    fn drop(&mut self) {
95        self.cancellation.cancel();
96    }
97}
98
99impl BackgroundJobs {
100    /// At most `limit` jobs run at once. `finished` is woken as jobs finish.
101    pub fn new(limit: usize, finished: Option<mpsc::UnboundedSender<()>>) -> Self {
102        Self {
103            limit,
104            state: Mutex::default(),
105            cancellation: CancellationToken::new(),
106            finished,
107            approvals: None,
108        }
109    }
110
111    /// Decide running jobs' nested approval requests with `gate`, which must
112    /// never grant more than the session's foreground would.
113    pub fn with_approvals(mut self, gate: Arc<dyn ApprovalGate>) -> Self {
114        self.approvals = Some(gate);
115        self
116    }
117
118    pub fn limit(&self) -> usize {
119        self.limit
120    }
121
122    fn state(&self) -> std::sync::MutexGuard<'_, JobsState> {
123        self.state.lock().unwrap_or_else(PoisonError::into_inner)
124    }
125
126    /// Start `tool` with `arguments` in the background and return its job's
127    /// `{"job","status":"running","background":true}` description.
128    fn start(
129        self: &Arc<Self>,
130        tool: Arc<dyn Tool>,
131        name: &str,
132        arguments: Value,
133        workspace: PathBuf,
134    ) -> Result<Value, ToolError> {
135        let (id, progress, done_tx, cancellation) = {
136            let mut state = self.state();
137            let running = state
138                .jobs
139                .iter()
140                .filter(|job| job.outcome.is_none())
141                .count();
142            if running >= self.limit {
143                return Err(ToolError(format!(
144                    "{running} background jobs are already running, the limit \
145                     (agent.max_background). Start this one after a job finishes, or \
146                     stop one with agent_cancel if the user no longer needs it."
147                )));
148            }
149            state.next += 1;
150            let id = format!("job-{}", state.next);
151            let progress = ProgressSink::buffered();
152            let (done_tx, done) = watch::channel(false);
153            let cancel = self.cancellation.child_token();
154            state.jobs.push(Job {
155                id: id.clone(),
156                tool: name.to_owned(),
157                started: Instant::now(),
158                progress: progress.clone(),
159                last_progress: None,
160                cancel: cancel.clone(),
161                cancelled: false,
162                outcome: None,
163                reported: false,
164                done,
165            });
166            (id, progress, done_tx, cancel)
167        };
168        let jobs = Arc::downgrade(self);
169        let job = id.clone();
170        let approvals = self.approvals.clone();
171        tokio::spawn(async move {
172            let started = Instant::now();
173            let mut context = ToolContext::new(workspace, cancellation);
174            // No turn is left to ask a person, so nested approval requests
175            // get the session's unattended answer, or are denied.
176            if let Some(gate) = &approvals {
177                context.approvals = ToolApprovals::new(Arc::clone(gate), job.clone());
178            }
179            context.progress = progress;
180            let output = tool
181                .execute(arguments, context)
182                .await
183                .unwrap_or_else(|error| ToolOutput::failure(error.to_string()));
184            finish(&jobs, &job, output, started.elapsed());
185            let _ = done_tx.send(true);
186        });
187        Ok(json!({
188            "job": id,
189            "tool": name,
190            "status": "running",
191            "background": true,
192            "note": "The agent is working in the background. SCV reports the result in a new \
193                     turn when it finishes. agent_status shows its progress and agent_cancel \
194                     stops it."
195        }))
196    }
197
198    /// Stop `job` and wait briefly for it to settle, then describe it. A
199    /// stopped job needs no report turn: the model asked for the stop.
200    async fn cancel(&self, job: &str) -> Result<Value, ToolError> {
201        let mut done = {
202            let mut state = self.state();
203            let entry = state
204                .jobs
205                .iter_mut()
206                .find(|candidate| candidate.id == job)
207                .ok_or_else(|| unknown_job(job))?;
208            if entry.outcome.is_some() {
209                let mut value = entry.describe();
210                value["note"] = "The job had already finished.".into();
211                return Ok(value);
212            }
213            entry.cancelled = true;
214            entry.cancel.cancel();
215            entry.done.clone()
216        };
217        let _ = tokio::time::timeout(CANCEL_SETTLE, done.wait_for(|finished| *finished)).await;
218        self.describe(Some(job))
219    }
220
221    /// Wait up to `limit` for `job` and describe it; a finished job is then
222    /// marked seen.
223    async fn wait(
224        &self,
225        job: &str,
226        limit: Duration,
227        cancellation: &CancellationToken,
228    ) -> Result<Value, ToolError> {
229        let mut done = self
230            .state()
231            .jobs
232            .iter()
233            .find(|candidate| candidate.id == job)
234            .map(|candidate| candidate.done.clone())
235            .ok_or_else(|| unknown_job(job))?;
236        tokio::select! {
237            _ = cancellation.cancelled() => return Err(ToolError("wait cancelled".into())),
238            _ = tokio::time::timeout(limit, done.wait_for(|finished| *finished)) => {}
239        }
240        self.describe(Some(job))
241    }
242
243    /// Describe one job, or every job this session remembers; finished jobs
244    /// described are marked seen.
245    fn describe(&self, job: Option<&str>) -> Result<Value, ToolError> {
246        let mut state = self.state();
247        if let Some(job) = job {
248            let entry = state
249                .jobs
250                .iter_mut()
251                .find(|candidate| candidate.id == job)
252                .ok_or_else(|| unknown_job(job))?;
253            return Ok(entry.describe());
254        }
255        let jobs: Vec<Value> = state.jobs.iter_mut().map(Job::describe).collect();
256        Ok(json!({ "jobs": jobs }))
257    }
258
259    /// Finished jobs the model has not seen yet, marked seen, for a report
260    /// turn. At most a few per call; the rest stay for the next.
261    pub fn take_unreported(&self) -> Vec<JobReport> {
262        let mut state = self.state();
263        state
264            .jobs
265            .iter_mut()
266            .filter(|job| job.outcome.is_some() && !job.reported)
267            .take(REPORT_MAX_JOBS)
268            .map(|job| {
269                job.reported = true;
270                let outcome = job.outcome.as_ref().expect("filtered on outcome");
271                let result = result_value(&outcome.output);
272                JobReport {
273                    job: job.id.clone(),
274                    tool: job.tool.clone(),
275                    status: job_status(&outcome.output, &result),
276                    session: result
277                        .get("session")
278                        .and_then(Value::as_str)
279                        .map(str::to_owned),
280                    reply: bounded(
281                        result
282                            .get("reply")
283                            .and_then(Value::as_str)
284                            .unwrap_or(outcome.output.content.as_str()),
285                        REPORT_REPLY_CHARS,
286                    ),
287                }
288            })
289            .collect()
290    }
291
292    /// Whether any job is still running.
293    pub fn running(&self) -> usize {
294        self.state()
295            .jobs
296            .iter()
297            .filter(|job| job.outcome.is_none())
298            .count()
299    }
300}
301
302fn finish(jobs: &Weak<BackgroundJobs>, id: &str, output: ToolOutput, elapsed: Duration) {
303    // The session ended first: its jobs were cancelled with it.
304    let Some(jobs) = jobs.upgrade() else {
305        return;
306    };
307    {
308        let mut state = jobs.state();
309        if let Some(job) = state.jobs.iter_mut().find(|job| job.id == id) {
310            job.last_progress = job.progress.take().or(job.last_progress.take());
311            job.outcome = Some(Outcome { output, elapsed });
312            // Stopped on request: the model already knows.
313            job.reported |= job.cancelled;
314        }
315        // Keep every running job and the newest finished ones.
316        let finished = state
317            .jobs
318            .iter()
319            .filter(|job| job.outcome.is_some())
320            .count();
321        let mut excess = finished.saturating_sub(MAX_FINISHED);
322        state.jobs.retain(|job| {
323            if excess > 0 && job.outcome.is_some() && job.reported {
324                excess -= 1;
325                false
326            } else {
327                true
328            }
329        });
330    }
331    if let Some(finished) = &jobs.finished {
332        let _ = finished.send(());
333    }
334}
335
336impl Job {
337    fn describe(&mut self) -> Value {
338        if let Some(line) = self.progress.take() {
339            self.last_progress = Some(line);
340        }
341        let mut value = Map::new();
342        value.insert("job".into(), self.id.clone().into());
343        value.insert("tool".into(), self.tool.clone().into());
344        match &self.outcome {
345            None => {
346                value.insert("status".into(), "running".into());
347                value.insert(
348                    "elapsed_seconds".into(),
349                    self.started.elapsed().as_secs().into(),
350                );
351                if let Some(progress) = &self.last_progress {
352                    value.insert("progress".into(), progress.clone().into());
353                }
354            }
355            Some(outcome) => {
356                self.reported = true;
357                let result = result_value(&outcome.output);
358                let status = if self.cancelled {
359                    "cancelled".to_owned()
360                } else {
361                    job_status(&outcome.output, &result)
362                };
363                value.insert("status".into(), status.into());
364                value.insert("elapsed_seconds".into(), outcome.elapsed.as_secs().into());
365                value.insert("result".into(), result);
366            }
367        }
368        Value::Object(value)
369    }
370}
371
372/// The agent tool's structured result, or its text when it was not JSON.
373fn result_value(output: &ToolOutput) -> Value {
374    match serde_json::from_str::<Value>(&output.content) {
375        Ok(value @ Value::Object(_)) => value,
376        _ => json!({ "reply": output.content, "is_error": output.is_error }),
377    }
378}
379
380fn job_status(output: &ToolOutput, result: &Value) -> String {
381    result
382        .get("status")
383        .and_then(Value::as_str)
384        .map(str::to_owned)
385        .unwrap_or_else(|| {
386            if output.is_error {
387                "failed".into()
388            } else {
389                "completed".into()
390            }
391        })
392}
393
394fn unknown_job(job: &str) -> ToolError {
395    ToolError(format!(
396        "unknown background job {:?}; agent_status lists this session's jobs",
397        bounded(job, 64)
398    ))
399}
400
401/// The server-started turn that reports finished jobs to the model.
402pub fn report_prompt(reports: &[JobReport]) -> String {
403    let mut prompt = String::from(
404        "[SCV background report] Delegated work you started in the background has \
405         finished. The user did not send this message: tell them briefly what \
406         happened and the key result.\n",
407    );
408    for report in reports {
409        prompt.push_str(&format!(
410            "\n{} ({}{}): {}\n{}\n",
411            report.job,
412            report.tool,
413            report
414                .session
415                .as_deref()
416                .map_or_else(String::new, |session| format!(", conversation {session}")),
417            report.status,
418            report.reply.trim()
419        ));
420    }
421    prompt
422}
423
424/// An agent tool that can also run in the background.
425pub(crate) struct BackgroundCapable {
426    pub(crate) inner: Arc<dyn Tool>,
427    pub(crate) jobs: Arc<BackgroundJobs>,
428}
429
430/// Split `background` off an agent call's arguments.
431fn split_background(arguments: &Value) -> (Value, bool) {
432    let mut arguments = arguments.clone();
433    let background = arguments
434        .as_object_mut()
435        .and_then(|object| object.remove("background"))
436        .is_some_and(|value| value.as_bool() == Some(true));
437    (arguments, background)
438}
439
440#[async_trait]
441impl Tool for BackgroundCapable {
442    fn spec(&self) -> ToolSpec {
443        let mut spec = self.inner.spec();
444        if let Some(properties) = spec
445            .parameters
446            .get_mut("properties")
447            .and_then(Value::as_object_mut)
448        {
449            properties.insert(
450                "background".into(),
451                json!({
452                    "type":"boolean",
453                    "description":"Run in the background: the call returns a job handle at once, \
454                        the user can keep talking to you while the agent works, and SCV reports \
455                        the result in a new turn when it finishes. Use it for any substantial \
456                        task; run in the foreground only for quick work whose result you need \
457                        within this turn."
458                }),
459            );
460        }
461        spec.description.push_str(&format!(
462            " Set background to true for anything beyond a quick task: the call returns a job \
463             handle at once (at most {} running per session), SCV reports the result when the \
464             job finishes, agent_status shows progress, and agent_cancel stops it. A background \
465             job's own approval requests get only the answer this session would give without \
466             asking a person.",
467            self.jobs.limit()
468        ));
469        spec
470    }
471
472    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
473        self.inner.risk(&split_background(arguments).0)
474    }
475
476    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
477        let (arguments, background) = split_background(arguments);
478        let mut summary = self.inner.approval_summary(&arguments)?;
479        if background {
480            summary.push_str(
481                " Runs in the background: the call returns at once and the result is \
482                 reported when the agent finishes.",
483            );
484        }
485        Ok(summary)
486    }
487
488    async fn execute(
489        &self,
490        arguments: Value,
491        context: ToolContext,
492    ) -> Result<ToolOutput, ToolError> {
493        let (arguments, background) = split_background(&arguments);
494        if !background {
495            return self.inner.execute(arguments, context).await;
496        }
497        // Validate before returning a job handle, so a bad call fails now.
498        self.inner.risk(&arguments)?;
499        let name = self.inner.spec().name;
500        let started =
501            self.jobs
502                .start(Arc::clone(&self.inner), &name, arguments, context.workspace)?;
503        Ok(ToolOutput::success(started.to_string()))
504    }
505}
506
507#[derive(Deserialize)]
508#[serde(deny_unknown_fields)]
509struct WaitArgs {
510    job: String,
511    timeout_seconds: Option<u64>,
512}
513
514#[derive(Deserialize)]
515#[serde(deny_unknown_fields)]
516struct StatusArgs {
517    job: Option<String>,
518}
519
520/// `agent_wait`: block until a background job finishes, or the timeout.
521pub(crate) struct WaitTool {
522    pub(crate) jobs: Arc<BackgroundJobs>,
523    pub(crate) timeouts: Timeouts,
524}
525
526#[async_trait]
527impl Tool for WaitTool {
528    fn spec(&self) -> ToolSpec {
529        let mut timeout = timeout_schema(self.timeouts);
530        timeout["description"] = format!(
531            "Seconds to wait before returning the job still running. Defaults to {}; at most {}.",
532            self.timeouts.default.min(self.timeouts.max).as_secs(),
533            self.timeouts.max.as_secs()
534        )
535        .into();
536        ToolSpec {
537            name: "agent_wait".into(),
538            description: "Wait for a background agent job (the `job` handle an agent_* call with \
539                background: true returned) to finish, and return its result. Returns early with \
540                status running when the timeout passes. Waiting holds your turn open, so the \
541                user cannot reach you meanwhile; usually let SCV report the result instead."
542                .into(),
543            parameters: json!({
544                "type":"object",
545                "properties":{"job":{"type":"string"},"timeout_seconds":timeout},
546                "required":["job"],
547                "additionalProperties":false
548            }),
549        }
550    }
551
552    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
553        let args: WaitArgs = parse_args(arguments)?;
554        self.timeouts.resolve(args.timeout_seconds)?;
555        Ok(ToolRisk::ReadOnly)
556    }
557
558    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
559        let args: WaitArgs = parse_args(arguments)?;
560        Ok(format!(
561            "Wait for background job {}",
562            bounded(&args.job, 64)
563        ))
564    }
565
566    async fn execute(
567        &self,
568        arguments: Value,
569        context: ToolContext,
570    ) -> Result<ToolOutput, ToolError> {
571        let args: WaitArgs = parse_args(&arguments)?;
572        let limit = self.timeouts.resolve(args.timeout_seconds)?;
573        let value = self
574            .jobs
575            .wait(&args.job, limit, &context.cancellation)
576            .await?;
577        Ok(ToolOutput::success(value.to_string()))
578    }
579}
580
581/// `agent_status`: describe one background job or all of them.
582pub(crate) struct StatusTool {
583    pub(crate) jobs: Arc<BackgroundJobs>,
584}
585
586#[async_trait]
587impl Tool for StatusTool {
588    fn spec(&self) -> ToolSpec {
589        ToolSpec {
590            name: "agent_status".into(),
591            description: "Show this session's background agent jobs: running ones with their \
592                latest progress, finished ones with their result. Pass job for one job."
593                .into(),
594            parameters: json!({
595                "type":"object",
596                "properties":{"job":{"type":"string"}},
597                "additionalProperties":false
598            }),
599        }
600    }
601
602    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
603        let _: StatusArgs = parse_args(arguments)?;
604        Ok(ToolRisk::ReadOnly)
605    }
606
607    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
608        let args: StatusArgs = parse_args(arguments)?;
609        Ok(args.job.map_or_else(
610            || "List background jobs".into(),
611            |job| format!("Show background job {}", bounded(&job, 64)),
612        ))
613    }
614
615    async fn execute(
616        &self,
617        arguments: Value,
618        _context: ToolContext,
619    ) -> Result<ToolOutput, ToolError> {
620        let args: StatusArgs = parse_args(&arguments)?;
621        let value = self.jobs.describe(args.job.as_deref())?;
622        Ok(ToolOutput::success(value.to_string()))
623    }
624}
625
626#[derive(Deserialize)]
627#[serde(deny_unknown_fields)]
628struct CancelArgs {
629    job: String,
630}
631
632/// `agent_cancel`: stop one running background job.
633pub(crate) struct CancelTool {
634    pub(crate) jobs: Arc<BackgroundJobs>,
635}
636
637#[async_trait]
638impl Tool for CancelTool {
639    fn spec(&self) -> ToolSpec {
640        ToolSpec {
641            name: "agent_cancel".into(),
642            description: "Stop a running background agent job (the `job` handle an agent_* call \
643                with background: true returned), for example when the user no longer wants \
644                it. The agent and every process it started are stopped; work it already wrote \
645                stays. Returns the job with status cancelled, and no report turn follows."
646                .into(),
647            parameters: json!({
648                "type":"object",
649                "properties":{"job":{"type":"string"}},
650                "required":["job"],
651                "additionalProperties":false
652            }),
653        }
654    }
655
656    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
657        let _: CancelArgs = parse_args(arguments)?;
658        Ok(ToolRisk::Process)
659    }
660
661    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
662        let args: CancelArgs = parse_args(arguments)?;
663        Ok(format!("Stop background job {}", bounded(&args.job, 64)))
664    }
665
666    async fn execute(
667        &self,
668        arguments: Value,
669        _context: ToolContext,
670    ) -> Result<ToolOutput, ToolError> {
671        let args: CancelArgs = parse_args(&arguments)?;
672        let value = self.jobs.cancel(&args.job).await?;
673        Ok(ToolOutput::success(value.to_string()))
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use std::sync::atomic::{AtomicBool, Ordering};
681    use tokio::sync::Notify;
682
683    /// An agent that reports one progress line, then finishes when released
684    /// or stops when cancelled.
685    struct FakeAgent {
686        release: Arc<Notify>,
687        cancelled: Arc<AtomicBool>,
688    }
689
690    #[async_trait]
691    impl Tool for FakeAgent {
692        fn spec(&self) -> ToolSpec {
693            ToolSpec {
694                name: "agent_fake".into(),
695                description: "Fake agent.".into(),
696                parameters: json!({
697                    "type":"object",
698                    "properties":{"prompt":{"type":"string"}},
699                    "required":["prompt"],
700                    "additionalProperties":false
701                }),
702            }
703        }
704
705        fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
706            if arguments.get("prompt").and_then(Value::as_str).is_none() {
707                return Err(ToolError("prompt is required".into()));
708            }
709            Ok(ToolRisk::Delegate)
710        }
711
712        fn approval_summary(&self, _arguments: &Value) -> Result<String, ToolError> {
713            Ok("Launch the fake agent.".into())
714        }
715
716        async fn execute(
717            &self,
718            _arguments: Value,
719            context: ToolContext,
720        ) -> Result<ToolOutput, ToolError> {
721            context.progress.report("$ step one");
722            tokio::select! {
723                _ = self.release.notified() => Ok(ToolOutput::success(
724                    json!({"agent":"fake","status":"completed","reply":"all done","session":"fake-1"})
725                        .to_string(),
726                )),
727                _ = context.cancellation.cancelled() => {
728                    self.cancelled.store(true, Ordering::SeqCst);
729                    Err(ToolError("cancelled".into()))
730                }
731            }
732        }
733    }
734
735    struct Fixture {
736        tool: BackgroundCapable,
737        jobs: Arc<BackgroundJobs>,
738        release: Arc<Notify>,
739        cancelled: Arc<AtomicBool>,
740        finished: mpsc::UnboundedReceiver<()>,
741    }
742
743    fn fixture(limit: usize) -> Fixture {
744        let (finished_tx, finished) = mpsc::unbounded_channel();
745        let jobs = Arc::new(BackgroundJobs::new(limit, Some(finished_tx)));
746        let release = Arc::new(Notify::new());
747        let cancelled = Arc::new(AtomicBool::new(false));
748        let tool = BackgroundCapable {
749            inner: Arc::new(FakeAgent {
750                release: Arc::clone(&release),
751                cancelled: Arc::clone(&cancelled),
752            }),
753            jobs: Arc::clone(&jobs),
754        };
755        Fixture {
756            tool,
757            jobs,
758            release,
759            cancelled,
760            finished,
761        }
762    }
763
764    fn context() -> ToolContext {
765        ToolContext::new(std::env::temp_dir(), CancellationToken::new())
766    }
767
768    async fn start(tool: &BackgroundCapable) -> Value {
769        let output = tool
770            .execute(json!({"prompt":"work","background":true}), context())
771            .await
772            .unwrap();
773        serde_json::from_str(&output.content).unwrap()
774    }
775
776    #[tokio::test]
777    async fn background_calls_return_a_job_that_wait_and_status_observe() {
778        let mut fixture = fixture(2);
779        let started = start(&fixture.tool).await;
780        assert_eq!(
781            started,
782            json!({
783                "job":"job-1","tool":"agent_fake","status":"running","background":true,
784                "note":started["note"]
785            })
786        );
787        assert_eq!(
788            scv_protocol::background_job_update(&started.to_string()).started,
789            vec!["job-1".to_owned()]
790        );
791        // Running, with the job's latest progress.
792        let status = tokio::time::timeout(Duration::from_secs(5), async {
793            loop {
794                let status = fixture.jobs.describe(Some("job-1")).unwrap();
795                if status.get("progress").is_some() {
796                    break status;
797                }
798                tokio::time::sleep(Duration::from_millis(10)).await;
799            }
800        })
801        .await
802        .unwrap();
803        assert_eq!(status["status"], "running");
804        assert_eq!(status["progress"], "$ step one");
805        // A short wait returns it still running.
806        let waited = fixture
807            .jobs
808            .wait(
809                "job-1",
810                Duration::from_millis(50),
811                &CancellationToken::new(),
812            )
813            .await
814            .unwrap();
815        assert_eq!(waited["status"], "running");
816
817        fixture.release.notify_one();
818        let waited = fixture
819            .jobs
820            .wait("job-1", Duration::from_secs(5), &CancellationToken::new())
821            .await
822            .unwrap();
823        assert_eq!(waited["status"], "completed");
824        assert_eq!(waited["result"]["reply"], "all done");
825        assert_eq!(waited["result"]["session"], "fake-1");
826        assert_eq!(
827            scv_protocol::background_job_update(&waited.to_string()).settled,
828            vec!["job-1".to_owned()]
829        );
830        // The session was woken, but the model already saw the result.
831        fixture.finished.recv().await.unwrap();
832        assert!(fixture.jobs.take_unreported().is_empty());
833        let all = fixture.jobs.describe(None).unwrap();
834        assert_eq!(all["jobs"][0]["job"], "job-1");
835    }
836
837    #[tokio::test]
838    async fn a_finished_job_nobody_looked_at_is_reported_once() {
839        let mut fixture = fixture(2);
840        start(&fixture.tool).await;
841        fixture.release.notify_one();
842        tokio::time::timeout(Duration::from_secs(5), fixture.finished.recv())
843            .await
844            .unwrap()
845            .unwrap();
846        let reports = fixture.jobs.take_unreported();
847        assert_eq!(reports.len(), 1);
848        let report = &reports[0];
849        assert_eq!(
850            (
851                report.job.as_str(),
852                report.tool.as_str(),
853                report.status.as_str(),
854                report.session.as_deref(),
855                report.reply.as_str()
856            ),
857            (
858                "job-1",
859                "agent_fake",
860                "completed",
861                Some("fake-1"),
862                "all done"
863            )
864        );
865        let prompt = report_prompt(&reports);
866        assert!(prompt.starts_with("[SCV background report]"), "{prompt}");
867        assert!(
868            prompt.contains("job-1 (agent_fake, conversation fake-1): completed\nall done"),
869            "{prompt}"
870        );
871        assert!(fixture.jobs.take_unreported().is_empty(), "reported twice");
872    }
873
874    #[tokio::test]
875    async fn at_most_the_limit_runs_at_once() {
876        let mut fixture = fixture(1);
877        start(&fixture.tool).await;
878        let refused = fixture
879            .tool
880            .execute(json!({"prompt":"more","background":true}), context())
881            .await
882            .unwrap_err();
883        assert!(refused.0.contains("agent.max_background"), "{refused}");
884        fixture.release.notify_one();
885        fixture.finished.recv().await.unwrap();
886        assert_eq!(start(&fixture.tool).await["job"], "job-2");
887        assert_eq!(fixture.jobs.running(), 1);
888    }
889
890    #[tokio::test]
891    async fn dropping_the_session_store_cancels_running_jobs() {
892        let fixture = fixture(2);
893        start(&fixture.tool).await;
894        let cancelled = Arc::clone(&fixture.cancelled);
895        drop(fixture);
896        tokio::time::timeout(Duration::from_secs(5), async {
897            while !cancelled.load(Ordering::SeqCst) {
898                tokio::time::sleep(Duration::from_millis(10)).await;
899            }
900        })
901        .await
902        .expect("the job was cancelled with its session");
903    }
904
905    #[tokio::test]
906    async fn foreground_calls_pass_through_and_the_schema_offers_background() {
907        let fixture = fixture(2);
908        let spec = fixture.tool.spec();
909        assert_eq!(spec.name, "agent_fake");
910        assert_eq!(
911            spec.parameters["properties"]["background"]["type"],
912            "boolean"
913        );
914        assert!(
915            spec.description.contains("at most 2 running"),
916            "{}",
917            spec.description
918        );
919        let summary = fixture
920            .tool
921            .approval_summary(&json!({"prompt":"work","background":true}))
922            .unwrap();
923        assert!(summary.contains("Runs in the background"), "{summary}");
924        assert!(
925            !fixture
926                .tool
927                .approval_summary(&json!({"prompt":"work"}))
928                .unwrap()
929                .contains("background")
930        );
931        // A bad call fails at once instead of returning a job.
932        assert!(
933            fixture
934                .tool
935                .execute(json!({"background":true}), context())
936                .await
937                .is_err()
938        );
939        fixture.release.notify_one();
940        let output = fixture
941            .tool
942            .execute(json!({"prompt":"work","background":false}), context())
943            .await
944            .unwrap();
945        assert!(output.content.contains("all done"));
946        assert_eq!(fixture.jobs.running(), 0);
947        assert!(fixture.jobs.describe(Some("job-1")).is_err());
948    }
949
950    #[tokio::test]
951    async fn agent_cancel_stops_a_running_job_without_a_report() {
952        let mut fixture = fixture(2);
953        start(&fixture.tool).await;
954        let cancel = CancelTool {
955            jobs: Arc::clone(&fixture.jobs),
956        };
957        assert_eq!(
958            cancel.risk(&json!({"job":"job-1"})).unwrap(),
959            ToolRisk::Process
960        );
961        assert!(cancel.risk(&json!({})).is_err());
962        let output = cancel
963            .execute(json!({"job":"job-1"}), context())
964            .await
965            .unwrap();
966        let stopped: Value = serde_json::from_str(&output.content).unwrap();
967        assert_eq!(stopped["status"], "cancelled");
968        assert!(fixture.cancelled.load(Ordering::SeqCst), "the agent saw it");
969        assert_eq!(
970            scv_protocol::background_job_update(&output.content).settled,
971            vec!["job-1".to_owned()]
972        );
973        // The session is woken, but the model asked for the stop.
974        fixture.finished.recv().await.unwrap();
975        assert!(fixture.jobs.take_unreported().is_empty());
976        assert_eq!(fixture.jobs.running(), 0);
977        let again = cancel
978            .execute(json!({"job":"job-1"}), context())
979            .await
980            .unwrap();
981        assert!(
982            again.content.contains("already finished"),
983            "{}",
984            again.content
985        );
986        let unknown = cancel
987            .execute(json!({"job":"job-9"}), context())
988            .await
989            .unwrap_err();
990        assert!(unknown.0.contains("unknown background job"), "{unknown}");
991        // The freed slot takes a new job.
992        assert_eq!(start(&fixture.tool).await["job"], "job-2");
993    }
994
995    /// An agent that asks its session to approve one nested command and
996    /// replies with the answer.
997    struct AskingAgent;
998
999    #[async_trait]
1000    impl Tool for AskingAgent {
1001        fn spec(&self) -> ToolSpec {
1002            ToolSpec {
1003                name: "agent_asking".into(),
1004                description: "Asking agent.".into(),
1005                parameters: json!({"type":"object","properties":{}}),
1006            }
1007        }
1008
1009        fn risk(&self, _arguments: &Value) -> Result<ToolRisk, ToolError> {
1010            Ok(ToolRisk::Delegate)
1011        }
1012
1013        fn approval_summary(&self, _arguments: &Value) -> Result<String, ToolError> {
1014            Ok("Launch the asking agent.".into())
1015        }
1016
1017        async fn execute(
1018            &self,
1019            _arguments: Value,
1020            context: ToolContext,
1021        ) -> Result<ToolOutput, ToolError> {
1022            let approved = context
1023                .approvals
1024                .request(
1025                    "bash",
1026                    ToolRisk::Process,
1027                    context.workspace.clone(),
1028                    "cargo test",
1029                    context.cancellation.clone(),
1030                )
1031                .await
1032                .map_err(|error| ToolError(error.to_string()))?;
1033            Ok(ToolOutput::success(
1034                json!({"status":"completed","reply":if approved {"approved"} else {"denied"}})
1035                    .to_string(),
1036            ))
1037        }
1038    }
1039
1040    struct FixedGate {
1041        approve: bool,
1042        seen: Mutex<Vec<(String, ToolRisk)>>,
1043    }
1044
1045    #[async_trait]
1046    impl ApprovalGate for FixedGate {
1047        async fn approve(
1048            &self,
1049            request: scv_core::ApprovalRequest,
1050            _cancellation: CancellationToken,
1051        ) -> Result<bool, scv_core::AgentError> {
1052            self.seen
1053                .lock()
1054                .unwrap()
1055                .push((request.call_id, request.risk));
1056            Ok(self.approve)
1057        }
1058    }
1059
1060    async fn nested_answer(gate: Option<Arc<dyn ApprovalGate>>) -> String {
1061        let (finished_tx, mut finished) = mpsc::unbounded_channel();
1062        let mut jobs = BackgroundJobs::new(2, Some(finished_tx));
1063        if let Some(gate) = gate {
1064            jobs = jobs.with_approvals(gate);
1065        }
1066        let jobs = Arc::new(jobs);
1067        let tool = BackgroundCapable {
1068            inner: Arc::new(AskingAgent),
1069            jobs: Arc::clone(&jobs),
1070        };
1071        tool.execute(json!({"background":true}), context())
1072            .await
1073            .unwrap();
1074        tokio::time::timeout(Duration::from_secs(5), finished.recv())
1075            .await
1076            .unwrap()
1077            .unwrap();
1078        jobs.take_unreported().remove(0).reply
1079    }
1080
1081    #[tokio::test]
1082    async fn background_approvals_follow_the_session_gate_or_are_denied() {
1083        // Without a gate nobody can answer, so the request is denied.
1084        assert_eq!(nested_answer(None).await, "denied");
1085        for approve in [true, false] {
1086            let gate = Arc::new(FixedGate {
1087                approve,
1088                seen: Mutex::default(),
1089            });
1090            let expected = if approve { "approved" } else { "denied" };
1091            assert_eq!(
1092                nested_answer(Some(Arc::clone(&gate) as Arc<dyn ApprovalGate>)).await,
1093                expected
1094            );
1095            // The gate saw the nested call's risk, filed under the job.
1096            assert_eq!(
1097                *gate.seen.lock().unwrap(),
1098                [("job-1".to_owned(), ToolRisk::Process)]
1099            );
1100        }
1101    }
1102
1103    #[tokio::test]
1104    async fn wait_and_status_tools_validate_and_are_read_only() {
1105        let fixture = fixture(2);
1106        let wait = WaitTool {
1107            jobs: Arc::clone(&fixture.jobs),
1108            timeouts: Timeouts {
1109                default: Duration::from_secs(60),
1110                max: Duration::from_secs(120),
1111            },
1112        };
1113        assert_eq!(
1114            wait.risk(&json!({"job":"job-1"})).unwrap(),
1115            ToolRisk::ReadOnly
1116        );
1117        assert!(
1118            wait.risk(&json!({"job":"job-1","timeout_seconds":121}))
1119                .is_err()
1120        );
1121        let unknown = wait
1122            .execute(json!({"job":"job-9"}), context())
1123            .await
1124            .unwrap_err();
1125        assert!(unknown.0.contains("unknown background job"), "{unknown}");
1126        let status = StatusTool {
1127            jobs: Arc::clone(&fixture.jobs),
1128        };
1129        assert_eq!(status.risk(&json!({})).unwrap(), ToolRisk::ReadOnly);
1130        let empty = status.execute(json!({}), context()).await.unwrap();
1131        assert_eq!(empty.content, json!({"jobs":[]}).to_string());
1132    }
1133}