Skip to main content

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}