use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterAgentMessage {
pub id: Uuid,
pub from_agent: String,
pub to_agent: String,
pub task: String,
pub payload: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<Uuid>,
#[serde(
serialize_with = "serialize_duration_secs",
deserialize_with = "deserialize_duration_secs"
)]
pub ttl: Duration,
#[serde(default = "now_millis")]
pub created_at_ms: i64,
}
impl InterAgentMessage {
pub fn new(
from_agent: impl Into<String>,
to_agent: impl Into<String>,
task: impl Into<String>,
payload: Value,
ttl: Duration,
) -> Self {
Self {
id: Uuid::new_v4(),
from_agent: from_agent.into(),
to_agent: to_agent.into(),
task: task.into(),
payload,
reply_to: None,
ttl,
created_at_ms: now_millis(),
}
}
pub fn reply(
original: &InterAgentMessage,
payload: Value,
ttl: Duration,
) -> Self {
Self {
id: Uuid::new_v4(),
from_agent: original.to_agent.clone(),
to_agent: original.from_agent.clone(),
task: format!("reply to: {}", original.task),
payload,
reply_to: Some(original.id),
ttl,
created_at_ms: now_millis(),
}
}
pub fn is_expired(&self) -> bool {
let elapsed_ms = now_millis() - self.created_at_ms;
elapsed_ms > self.ttl.as_millis() as i64
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagePayload {
Text { content: String },
Structured { content: Value },
Binary {
mime_type: String,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
},
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum AgentBusError {
#[error("agent not found: {0}")]
AgentNotFound(String),
#[error("inbox full for agent: {0}")]
InboxFull(String),
#[error("message expired (ttl: {ttl:?})")]
MessageExpired { ttl: Duration },
}
fn now_millis() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn serialize_duration_secs<S>(d: &Duration, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_u64(d.as_secs())
}
fn deserialize_duration_secs<'de, D>(d: D) -> Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
let secs = u64::deserialize(d)?;
Ok(Duration::from_secs(secs))
}
mod base64_bytes {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
hex.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let hex = String::deserialize(deserializer)?;
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(serde::de::Error::custom)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inter_agent_message_new() {
let msg = InterAgentMessage::new(
"agent-a",
"agent-b",
"summarize document",
serde_json::json!({"doc_id": 42}),
Duration::from_secs(60),
);
assert_eq!(msg.from_agent, "agent-a");
assert_eq!(msg.to_agent, "agent-b");
assert_eq!(msg.task, "summarize document");
assert!(msg.reply_to.is_none());
assert!(!msg.is_expired());
}
#[test]
fn inter_agent_message_reply() {
let original = InterAgentMessage::new(
"agent-a",
"agent-b",
"task",
Value::Null,
Duration::from_secs(60),
);
let reply = InterAgentMessage::reply(
&original,
serde_json::json!({"result": "done"}),
Duration::from_secs(30),
);
assert_eq!(reply.from_agent, "agent-b");
assert_eq!(reply.to_agent, "agent-a");
assert_eq!(reply.reply_to, Some(original.id));
}
#[test]
fn inter_agent_message_serde_roundtrip() {
let msg = InterAgentMessage::new(
"a",
"b",
"test",
serde_json::json!({"key": "value"}),
Duration::from_secs(120),
);
let json = serde_json::to_string(&msg).unwrap();
let restored: InterAgentMessage = serde_json::from_str(&json).unwrap();
assert_eq!(restored.from_agent, "a");
assert_eq!(restored.to_agent, "b");
assert_eq!(restored.task, "test");
assert_eq!(restored.ttl, Duration::from_secs(120));
}
#[test]
fn message_payload_text() {
let p = MessagePayload::Text {
content: "hello".into(),
};
let json = serde_json::to_string(&p).unwrap();
let restored: MessagePayload = serde_json::from_str(&json).unwrap();
match restored {
MessagePayload::Text { content } => assert_eq!(content, "hello"),
_ => panic!("expected Text variant"),
}
}
#[test]
fn message_payload_structured() {
let p = MessagePayload::Structured {
content: serde_json::json!({"key": "value"}),
};
let json = serde_json::to_string(&p).unwrap();
let restored: MessagePayload = serde_json::from_str(&json).unwrap();
match restored {
MessagePayload::Structured { content } => {
assert_eq!(content["key"], "value");
}
_ => panic!("expected Structured variant"),
}
}
#[test]
fn message_payload_binary() {
let p = MessagePayload::Binary {
mime_type: "image/png".into(),
data: vec![0x89, 0x50, 0x4e, 0x47],
};
let json = serde_json::to_string(&p).unwrap();
let restored: MessagePayload = serde_json::from_str(&json).unwrap();
match restored {
MessagePayload::Binary { mime_type, data } => {
assert_eq!(mime_type, "image/png");
assert_eq!(data, vec![0x89, 0x50, 0x4e, 0x47]);
}
_ => panic!("expected Binary variant"),
}
}
#[test]
fn agent_bus_error_display() {
let err = AgentBusError::AgentNotFound("agent-x".into());
assert_eq!(err.to_string(), "agent not found: agent-x");
let err = AgentBusError::InboxFull("agent-y".into());
assert_eq!(err.to_string(), "inbox full for agent: agent-y");
let err = AgentBusError::MessageExpired {
ttl: Duration::from_secs(30),
};
assert_eq!(err.to_string(), "message expired (ttl: 30s)");
}
}