use serde::{Deserialize, Serialize};
use crate::error::ErrorCode;
use crate::ids::JobId;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentRef {
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AgentRefParseError {
#[error("invalid agent name {0:?}")]
InvalidName(String),
#[error("invalid agent version {0:?}")]
InvalidVersion(String),
}
const fn is_name_head(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9')
}
const fn is_name_tail(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '-')
}
const fn is_version_char(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '+' | '_' | '-')
}
fn validate_name(name: &str) -> Result<(), AgentRefParseError> {
let mut chars = name.chars();
let Some(head) = chars.next() else {
return Err(AgentRefParseError::InvalidName(name.to_owned()));
};
if !is_name_head(head) {
return Err(AgentRefParseError::InvalidName(name.to_owned()));
}
for c in chars {
if !is_name_tail(c) {
return Err(AgentRefParseError::InvalidName(name.to_owned()));
}
}
Ok(())
}
fn validate_version(version: &str) -> Result<(), AgentRefParseError> {
if version.is_empty() {
return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
}
for c in version.chars() {
if !is_version_char(c) {
return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
}
}
Ok(())
}
impl AgentRef {
pub fn parse(input: &str) -> Result<Self, AgentRefParseError> {
if let Some(at) = input.find('@') {
let (name, rest) = input.split_at(at);
let version = &rest[1..];
validate_name(name)?;
validate_version(version)?;
Ok(Self {
name: name.to_owned(),
version: Some(version.to_owned()),
})
} else {
validate_name(input)?;
Ok(Self {
name: input.to_owned(),
version: None,
})
}
}
#[must_use]
pub fn format(&self) -> String {
self.version.as_ref().map_or_else(
|| self.name.clone(),
|v| format!("{name}@{v}", name = self.name),
)
}
}
impl std::fmt::Display for AgentRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.format())
}
}
impl std::str::FromStr for AgentRef {
type Err = AgentRefParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl Serialize for AgentRef {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.format())
}
}
impl<'de> Deserialize<'de> for AgentRef {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
Self::parse(&raw).map_err(D::Error::custom)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod agent_ref_tests {
use super::*;
#[test]
fn parse_bare_name() {
let r = AgentRef::parse("code-refactor").unwrap();
assert_eq!(r.name, "code-refactor");
assert!(r.version.is_none());
}
#[test]
fn parse_name_at_version() {
let r = AgentRef::parse("code-refactor@2.0.0").unwrap();
assert_eq!(r.name, "code-refactor");
assert_eq!(r.version.as_deref(), Some("2.0.0"));
}
#[test]
fn format_round_trips() {
for s in ["a", "a-b", "a@1.0.0", "agent_x@v1.2.3+build.4"] {
let r = AgentRef::parse(s).unwrap();
assert_eq!(r.format(), s);
}
}
#[test]
fn rejects_uppercase_in_name() {
assert!(AgentRef::parse("CodeRefactor").is_err());
assert!(AgentRef::parse("Foo@1").is_err());
}
#[test]
fn rejects_empty_version() {
assert!(AgentRef::parse("ok@").is_err());
}
#[test]
fn serde_round_trip() {
let r = AgentRef::parse("web-research@1.0.0").unwrap();
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, "\"web-research@1.0.0\"");
let back: AgentRef = serde_json::from_str(&json).unwrap();
assert_eq!(back, r);
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolInvokePayload {
pub tool: String,
pub arguments: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_budget: Option<crate::messages::permissions::CostBudget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease_request: Option<crate::messages::permissions::LeaseRequest>,
}
impl ToolInvokePayload {
#[must_use]
pub fn new(tool: impl Into<String>, arguments: serde_json::Value) -> Self {
Self {
tool: tool.into(),
arguments,
cost_budget: None,
lease_request: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResultPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolErrorPayload {
pub code: ErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retryable: Option<bool>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobState {
Accepted,
Queued,
Running,
Blocked,
Paused,
Completed,
Failed,
Cancelled,
}
impl JobState {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
#[must_use]
pub const fn wire_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Queued => "queued",
Self::Running => "running",
Self::Blocked => "blocked",
Self::Paused => "paused",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobAcceptedPayload {
pub job_id: JobId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credentials: Vec<crate::messages::credentials::ProvisionedCredential>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease: Option<crate::messages::permissions::LeaseRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobStartedPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobProgressPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percent: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobHeartbeatPayload {
pub sequence: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
pub state: JobState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobCheckpointPayload {
pub checkpoint_id: String,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobCompletedPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResultChunkEncoding {
Utf8,
Base64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobResultChunkPayload {
pub result_id: String,
pub chunk_seq: u64,
pub data: String,
pub encoding: ResultChunkEncoding,
pub more: bool,
}
#[derive(Debug, Default)]
pub struct ResultChunkAssembler {
result_id: Option<String>,
encoding: Option<ResultChunkEncoding>,
next_seq: u64,
buffer: Vec<u8>,
finished: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResultChunkError {
#[error("result_chunk out of order: expected seq {expected}, got {got}")]
OutOfOrder {
expected: u64,
got: u64,
},
#[error("result_chunk result_id mismatch: expected {expected:?}, got {got:?}")]
ResultIdMismatch {
expected: String,
got: String,
},
#[error("result_chunk encoding mismatch: expected {expected:?}, got {got:?}")]
EncodingMismatch {
expected: ResultChunkEncoding,
got: ResultChunkEncoding,
},
#[error("result_chunk base64 decode failed at seq {seq}")]
Base64Decode {
seq: u64,
},
#[error("result_chunk: chunk pushed after final chunk")]
AfterFinal,
#[error("result_chunk: not yet final")]
NotFinal,
}
impl ResultChunkAssembler {
#[must_use]
pub const fn new() -> Self {
Self {
result_id: None,
encoding: None,
next_seq: 0,
buffer: Vec::new(),
finished: false,
}
}
pub fn push(&mut self, chunk: &JobResultChunkPayload) -> Result<bool, ResultChunkError> {
if self.finished {
return Err(ResultChunkError::AfterFinal);
}
if chunk.chunk_seq != self.next_seq {
return Err(ResultChunkError::OutOfOrder {
expected: self.next_seq,
got: chunk.chunk_seq,
});
}
if let Some(rid) = self.result_id.as_deref() {
if rid != chunk.result_id {
return Err(ResultChunkError::ResultIdMismatch {
expected: rid.to_owned(),
got: chunk.result_id.clone(),
});
}
} else {
self.result_id = Some(chunk.result_id.clone());
}
if let Some(enc) = self.encoding {
if enc != chunk.encoding {
return Err(ResultChunkError::EncodingMismatch {
expected: enc,
got: chunk.encoding,
});
}
} else {
self.encoding = Some(chunk.encoding);
}
match chunk.encoding {
ResultChunkEncoding::Utf8 => {
self.buffer.extend_from_slice(chunk.data.as_bytes());
}
ResultChunkEncoding::Base64 => {
let decoded =
decode_base64(&chunk.data).map_err(|()| ResultChunkError::Base64Decode {
seq: chunk.chunk_seq,
})?;
self.buffer.extend_from_slice(&decoded);
}
}
self.next_seq += 1;
if !chunk.more {
self.finished = true;
}
Ok(!chunk.more)
}
#[must_use]
pub const fn is_finished(&self) -> bool {
self.finished
}
#[must_use]
pub const fn encoding(&self) -> Option<ResultChunkEncoding> {
self.encoding
}
#[must_use]
pub fn result_id(&self) -> Option<&str> {
self.result_id.as_deref()
}
pub fn finish(self) -> Result<Vec<u8>, ResultChunkError> {
if !self.finished {
return Err(ResultChunkError::NotFinal);
}
Ok(self.buffer)
}
pub fn finish_utf8(self) -> Result<String, ResultChunkError> {
let bytes = self.finish()?;
String::from_utf8(bytes).map_err(|_| ResultChunkError::Base64Decode { seq: u64::MAX })
}
}
fn decode_base64(input: &str) -> Result<Vec<u8>, ()> {
const fn val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let (data, pad) = bytes
.iter()
.position(|&b| b == b'=')
.map_or((bytes.as_slice(), 0), |p| (&bytes[..p], bytes.len() - p));
if (data.len() + pad) % 4 != 0 {
return Err(());
}
let mut out = Vec::with_capacity(data.len() * 3 / 4);
let mut chunk = [0u8; 4];
let mut filled = 0;
for &b in data {
let v = val(b).ok_or(())?;
chunk[filled] = v;
filled += 1;
if filled == 4 {
out.push((chunk[0] << 2) | (chunk[1] >> 4));
out.push((chunk[1] << 4) | (chunk[2] >> 2));
out.push((chunk[2] << 6) | chunk[3]);
filled = 0;
}
}
match filled {
0 => {}
2 => out.push((chunk[0] << 2) | (chunk[1] >> 4)),
3 => {
out.push((chunk[0] << 2) | (chunk[1] >> 4));
out.push((chunk[1] << 4) | (chunk[2] >> 2));
}
_ => return Err(()),
}
Ok(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod result_chunk_tests {
use super::*;
#[test]
fn utf8_chunks_assemble_in_order() {
let mut a = ResultChunkAssembler::new();
for (seq, fragment, more) in [(0u64, "hello ", true), (1, "world", false)] {
let done = a
.push(&JobResultChunkPayload {
result_id: "res_x".into(),
chunk_seq: seq,
data: fragment.into(),
encoding: ResultChunkEncoding::Utf8,
more,
})
.unwrap();
assert_eq!(done, !more);
}
assert!(a.is_finished());
assert_eq!(a.finish_utf8().unwrap(), "hello world");
}
#[test]
fn out_of_order_chunks_rejected() {
let mut a = ResultChunkAssembler::new();
let _ = a
.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 0,
data: "a".into(),
encoding: ResultChunkEncoding::Utf8,
more: true,
})
.unwrap();
let err = a
.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 2,
data: "c".into(),
encoding: ResultChunkEncoding::Utf8,
more: false,
})
.unwrap_err();
assert!(matches!(
err,
ResultChunkError::OutOfOrder {
expected: 1,
got: 2
}
));
}
#[test]
fn encoding_mismatch_rejected() {
let mut a = ResultChunkAssembler::new();
let _ = a
.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 0,
data: "a".into(),
encoding: ResultChunkEncoding::Utf8,
more: true,
})
.unwrap();
let err = a
.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 1,
data: "AA==".into(),
encoding: ResultChunkEncoding::Base64,
more: false,
})
.unwrap_err();
assert!(matches!(err, ResultChunkError::EncodingMismatch { .. }));
}
#[test]
fn base64_chunks_assemble() {
let mut a = ResultChunkAssembler::new();
a.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 0,
data: "aGk=".into(),
encoding: ResultChunkEncoding::Base64,
more: false,
})
.unwrap();
assert_eq!(a.finish().unwrap(), b"hi");
}
#[test]
fn finish_before_terminal_is_error() {
let mut a = ResultChunkAssembler::new();
a.push(&JobResultChunkPayload {
result_id: "r".into(),
chunk_seq: 0,
data: "x".into(),
encoding: ResultChunkEncoding::Utf8,
more: true,
})
.unwrap();
assert!(matches!(a.finish(), Err(ResultChunkError::NotFinal)));
}
#[test]
fn payload_round_trips_through_serde() {
let p = JobResultChunkPayload {
result_id: "res_01J".into(),
chunk_seq: 7,
data: "fragment".into(),
encoding: ResultChunkEncoding::Utf8,
more: true,
};
let j = serde_json::to_string(&p).unwrap();
let back: JobResultChunkPayload = serde_json::from_str(&j).unwrap();
assert_eq!(p, back);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobFailedPayload {
pub code: ErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retryable: Option<bool>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobCancelledPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobSchedulePayload {
pub job: serde_json::Value,
pub when: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentDelegatePayload {
pub target: String,
pub task: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentHandoffPayload {
pub runtime: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowStartPayload {
pub workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCompletePayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
}