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}