nexo_core/agent/inbox.rs
1//! Agent inbox subject contract + payload shape.
2//!
3//! Multi-agent coordination via per-goal NATS inbox subject:
4//! `agent.inbox.<goal_id>`. Sender (LLM tool `send_to_peer`) fires
5//! and forgets; receiver subscribes per-goal and queues incoming
6//! messages for injection at next turn start.
7//!
8//! Wire format is JSON; payload field carries `InboxMessage` so
9//! standard NATS subject + body conventions apply.
10//!
11//! # Provider-agnostic
12//!
13//! Pure NATS + JSON. Works under any LLM provider — the subject
14//! contract sits below the LLM round-trip.
15
16use chrono::{DateTime, Utc};
17use nexo_driver_types::GoalId;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21/// NATS subject prefix for per-goal inboxes.
22pub const INBOX_SUBJECT_PREFIX: &str = "agent.inbox";
23
24/// Build the inbox subject for a goal: `agent.inbox.<goal_id>`.
25pub fn inbox_subject(goal_id: GoalId) -> String {
26 format!("{}.{}", INBOX_SUBJECT_PREFIX, goal_id.0)
27}
28
29/// Per-goal inbox message — fire-and-forget peer-to-peer
30/// communication. Carries provenance fields so the receiver knows
31/// who wrote and (optionally) which goal originated the message.
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
33pub struct InboxMessage {
34 /// Sending agent's stable id (matches `AgentConfig.id`).
35 pub from_agent_id: String,
36 /// Sender's goal id at the time of sending. Useful for the
37 /// receiver to reply via the sender's inbox subject.
38 pub from_goal_id: GoalId,
39 /// Receiver's agent id (the `to:` argument resolved to a
40 /// concrete name). Repeated on the wire so subscribers don't
41 /// have to parse the subject string.
42 pub to_agent_id: String,
43 /// Plain text body. Empty body is invalid (sender-side
44 /// validation rejects).
45 pub body: String,
46 /// UTC timestamp at send time.
47 pub sent_at: DateTime<Utc>,
48 /// Optional correlation id. When set, a reply may carry the
49 /// same value so request/response patterns work without a
50 /// separate transport.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub correlation_id: Option<Uuid>,
53}
54
55/// Minimum body length (sender-side guard against empty messages).
56pub const MIN_BODY_CHARS: usize = 1;
57/// Maximum body length sender-side. Receiver may impose its own
58/// stricter cap. 64 KB is generous for an LLM-driven peer message
59/// without being so large that broker fan-out chokes.
60pub const MAX_BODY_BYTES: usize = 64 * 1024;
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 #[test]
67 fn subject_format_uses_prefix_dot_uuid() {
68 let goal = GoalId(Uuid::nil());
69 let s = inbox_subject(goal);
70 assert!(s.starts_with("agent.inbox."));
71 assert!(s.contains("00000000-0000-0000-0000-000000000000"));
72 }
73
74 #[test]
75 fn message_serde_round_trip() {
76 let msg = InboxMessage {
77 from_agent_id: "kate".into(),
78 from_goal_id: GoalId(Uuid::new_v4()),
79 to_agent_id: "researcher".into(),
80 body: "hello".into(),
81 sent_at: Utc::now(),
82 correlation_id: Some(Uuid::new_v4()),
83 };
84 let s = serde_json::to_string(&msg).unwrap();
85 let back: InboxMessage = serde_json::from_str(&s).unwrap();
86 assert_eq!(msg, back);
87 }
88
89 #[test]
90 fn correlation_id_omitted_when_none() {
91 let msg = InboxMessage {
92 from_agent_id: "kate".into(),
93 from_goal_id: GoalId(Uuid::new_v4()),
94 to_agent_id: "researcher".into(),
95 body: "hello".into(),
96 sent_at: Utc::now(),
97 correlation_id: None,
98 };
99 let s = serde_json::to_string(&msg).unwrap();
100 assert!(!s.contains("correlation_id"));
101 }
102}