use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgentSubmit {
pub v: u32,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::encoding::opt_bin_bytes"
)]
pub input: Option<Vec<u8>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgentCancel {
pub v: u32,
pub run_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgentStatusReq {
pub v: u32,
pub run_id: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AgentList {
pub v: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<AgentRunState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::encoding::opt_bin_bytes"
)]
pub cursor: Option<Vec<u8>>,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum AgentRunState {
#[default]
Submitted,
Running,
Completed,
Cancelled,
Failed,
}
impl AgentRunState {
pub fn as_str(self) -> &'static str {
self.into()
}
pub fn is_terminal(self) -> bool {
matches!(
self,
AgentRunState::Completed | AgentRunState::Cancelled | AgentRunState::Failed
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentRunInfo {
pub run_id: String,
pub agent_id: String,
pub user_id: u32,
pub state: AgentRunState,
pub created_at_micros: u64,
pub updated_at_micros: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cancel_requested: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RunPage {
pub runs: Vec<AgentRunInfo>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::encoding::opt_bin_bytes"
)]
pub cursor: Option<Vec<u8>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AgentReply {
Ok(AgentOutcome),
Err(AgentError),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AgentOutcome {
Submitted(AgentRunInfo),
Cancelled(AgentRunInfo),
Status(AgentRunInfo),
List(RunPage),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[non_exhaustive]
pub enum AgentError {
#[error("agent ops not supported: {0}")]
Unsupported(String),
#[error("run not found: {0}")]
NotFound(String),
#[error("invalid agent request: {0}")]
Invalid(String),
#[error("agent backend error: {0}")]
Backend(String),
#[error("unsupported agent op version (expected {expected}, got {got})")]
Version { expected: u32, got: u32 },
}
#[cfg(all(test, feature = "cbor"))]
mod tests {
use super::*;
use crate::codes::AGENT_WORKFLOW_OP_VERSION;
use crate::framing::{decode_named, encode_named};
#[test]
fn given_a_submit_when_round_tripped_then_should_decode_unchanged() {
let submit = AgentSubmit {
v: AGENT_WORKFLOW_OP_VERSION,
agent_id: "diagnoser".to_owned(),
run_id: Some("run-7".to_owned()),
params: BTreeMap::from([("priority".to_owned(), "high".to_owned())]),
input: Some(br#"{"incident":"INC-7"}"#.to_vec()),
};
let bytes = encode_named(&submit).expect("encodes");
let back: AgentSubmit = decode_named(&bytes).expect("decodes");
assert_eq!(back.agent_id, submit.agent_id);
assert_eq!(back.run_id, submit.run_id);
assert_eq!(back.input, submit.input);
}
#[test]
fn given_a_reply_when_round_tripped_then_should_preserve_the_variant() {
let reply = AgentReply::Ok(AgentOutcome::Status(AgentRunInfo {
run_id: "run-7".to_owned(),
agent_id: "diagnoser".to_owned(),
user_id: 42,
state: AgentRunState::Running,
created_at_micros: 1_717_171_717_000_000,
updated_at_micros: 1_717_171_718_000_000,
detail: None,
cancel_requested: false,
}));
let bytes = encode_named(&reply).expect("encodes");
let back: AgentReply = decode_named(&bytes).expect("decodes");
assert!(matches!(
back,
AgentReply::Ok(AgentOutcome::Status(info)) if info.run_id == "run-7"
));
}
#[test]
fn given_a_filtered_list_when_round_tripped_then_should_keep_filters_and_cursor() {
let list = AgentList {
v: AGENT_WORKFLOW_OP_VERSION,
agent_id: Some("diagnoser".to_owned()),
state: Some(AgentRunState::Running),
limit: Some(25),
cursor: Some(vec![0x01, 0x02]),
};
let bytes = encode_named(&list).expect("encodes");
let back: AgentList = decode_named(&bytes).expect("decodes");
assert_eq!(back.agent_id, list.agent_id);
assert_eq!(back.state, list.state);
assert_eq!(back.limit, list.limit);
assert_eq!(back.cursor, list.cursor);
}
#[test]
fn given_run_state_words_when_parsed_then_should_round_trip_through_display() {
for state in [
AgentRunState::Submitted,
AgentRunState::Running,
AgentRunState::Completed,
AgentRunState::Cancelled,
AgentRunState::Failed,
] {
let parsed: AgentRunState = state.as_str().parse().expect("pinned word parses");
assert_eq!(parsed, state);
}
assert!("paused".parse::<AgentRunState>().is_err());
}
#[test]
fn given_an_empty_list_request_when_encoded_then_should_skip_absent_fields() {
let bare = AgentList {
v: AGENT_WORKFLOW_OP_VERSION,
..AgentList::default()
};
let bytes = encode_named(&bare).expect("encodes");
let back: AgentList = decode_named(&bytes).expect("decodes");
assert!(back.agent_id.is_none());
assert!(back.state.is_none());
assert!(back.limit.is_none());
assert!(back.cursor.is_none());
}
}