1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Clone, Debug, Serialize, Deserialize)]
8pub struct AgentSubmit {
9 pub v: u32,
10 pub agent_id: String,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub run_id: Option<String>,
13 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
14 pub params: BTreeMap<String, String>,
15 #[serde(
16 default,
17 skip_serializing_if = "Option::is_none",
18 with = "crate::encoding::opt_bin_bytes"
19 )]
20 pub input: Option<Vec<u8>>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub budget: Option<RunBudget>,
23}
24
25#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
29pub struct RunBudget {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub max_events: Option<u64>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub max_model_calls: Option<u64>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub max_tool_calls: Option<u64>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub max_patches: Option<u64>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub max_depth: Option<u32>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub max_wall_clock_micros: Option<u64>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub max_cost_usd: Option<f64>,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct AgentCancel {
49 pub v: u32,
50 pub run_id: String,
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct AgentStatusReq {
56 pub v: u32,
57 pub run_id: String,
58}
59
60#[derive(Clone, Debug, Default, Serialize, Deserialize)]
65pub struct AgentList {
66 pub v: u32,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub agent_id: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub state: Option<AgentRunState>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub limit: Option<u32>,
73 #[serde(
74 default,
75 skip_serializing_if = "Option::is_none",
76 with = "crate::encoding::opt_bin_bytes"
77 )]
78 pub cursor: Option<Vec<u8>>,
79}
80
81#[derive(
86 Clone,
87 Copy,
88 Debug,
89 Default,
90 PartialEq,
91 Eq,
92 Serialize,
93 Deserialize,
94 strum::Display,
95 strum::EnumString,
96 strum::IntoStaticStr,
97 strum::VariantArray,
98)]
99#[serde(rename_all = "snake_case")]
100#[strum(serialize_all = "snake_case")]
101#[non_exhaustive]
102pub enum AgentRunState {
103 #[default]
104 Submitted,
105 Running,
106 Completed,
107 Cancelled,
108 Failed,
109}
110
111impl AgentRunState {
112 pub fn as_str(self) -> &'static str {
115 self.into()
116 }
117
118 pub fn is_terminal(self) -> bool {
121 matches!(
122 self,
123 AgentRunState::Completed | AgentRunState::Cancelled | AgentRunState::Failed
124 )
125 }
126}
127
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
136pub struct AgentRunInfo {
137 pub run_id: String,
138 pub agent_id: String,
139 pub user_id: u32,
140 pub state: AgentRunState,
141 pub created_at_micros: u64,
142 pub updated_at_micros: u64,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub detail: Option<String>,
145 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
146 pub cancel_requested: bool,
147}
148
149#[derive(Clone, Debug, Default, Serialize, Deserialize)]
152pub struct RunPage {
153 pub runs: Vec<AgentRunInfo>,
154 #[serde(
155 default,
156 skip_serializing_if = "Option::is_none",
157 with = "crate::encoding::opt_bin_bytes"
158 )]
159 pub cursor: Option<Vec<u8>>,
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
165#[non_exhaustive]
166pub enum AgentReply {
167 Ok(AgentOutcome),
168 Err(AgentError),
169}
170
171#[derive(Clone, Debug, Serialize, Deserialize)]
173#[non_exhaustive]
174pub enum AgentOutcome {
175 Submitted(AgentRunInfo),
176 Cancelled(AgentRunInfo),
177 Status(AgentRunInfo),
178 List(RunPage),
179}
180
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
183#[non_exhaustive]
184pub enum AgentError {
185 #[error("agent ops not supported: {0}")]
186 Unsupported(String),
187 #[error("run not found: {0}")]
188 NotFound(String),
189 #[error("invalid agent request: {0}")]
190 Invalid(String),
191 #[error("agent backend error: {0}")]
192 Backend(String),
193 #[error("unsupported agent op version (expected {expected}, got {got})")]
194 Version { expected: u32, got: u32 },
195}
196
197#[cfg(all(test, feature = "cbor"))]
198mod tests {
199 use super::*;
200 use crate::codes::AGENT_WORKFLOW_OP_VERSION;
201 use crate::framing::{decode_named, encode_named};
202
203 #[test]
204 fn given_a_submit_when_round_tripped_then_should_decode_unchanged() {
205 let submit = AgentSubmit {
206 v: AGENT_WORKFLOW_OP_VERSION,
207 agent_id: "diagnoser".to_owned(),
208 run_id: Some("run-7".to_owned()),
209 params: BTreeMap::from([("priority".to_owned(), "high".to_owned())]),
210 input: Some(br#"{"incident":"INC-7"}"#.to_vec()),
211 budget: None,
212 };
213 let bytes = encode_named(&submit).expect("encodes");
214 let back: AgentSubmit = decode_named(&bytes).expect("decodes");
215 assert_eq!(back.agent_id, submit.agent_id);
216 assert_eq!(back.run_id, submit.run_id);
217 assert_eq!(back.input, submit.input);
218 }
219
220 #[test]
221 fn given_a_reply_when_round_tripped_then_should_preserve_the_variant() {
222 let reply = AgentReply::Ok(AgentOutcome::Status(AgentRunInfo {
223 run_id: "run-7".to_owned(),
224 agent_id: "diagnoser".to_owned(),
225 user_id: 42,
226 state: AgentRunState::Running,
227 created_at_micros: 1_717_171_717_000_000,
228 updated_at_micros: 1_717_171_718_000_000,
229 detail: None,
230 cancel_requested: false,
231 }));
232 let bytes = encode_named(&reply).expect("encodes");
233 let back: AgentReply = decode_named(&bytes).expect("decodes");
234 assert!(matches!(
235 back,
236 AgentReply::Ok(AgentOutcome::Status(info)) if info.run_id == "run-7"
237 ));
238 }
239
240 #[test]
241 fn given_a_filtered_list_when_round_tripped_then_should_keep_filters_and_cursor() {
242 let list = AgentList {
243 v: AGENT_WORKFLOW_OP_VERSION,
244 agent_id: Some("diagnoser".to_owned()),
245 state: Some(AgentRunState::Running),
246 limit: Some(25),
247 cursor: Some(vec![0x01, 0x02]),
248 };
249 let bytes = encode_named(&list).expect("encodes");
250 let back: AgentList = decode_named(&bytes).expect("decodes");
251 assert_eq!(back.agent_id, list.agent_id);
252 assert_eq!(back.state, list.state);
253 assert_eq!(back.limit, list.limit);
254 assert_eq!(back.cursor, list.cursor);
255 }
256
257 #[test]
258 fn given_run_state_words_when_parsed_then_should_round_trip_through_display() {
259 for state in [
260 AgentRunState::Submitted,
261 AgentRunState::Running,
262 AgentRunState::Completed,
263 AgentRunState::Cancelled,
264 AgentRunState::Failed,
265 ] {
266 let parsed: AgentRunState = state.as_str().parse().expect("pinned word parses");
267 assert_eq!(parsed, state);
268 }
269 assert!("paused".parse::<AgentRunState>().is_err());
270 }
271
272 #[test]
273 fn given_an_empty_list_request_when_encoded_then_should_skip_absent_fields() {
274 let bare = AgentList {
275 v: AGENT_WORKFLOW_OP_VERSION,
276 ..AgentList::default()
277 };
278 let bytes = encode_named(&bare).expect("encodes");
279 let back: AgentList = decode_named(&bytes).expect("decodes");
280 assert!(back.agent_id.is_none());
281 assert!(back.state.is_none());
282 assert!(back.limit.is_none());
283 assert!(back.cursor.is_none());
284 }
285}