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