1use 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
36const MAX_FINISHED: usize = 16;
38const REPORT_REPLY_CHARS: usize = 6000;
40const REPORT_MAX_JOBS: usize = 4;
42const CANCEL_SETTLE: Duration = Duration::from_secs(10);
44const MAX_PENDING_CHANGES: usize = 64;
47const TASK_CHARS: usize = 80;
49
50pub struct BackgroundJobs {
53 limit: usize,
54 state: Mutex<JobsState>,
55 cancellation: CancellationToken,
56 finished: Option<mpsc::UnboundedSender<()>>,
58 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: Vec<(String, JobChange)>,
78}
79
80impl JobsState {
81 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 tool: String,
98 agent: String,
100 task: String,
102 started: Instant,
103 progress: ProgressSink,
104 last_progress: Option<String>,
105 cancel: CancellationToken,
107 cancelled: bool,
109 outcome: Option<Outcome>,
110 reported: bool,
114 done: watch::Receiver<bool>,
115}
116
117struct Outcome {
118 output: ToolOutput,
119 elapsed: Duration,
120}
121
122#[derive(Debug, Clone)]
124pub struct JobReport {
125 pub job: String,
126 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 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 #[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 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 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 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 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 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 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 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 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 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 job.reported |= job.cancelled;
417 }
418 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 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 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
495fn 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
503fn 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
513fn 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
539pub 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
562pub(crate) struct BackgroundCapable {
564 pub(crate) inner: Arc<AgentTool>,
565 pub(crate) jobs: Arc<BackgroundJobs>,
566}
567
568fn 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 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
664pub(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
725pub(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
776pub(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;