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 #[error("not the partition leader for this run")]
197 NotLeader,
198}
199
200#[cfg(all(test, feature = "cbor"))]
201mod tests {
202 use super::*;
203 use crate::codes::AGENT_WORKFLOW_OP_VERSION;
204 use crate::framing::{decode_named, encode_named};
205
206 #[test]
207 fn given_a_submit_when_round_tripped_then_should_decode_unchanged() {
208 let submit = AgentSubmit {
209 v: AGENT_WORKFLOW_OP_VERSION,
210 agent_id: "diagnoser".to_owned(),
211 run_id: Some("run-7".to_owned()),
212 params: BTreeMap::from([("priority".to_owned(), "high".to_owned())]),
213 input: Some(br#"{"incident":"INC-7"}"#.to_vec()),
214 budget: None,
215 };
216 let bytes = encode_named(&submit).expect("encodes");
217 let back: AgentSubmit = decode_named(&bytes).expect("decodes");
218 assert_eq!(back.agent_id, submit.agent_id);
219 assert_eq!(back.run_id, submit.run_id);
220 assert_eq!(back.input, submit.input);
221 }
222
223 #[test]
224 fn given_a_reply_when_round_tripped_then_should_preserve_the_variant() {
225 let reply = AgentReply::Ok(AgentOutcome::Status(AgentRunInfo {
226 run_id: "run-7".to_owned(),
227 agent_id: "diagnoser".to_owned(),
228 user_id: 42,
229 state: AgentRunState::Running,
230 created_at_micros: 1_717_171_717_000_000,
231 updated_at_micros: 1_717_171_718_000_000,
232 detail: None,
233 cancel_requested: false,
234 }));
235 let bytes = encode_named(&reply).expect("encodes");
236 let back: AgentReply = decode_named(&bytes).expect("decodes");
237 assert!(matches!(
238 back,
239 AgentReply::Ok(AgentOutcome::Status(info)) if info.run_id == "run-7"
240 ));
241 }
242
243 #[test]
244 fn given_a_filtered_list_when_round_tripped_then_should_keep_filters_and_cursor() {
245 let list = AgentList {
246 v: AGENT_WORKFLOW_OP_VERSION,
247 agent_id: Some("diagnoser".to_owned()),
248 state: Some(AgentRunState::Running),
249 limit: Some(25),
250 cursor: Some(vec![0x01, 0x02]),
251 };
252 let bytes = encode_named(&list).expect("encodes");
253 let back: AgentList = decode_named(&bytes).expect("decodes");
254 assert_eq!(back.agent_id, list.agent_id);
255 assert_eq!(back.state, list.state);
256 assert_eq!(back.limit, list.limit);
257 assert_eq!(back.cursor, list.cursor);
258 }
259
260 #[test]
261 fn given_run_state_words_when_parsed_then_should_round_trip_through_display() {
262 for state in [
263 AgentRunState::Submitted,
264 AgentRunState::Running,
265 AgentRunState::Completed,
266 AgentRunState::Cancelled,
267 AgentRunState::Failed,
268 ] {
269 let parsed: AgentRunState = state.as_str().parse().expect("pinned word parses");
270 assert_eq!(parsed, state);
271 }
272 assert!("paused".parse::<AgentRunState>().is_err());
273 }
274
275 #[test]
276 fn given_an_empty_list_request_when_encoded_then_should_skip_absent_fields() {
277 let bare = AgentList {
278 v: AGENT_WORKFLOW_OP_VERSION,
279 ..AgentList::default()
280 };
281 let bytes = encode_named(&bare).expect("encodes");
282 let back: AgentList = decode_named(&bytes).expect("decodes");
283 assert!(back.agent_id.is_none());
284 assert!(back.state.is_none());
285 assert!(back.limit.is_none());
286 assert!(back.cursor.is_none());
287 }
288}