scv_protocol/background.rs
1//! Background delegation jobs, as clients see them.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::review::{JobOutcome, describe_outcome};
8
9/// Why the server started a turn on its own.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct TurnOrigin {
12 /// What the turn is for.
13 pub kind: OriginKind,
14 /// The background jobs this turn reports. Its end settles them: the
15 /// model has seen their results when it completes, the user stopped the
16 /// report when it is cancelled, and when it fails they are reported
17 /// directly ([`ServerEvent::BackgroundReported`](crate::ServerEvent::BackgroundReported),
18 /// sent first), unless `retry_seconds` is set.
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub jobs: Vec<String>,
21 /// On a report turn's `turn.failed` only: its jobs are still unreported,
22 /// and the server starts another report turn for them in about this many
23 /// seconds, or as soon as another turn succeeds. Absent everywhere else,
24 /// and from servers before 0.3.11, whose failed report turns settled their
25 /// jobs.
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub retry_seconds: Option<u64>,
28 /// The outcomes of the reviewed jobs among `jobs`, which clients show
29 /// as SCV's own lines before the model's report. Absent from servers
30 /// before 0.3.14.
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub outcomes: Vec<JobOutcome>,
33}
34
35/// What a server-started turn is for.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum OriginKind {
40 /// Finished background delegations are being reported.
41 Background,
42 /// A kind this client does not know, from a newer server.
43 #[serde(other)]
44 Unknown,
45}
46
47/// Where a background job stands, with the strings the model reads in the
48/// job tools' results.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51#[non_exhaustive]
52pub enum JobStatus {
53 /// Still working.
54 Running,
55 /// The agent finished its task.
56 Completed,
57 /// The agent or its run failed.
58 Failed,
59 /// The agent's model refused the request.
60 Declined,
61 /// The run reached its time limit.
62 Timeout,
63 /// Stopped on request.
64 Cancelled,
65 /// A status this client does not know, from a newer server.
66 #[serde(other)]
67 Unknown,
68}
69
70impl JobStatus {
71 /// The status as the model reads it, such as `completed`.
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Running => "running",
75 Self::Completed => "completed",
76 Self::Failed => "failed",
77 Self::Declined => "declined",
78 Self::Timeout => "timeout",
79 Self::Cancelled => "cancelled",
80 Self::Unknown => "unknown",
81 }
82 }
83}
84
85impl fmt::Display for JobStatus {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter.write_str(self.as_str())
88 }
89}
90
91/// A background job a tool call started, or whose result the call showed
92/// the model (`tool.completed.jobs`). A session's clients keep it open while
93/// its jobs run, since closing the session cancels them.
94///
95/// A job appears once as `running`, from the `agent` call that started it,
96/// and once more with how it ended, from the `agent_wait`, `agent_status`, or
97/// `agent_cancel` call through which the model saw that result. A job whose
98/// result the model sees in a report turn is settled by that turn's
99/// [`TurnOrigin::jobs`] instead.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct JobChange {
102 /// The job's handle, such as `job-1`.
103 pub job: String,
104 /// The delegating tool: `agent`, or `agent_<name>` from SCV 0.3.0 and
105 /// older, which had one tool per agent.
106 pub tool: String,
107 /// The agent that runs the job, such as `codex`; empty from SCV 0.3.0
108 /// and older. [`JobChange::agent_name`] reads either form.
109 #[serde(default, skip_serializing_if = "String::is_empty")]
110 pub agent: String,
111 /// `running` when the call started it; otherwise how it ended.
112 pub status: JobStatus,
113 /// The first line of the delegated prompt, shortened; empty when unknown.
114 #[serde(default, skip_serializing_if = "String::is_empty")]
115 pub task: String,
116 /// On a reviewed job's start: its review journal's ID, such as
117 /// `rev-1759961234-3fa9c1`.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub journal: Option<String>,
120 /// On the change that settles a reviewed job: its outcome, which clients
121 /// show as SCV's own lines.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub outcome: Option<JobOutcome>,
124}
125
126impl JobChange {
127 /// Whether the call started the job, rather than settled it.
128 pub fn started(&self) -> bool {
129 self.status == JobStatus::Running
130 }
131
132 /// The agent that runs the job, such as `codex`, from either form.
133 pub fn agent_name(&self) -> &str {
134 job_agent(&self.agent, &self.tool)
135 }
136}
137
138/// A finished background job's result, as the server reports it: to the
139/// model in a report turn's prompt, or to the client in
140/// [`ServerEvent::BackgroundReported`](crate::ServerEvent::BackgroundReported)
141/// when the model could not report it.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct JobReport {
144 /// The job's handle, such as `job-1`.
145 pub job: String,
146 /// The agent that ran it, such as `codex`.
147 pub agent: String,
148 /// The first line of the delegated prompt, shortened; empty when unknown.
149 #[serde(default, skip_serializing_if = "String::is_empty")]
150 pub task: String,
151 /// How it ended.
152 pub status: JobStatus,
153 /// The agent conversation it ran in, which a later `agent` call may
154 /// continue.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub session: Option<String>,
157 /// The agent's reply, bounded: untrusted delegated-agent output.
158 pub reply: String,
159 /// A reviewed job's outcome, which SCV states in its own words.
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub outcome: Option<JobOutcome>,
162}
163
164/// `reports` as people and the model read them: for each job, a line naming
165/// it, its agent, conversation, and status, then its task, a reviewed job's
166/// outcome lines ([`describe_outcome`]), and its reply.
167pub fn describe_reports(reports: &[JobReport]) -> String {
168 let mut text = String::new();
169 for report in reports {
170 if !text.is_empty() {
171 text.push('\n');
172 }
173 text.push_str(&format!("{} ({}", report.job, report.agent));
174 if let Some(session) = &report.session {
175 text.push_str(&format!(", conversation {session}"));
176 }
177 text.push_str(&format!("): {}\n", report.status));
178 if !report.task.is_empty() {
179 text.push_str(&format!("Task: {}\n", report.task));
180 }
181 if let Some(outcome) = &report.outcome {
182 text.push_str(&describe_outcome(outcome));
183 }
184 let reply = report.reply.trim();
185 if !reply.is_empty() {
186 text.push_str(reply);
187 text.push('\n');
188 }
189 }
190 text
191}
192
193/// The agent a background job runs, such as `codex`: `agent` when it is
194/// set, otherwise what follows `agent_` in the delegating `tool`, as SCV
195/// 0.3.0 and older named it (`agent_codex`), otherwise `tool` itself.
196pub fn job_agent<'a>(agent: &'a str, tool: &'a str) -> &'a str {
197 if agent.is_empty() {
198 tool.strip_prefix("agent_").unwrap_or(tool)
199 } else {
200 agent
201 }
202}