Skip to main content

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}