Skip to main content

rz_agent_protocol/
lib.rs

1//! Wire protocol: JSON envelopes with `@@RZ:` sentinel.
2//!
3//! Every protocol message is a single line:
4//! ```text
5//! @@RZ:{"id":"...","from":"...","kind":{"kind":"chat","body":{"text":"..."}}}
6//! ```
7//! The `@@RZ:` prefix lets receivers distinguish protocol messages from
8//! normal shell output or human typing.
9
10use serde::{Deserialize, Serialize};
11use std::sync::atomic::{AtomicU32, Ordering};
12
13pub const SENTINEL: &str = "@@RZ:";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Envelope {
17    pub id: String,
18    pub from: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub to: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub r#ref: Option<String>,
23    pub kind: MessageKind,
24    pub ts: u64,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "kind", content = "body", rename_all = "snake_case")]
29pub enum MessageKind {
30    Chat { text: String },
31    Ping,
32    Pong,
33    Error { message: String },
34    Timer { label: String },
35    Status { state: String, detail: String },
36    ToolCall { name: String, input: String },
37    ToolResult { name: String, result: String, is_error: bool },
38    Delegate { task: String, to: Option<String> },
39    Hello { name: String },
40}
41
42static COUNTER: AtomicU32 = AtomicU32::new(0);
43
44impl Envelope {
45    pub fn chat(from: impl Into<String>, text: impl Into<String>) -> Self {
46        Self::new(from, MessageKind::Chat { text: text.into() })
47    }
48
49    pub fn new(from: impl Into<String>, kind: MessageKind) -> Self {
50        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
51        let ts = std::time::SystemTime::now()
52            .duration_since(std::time::UNIX_EPOCH)
53            .unwrap_or_default()
54            .as_millis() as u64;
55        Self {
56            id: format!("{:04x}{:04x}", (ts & 0xFFFF) as u16, seq),
57            to: None,
58            r#ref: None,
59            from: from.into(),
60            kind,
61            ts,
62        }
63    }
64
65    /// Builder: set `to` for directed messaging.
66    pub fn with_to(mut self, t: impl Into<String>) -> Self {
67        self.to = Some(t.into());
68        self
69    }
70
71    /// Builder: set `ref` for threading.
72    pub fn with_ref(mut self, r: impl Into<String>) -> Self {
73        self.r#ref = Some(r.into());
74        self
75    }
76
77    /// Builder: conditionally set `ref`.
78    pub fn maybe_with_ref(mut self, r: Option<String>) -> Self {
79        self.r#ref = r;
80        self
81    }
82
83    /// Encode to wire format: `@@RZ:<json>`
84    pub fn encode(&self) -> eyre::Result<String> {
85        let json = serde_json::to_string(self)?;
86        Ok(format!("{SENTINEL}{json}"))
87    }
88
89    /// Decode from wire format (with or without sentinel prefix).
90    pub fn decode(line: &str) -> eyre::Result<Self> {
91        let payload = line.strip_prefix(SENTINEL).unwrap_or(line);
92        Ok(serde_json::from_str(payload.trim())?)
93    }
94}