1use 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
24const MAX_FINISHED: usize = 16;
26const REPORT_REPLY_CHARS: usize = 6000;
28const REPORT_MAX_JOBS: usize = 4;
30const CANCEL_SETTLE: Duration = Duration::from_secs(10);
32
33pub struct BackgroundJobs {
36 limit: usize,
37 state: Mutex<JobsState>,
38 cancellation: CancellationToken,
39 finished: Option<mpsc::UnboundedSender<()>>,
41 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 cancel: CancellationToken,
69 cancelled: bool,
71 outcome: Option<Outcome>,
72 reported: bool,
75 done: watch::Receiver<bool>,
76}
77
78struct Outcome {
79 output: ToolOutput,
80 elapsed: Duration,
81}
82
83#[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 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 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 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 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 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 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 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 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 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 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 job.reported |= job.cancelled;
314 }
315 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
372fn 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
401pub 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
424pub(crate) struct BackgroundCapable {
426 pub(crate) inner: Arc<dyn Tool>,
427 pub(crate) jobs: Arc<BackgroundJobs>,
428}
429
430fn 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 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
520pub(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
581pub(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
632pub(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 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 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 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 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 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 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 assert_eq!(start(&fixture.tool).await["job"], "job-2");
993 }
994
995 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 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 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}