Skip to main content

car_engine/
messaging.rs

1//! Outbound human-directed messaging as a runtime capability.
2//!
3//! CAR owns tools, state, retry, idempotency, timeouts and rollback — but
4//! historically NOT "send a message to a human". Every agent that needed to
5//! reach a person hand-rolled its own transport, which put the single most
6//! visible side effect an agent can have outside the runtime's sight: the
7//! declarative policy engine could not see it, the rate limiter could not
8//! bound it, and the event log did not record it.
9//!
10//! This module is the seam that closes that. The runtime does not own a
11//! transport — same as it does not own tools — it owns the *verb*. A host
12//! attaches a [`MessageSink`] (see `car-messaging`'s `OutboundRegistry` for
13//! the production one), and `messaging.send` becomes a first-class tool that
14//! flows through the same validator → policy → rate-limit → eventlog chain as
15//! every other side effect.
16//!
17//! Two invariants are worth stating because they are easy to erode:
18//!
19//! - **A tool the runtime cannot execute is never advertised.** The schema is
20//!   registered by [`crate::Runtime::with_message_sink`] and nowhere else.
21//! - **No fall-through.** With no sink attached, `messaging.send` is an error,
22//!   not a request handed to the configured host executor. A silent
23//!   fall-through would re-open the exact ungoverned path this exists to close.
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28/// Where a message is addressed.
29///
30/// Two verbs, not one: a direct message to a person and a post into a shared
31/// channel are different acts with different blast radius, and collapsing them
32/// into a single opaque "address" string loses one of the two real call shapes
33/// — a sink could no longer tell "text Keenan" from "post to #general", and
34/// neither could a policy rule written against the parameters.
35///
36/// Serializes adjacently-tagged as `{"kind": "direct", "to": "<handle>"}`,
37/// which is deliberately the same `kind`/`to` pair the `messaging.send` tool
38/// schema exposes — one vocabulary from the model's JSON down to the sink.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "kind", content = "to", rename_all = "lowercase")]
41pub enum Recipient {
42    /// A person, named by whatever handle the channel uses (phone number,
43    /// email, workspace member id).
44    Direct(String),
45    /// A shared channel, named by the channel's own id.
46    Channel(String),
47}
48
49/// One outbound message, as the runtime hands it to a sink.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct OutboundMessage {
52    /// Channel name — "imessage", or any name a host adapter answers to.
53    /// The runtime keeps no list of valid channels; the sink is the authority.
54    pub channel: String,
55    /// Who the message is for.
56    pub to: Recipient,
57    /// The message text, as the human will read it.
58    pub body: String,
59    /// Caller-supplied dedup key. A retry after a delivered-but-timed-out send
60    /// is the classic duplicate-message bug — the transport succeeded, the
61    /// acknowledgement did not arrive, the caller retries, and the human gets
62    /// the same message twice. A sink that honours this key makes the retry
63    /// safe. `None` means "no dedup", which is the right answer for a message
64    /// the caller genuinely wants sent again.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub idempotency_key: Option<String>,
67}
68
69/// What a sink reports back about a delivered (or suppressed) send.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct MessageReceipt {
72    /// The channel that carried it.
73    pub channel: String,
74    /// Channel-assigned id, when the channel exposes one. Several do not
75    /// (iMessage via JXA gives back nothing addressable), hence `Option`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub message_id: Option<String>,
78    /// True when this send was suppressed because its idempotency key had
79    /// already been delivered. Surfaced rather than hidden: a caller that sees
80    /// `deduplicated: true` learns its retry was unnecessary, and a caller
81    /// that never expected a duplicate learns it has a resend bug.
82    pub deduplicated: bool,
83}
84
85impl MessageReceipt {
86    /// A fresh delivery on `channel` with no channel-assigned id.
87    pub fn delivered(channel: impl Into<String>) -> Self {
88        Self {
89            channel: channel.into(),
90            message_id: None,
91            deduplicated: false,
92        }
93    }
94
95    /// Attach a channel-assigned message id.
96    pub fn with_message_id(mut self, id: impl Into<String>) -> Self {
97        self.message_id = Some(id.into());
98        self
99    }
100}
101
102/// The host-supplied outbound transport the runtime dispatches
103/// `messaging.send` to.
104///
105/// Mirrors the [`crate::ToolExecutor`] arrangement: the runtime owns the
106/// governance (validation, policy, rate limiting, logging) and the caller owns
107/// the wire. One sink fronts every channel — routing among channels is the
108/// sink's job, because only the sink knows which adapters it has.
109#[async_trait::async_trait]
110pub trait MessageSink: Send + Sync {
111    /// Channel names this sink can deliver to.
112    ///
113    /// Advisory — used to build actionable error messages ("unknown channel
114    /// 'slak'; registered: imessage") and for host introspection. It is NOT a
115    /// pre-flight gate: [`Self::send`] remains the authority, since a sink's
116    /// set of channels can change between the two calls.
117    async fn channels(&self) -> Vec<String>;
118
119    /// Deliver one message, or explain why it was not delivered.
120    ///
121    /// `Err` carries a human-readable reason that reaches the model as the
122    /// tool's error, so it should say what would make the send work (pair the
123    /// handle, use a direct recipient, name a registered channel).
124    async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String>;
125}
126
127impl OutboundMessage {
128    /// Parse the `messaging.send` tool parameters into a message.
129    ///
130    /// Errors are written for two readers at once: the model, which has to fix
131    /// the call on its next turn, and the operator reading the event log. Both
132    /// need to know which field was wrong and what a correct one looks like,
133    /// so every message names the offending key and the accepted shape.
134    ///
135    /// There is deliberately exactly one accepted spelling of the address
136    /// parameter: `to`, the name the schema advertises. Policy rules match the
137    /// LITERAL parameter key, so an alias would be an ungoverned spelling of a
138    /// governed field — a rule written as `allow_tool_param { param = "to" }`
139    /// would simply not see a call that spelled it differently.
140    pub fn from_tool_params(params: &Value) -> Result<Self, String> {
141        let obj = params
142            .as_object()
143            .ok_or("messaging.send: parameters must be a JSON object")?;
144
145        let channel = required_str(obj.get("channel"), "channel")?;
146
147        let address = match obj.get("to") {
148            Some(v) => required_str(Some(v), "to")?,
149            None => {
150                return Err("messaging.send: missing required parameter 'to' — \
151                            pass 'to' naming the handle or channel id"
152                    .to_string())
153            }
154        };
155
156        // Absent `kind` means the narrower of the two acts. Defaulting to
157        // `direct` keeps an under-specified call from broadcasting.
158        let to = match obj.get("kind") {
159            None | Some(Value::Null) => Recipient::Direct(address),
160            Some(Value::String(k)) => match k.as_str() {
161                "direct" => Recipient::Direct(address),
162                "channel" => Recipient::Channel(address),
163                other => {
164                    return Err(format!(
165                        "messaging.send: unknown kind '{other}' — expected \
166                         'direct' (a person) or 'channel' (a shared channel)"
167                    ))
168                }
169            },
170            Some(other) => {
171                return Err(format!(
172                    "messaging.send: 'kind' must be the string 'direct' or \
173                     'channel', got {other}"
174                ))
175            }
176        };
177
178        let body = required_str(obj.get("body"), "body")?;
179
180        let idempotency_key = match obj.get("idempotency_key") {
181            None | Some(Value::Null) => None,
182            Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
183            Some(Value::String(_)) => {
184                return Err("messaging.send: 'idempotency_key' must not be blank \
185                            — omit it entirely to opt out of dedup"
186                    .to_string())
187            }
188            Some(other) => {
189                return Err(format!(
190                    "messaging.send: 'idempotency_key' must be a string, got {other}"
191                ))
192            }
193        };
194
195        Ok(Self {
196            channel,
197            to,
198            body,
199            idempotency_key,
200        })
201    }
202}
203
204/// A required string field: present, a string, and not blank. A blank channel
205/// routes nowhere, a blank recipient addresses nobody, and a blank body pages
206/// a human with silence — all three are bugs, not permissive inputs.
207fn required_str(value: Option<&Value>, field: &str) -> Result<String, String> {
208    match value {
209        None | Some(Value::Null) => Err(format!(
210            "messaging.send: missing required parameter '{field}'"
211        )),
212        Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
213        Some(Value::String(_)) => Err(format!("messaging.send: '{field}' must not be empty")),
214        Some(other) => Err(format!(
215            "messaging.send: '{field}' must be a string, got {other}"
216        )),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde_json::json;
224
225    #[test]
226    fn parses_direct_by_default() {
227        let msg = OutboundMessage::from_tool_params(&json!({
228            "channel": "imessage",
229            "to": "+15551112222",
230            "body": "build is green",
231        }))
232        .unwrap();
233        assert_eq!(msg.channel, "imessage");
234        assert_eq!(msg.to, Recipient::Direct("+15551112222".into()));
235        assert_eq!(msg.body, "build is green");
236        assert!(msg.idempotency_key.is_none());
237    }
238
239    #[test]
240    fn parses_explicit_kinds() {
241        let direct = OutboundMessage::from_tool_params(&json!({
242            "channel": "imessage",
243            "to": "keenan@parslee.ai",
244            "kind": "direct",
245            "body": "hi",
246            "idempotency_key": "run-42",
247        }))
248        .unwrap();
249        assert_eq!(direct.to, Recipient::Direct("keenan@parslee.ai".into()));
250        assert_eq!(direct.idempotency_key.as_deref(), Some("run-42"));
251
252        let channel = OutboundMessage::from_tool_params(&json!({
253            "channel": "slack",
254            "to": "C012ABCDEF",
255            "kind": "channel",
256            "body": "deploy done",
257        }))
258        .unwrap();
259        assert_eq!(channel.to, Recipient::Channel("C012ABCDEF".into()));
260    }
261
262    /// `to` is the only accepted spelling. Policy matches the literal parameter
263    /// key, so an alias would be an ungoverned spelling of a governed field.
264    #[test]
265    fn rejects_recipient_as_an_alias_for_to() {
266        let err = OutboundMessage::from_tool_params(&json!({
267            "channel": "imessage",
268            "recipient": "+15551112222",
269            "body": "hi",
270        }))
271        .unwrap_err();
272        assert!(err.contains("missing required parameter 'to'"), "{err}");
273    }
274
275    #[test]
276    fn rejects_non_object_params() {
277        let err = OutboundMessage::from_tool_params(&json!("just a string")).unwrap_err();
278        assert!(err.contains("must be a JSON object"), "{err}");
279    }
280
281    #[test]
282    fn rejects_missing_channel() {
283        let err = OutboundMessage::from_tool_params(&json!({
284            "to": "+15551112222",
285            "body": "hi",
286        }))
287        .unwrap_err();
288        assert!(
289            err.contains("missing required parameter 'channel'"),
290            "{err}"
291        );
292    }
293
294    #[test]
295    fn rejects_blank_channel() {
296        let err = OutboundMessage::from_tool_params(&json!({
297            "channel": "   ",
298            "to": "+15551112222",
299            "body": "hi",
300        }))
301        .unwrap_err();
302        assert!(err.contains("'channel' must not be empty"), "{err}");
303    }
304
305    #[test]
306    fn rejects_missing_recipient() {
307        let err = OutboundMessage::from_tool_params(&json!({
308            "channel": "imessage",
309            "body": "hi",
310        }))
311        .unwrap_err();
312        assert!(err.contains("missing required parameter 'to'"), "{err}");
313    }
314
315    #[test]
316    fn rejects_missing_body() {
317        let err = OutboundMessage::from_tool_params(&json!({
318            "channel": "imessage",
319            "to": "+15551112222",
320        }))
321        .unwrap_err();
322        assert!(err.contains("missing required parameter 'body'"), "{err}");
323    }
324
325    #[test]
326    fn rejects_non_string_body() {
327        let err = OutboundMessage::from_tool_params(&json!({
328            "channel": "imessage",
329            "to": "+15551112222",
330            "body": 42,
331        }))
332        .unwrap_err();
333        assert!(err.contains("'body' must be a string"), "{err}");
334    }
335
336    #[test]
337    fn rejects_unknown_kind() {
338        let err = OutboundMessage::from_tool_params(&json!({
339            "channel": "imessage",
340            "to": "+15551112222",
341            "kind": "broadcast",
342            "body": "hi",
343        }))
344        .unwrap_err();
345        assert!(err.contains("unknown kind 'broadcast'"), "{err}");
346        assert!(err.contains("'direct'"), "{err}");
347    }
348
349    #[test]
350    fn rejects_non_string_kind() {
351        let err = OutboundMessage::from_tool_params(&json!({
352            "channel": "imessage",
353            "to": "+15551112222",
354            "kind": true,
355            "body": "hi",
356        }))
357        .unwrap_err();
358        assert!(err.contains("'kind' must be the string"), "{err}");
359    }
360
361    #[test]
362    fn rejects_blank_idempotency_key() {
363        let err = OutboundMessage::from_tool_params(&json!({
364            "channel": "imessage",
365            "to": "+15551112222",
366            "body": "hi",
367            "idempotency_key": "",
368        }))
369        .unwrap_err();
370        assert!(err.contains("'idempotency_key' must not be blank"), "{err}");
371    }
372
373    #[test]
374    fn recipient_serializes_with_the_tool_vocabulary() {
375        // The wire shape a sink (or a policy rule) sees must be the same
376        // `kind`/`to` pair the tool schema advertises.
377        let json = serde_json::to_value(Recipient::Direct("+15551112222".into())).unwrap();
378        assert_eq!(json, json!({ "kind": "direct", "to": "+15551112222" }));
379        let round: Recipient = serde_json::from_value(json).unwrap();
380        assert_eq!(round, Recipient::Direct("+15551112222".into()));
381
382        let json = serde_json::to_value(Recipient::Channel("C1".into())).unwrap();
383        assert_eq!(json, json!({ "kind": "channel", "to": "C1" }));
384    }
385
386    #[test]
387    fn receipt_round_trips() {
388        let receipt = MessageReceipt::delivered("imessage").with_message_id("m-1");
389        let json = serde_json::to_value(&receipt).unwrap();
390        assert_eq!(
391            json,
392            json!({ "channel": "imessage", "message_id": "m-1", "deduplicated": false })
393        );
394        let round: MessageReceipt = serde_json::from_value(json).unwrap();
395        assert_eq!(round, receipt);
396    }
397}