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    /// Owner-phone classification, separate from recipient kind. Omission is a question.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub message_kind: Option<String>,
62    /// Absolute Unix-millisecond deadline for an owner question.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub deadline_ms: Option<u64>,
65    /// Caller-supplied dedup key. A retry after a delivered-but-timed-out send
66    /// is the classic duplicate-message bug — the transport succeeded, the
67    /// acknowledgement did not arrive, the caller retries, and the human gets
68    /// the same message twice. A sink that honours this key makes the retry
69    /// safe. `None` means "no dedup", which is the right answer for a message
70    /// the caller genuinely wants sent again.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub idempotency_key: Option<String>,
73}
74
75/// What a sink reports back about a delivered (or suppressed) send.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct MessageReceipt {
78    /// The channel that carried it.
79    pub channel: String,
80    /// Channel-assigned id, when the channel exposes one. Several do not
81    /// (iMessage via JXA gives back nothing addressable), hence `Option`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub message_id: Option<String>,
84    /// Owner-floor topic, readable by the calling session via conversation.get.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub topic_id: Option<String>,
87    /// True when this send was suppressed because its idempotency key had
88    /// already been delivered. Surfaced rather than hidden: a caller that sees
89    /// `deduplicated: true` learns its retry was unnecessary, and a caller
90    /// that never expected a duplicate learns it has a resend bug.
91    pub deduplicated: bool,
92    /// Whether transport delivery is confirmed by this receipt.
93    #[serde(default)]
94    pub delivered: bool,
95    /// Why an accepted send was not delivered, when known.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub reason: Option<String>,
98    /// Accepted owner question waiting for its turn on the floor.
99    #[serde(default)]
100    pub queued: bool,
101}
102
103impl MessageReceipt {
104    /// A fresh delivery on `channel` with no channel-assigned id.
105    pub fn delivered(channel: impl Into<String>) -> Self {
106        Self {
107            channel: channel.into(),
108            message_id: None,
109            topic_id: None,
110            deduplicated: false,
111            queued: false,
112            delivered: true,
113            reason: None,
114        }
115    }
116
117    /// Attach a channel-assigned message id.
118    pub fn with_message_id(mut self, id: impl Into<String>) -> Self {
119        self.message_id = Some(id.into());
120        self
121    }
122}
123
124/// The host-supplied outbound transport the runtime dispatches
125/// `messaging.send` to.
126///
127/// Mirrors the [`crate::ToolExecutor`] arrangement: the runtime owns the
128/// governance (validation, policy, rate limiting, logging) and the caller owns
129/// the wire. One sink fronts every channel — routing among channels is the
130/// sink's job, because only the sink knows which adapters it has.
131#[async_trait::async_trait]
132pub trait MessageSink: Send + Sync {
133    /// Channel names this sink can deliver to.
134    ///
135    /// Advisory — used to build actionable error messages ("unknown channel
136    /// 'slak'; registered: imessage") and for host introspection. It is NOT a
137    /// pre-flight gate: [`Self::send`] remains the authority, since a sink's
138    /// set of channels can change between the two calls.
139    async fn channels(&self) -> Vec<String>;
140
141    /// Deliver one message, or explain why it was not delivered.
142    ///
143    /// `Err` carries a human-readable reason that reaches the model as the
144    /// tool's error, so it should say what would make the send work (pair the
145    /// handle, use a direct recipient, name a registered channel).
146    async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String>;
147}
148
149impl OutboundMessage {
150    /// Parse the `messaging.send` tool parameters into a message.
151    ///
152    /// Errors are written for two readers at once: the model, which has to fix
153    /// the call on its next turn, and the operator reading the event log. Both
154    /// need to know which field was wrong and what a correct one looks like,
155    /// so every message names the offending key and the accepted shape.
156    ///
157    /// There is deliberately exactly one accepted spelling of the address
158    /// parameter: `to`, the name the schema advertises. Policy rules match the
159    /// LITERAL parameter key, so an alias would be an ungoverned spelling of a
160    /// governed field — a rule written as `allow_tool_param { param = "to" }`
161    /// would simply not see a call that spelled it differently.
162    pub fn from_tool_params(params: &Value) -> Result<Self, String> {
163        let obj = params
164            .as_object()
165            .ok_or("messaging.send: parameters must be a JSON object")?;
166
167        let channel = required_str(obj.get("channel"), "channel")?;
168
169        let address = match obj.get("to") {
170            Some(v) => required_str(Some(v), "to")?,
171            None => {
172                return Err("messaging.send: missing required parameter 'to' — \
173                            pass 'to' naming the handle or channel id"
174                    .to_string())
175            }
176        };
177
178        // Absent `kind` means the narrower of the two acts. Defaulting to
179        // `direct` keeps an under-specified call from broadcasting.
180        let to = match obj.get("kind") {
181            None | Some(Value::Null) => Recipient::Direct(address),
182            Some(Value::String(k)) => match k.as_str() {
183                "direct" => Recipient::Direct(address),
184                "channel" => Recipient::Channel(address),
185                other => {
186                    return Err(format!(
187                        "messaging.send: unknown kind '{other}' — expected \
188                         'direct' (a person) or 'channel' (a shared channel)"
189                    ))
190                }
191            },
192            Some(other) => {
193                return Err(format!(
194                    "messaging.send: 'kind' must be the string 'direct' or \
195                     'channel', got {other}"
196                ))
197            }
198        };
199
200        let body = required_str(obj.get("body"), "body")?;
201
202        let idempotency_key = match obj.get("idempotency_key") {
203            None | Some(Value::Null) => None,
204            Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
205            Some(Value::String(_)) => {
206                return Err("messaging.send: 'idempotency_key' must not be blank \
207                            — omit it entirely to opt out of dedup"
208                    .to_string())
209            }
210            Some(other) => {
211                return Err(format!(
212                    "messaging.send: 'idempotency_key' must be a string, got {other}"
213                ))
214            }
215        };
216
217        let deadline_ms = obj
218            .get("deadline_ms")
219            .filter(|v| !v.is_null())
220            .map(|v| {
221                v.as_u64().ok_or_else(|| {
222                    "messaging.send: 'deadline_ms' must be a Unix-millisecond unsigned integer"
223                        .to_string()
224                })
225            })
226            .transpose()?;
227        Ok(Self {
228            channel,
229            to,
230            body,
231            deadline_ms,
232            message_kind: obj
233                .get("message_kind")
234                .filter(|v| !v.is_null())
235                .map(|v| required_str(Some(v), "message_kind"))
236                .transpose()?,
237            idempotency_key,
238        })
239    }
240}
241
242/// A required string field: present, a string, and not blank. A blank channel
243/// routes nowhere, a blank recipient addresses nobody, and a blank body pages
244/// a human with silence — all three are bugs, not permissive inputs.
245fn required_str(value: Option<&Value>, field: &str) -> Result<String, String> {
246    match value {
247        None | Some(Value::Null) => Err(format!(
248            "messaging.send: missing required parameter '{field}'"
249        )),
250        Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
251        Some(Value::String(_)) => Err(format!("messaging.send: '{field}' must not be empty")),
252        Some(other) => Err(format!(
253            "messaging.send: '{field}' must be a string, got {other}"
254        )),
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use serde_json::json;
262
263    #[test]
264    fn parses_direct_by_default() {
265        let msg = OutboundMessage::from_tool_params(&json!({
266            "channel": "imessage",
267            "to": "+15551112222",
268            "body": "build is green",
269        }))
270        .unwrap();
271        assert_eq!(msg.channel, "imessage");
272        assert_eq!(msg.to, Recipient::Direct("+15551112222".into()));
273        assert_eq!(msg.body, "build is green");
274        assert!(msg.idempotency_key.is_none());
275    }
276
277    #[test]
278    fn parses_explicit_kinds() {
279        let direct = OutboundMessage::from_tool_params(&json!({
280            "channel": "imessage",
281            "to": "keenan@parslee.ai",
282            "kind": "direct",
283            "body": "hi",
284            "idempotency_key": "run-42",
285        }))
286        .unwrap();
287        assert_eq!(direct.to, Recipient::Direct("keenan@parslee.ai".into()));
288        assert_eq!(direct.idempotency_key.as_deref(), Some("run-42"));
289
290        let channel = OutboundMessage::from_tool_params(&json!({
291            "channel": "slack",
292            "to": "C012ABCDEF",
293            "kind": "channel",
294            "body": "deploy done",
295        }))
296        .unwrap();
297        assert_eq!(channel.to, Recipient::Channel("C012ABCDEF".into()));
298    }
299
300    /// `to` is the only accepted spelling. Policy matches the literal parameter
301    /// key, so an alias would be an ungoverned spelling of a governed field.
302    #[test]
303    fn rejects_recipient_as_an_alias_for_to() {
304        let err = OutboundMessage::from_tool_params(&json!({
305            "channel": "imessage",
306            "recipient": "+15551112222",
307            "body": "hi",
308        }))
309        .unwrap_err();
310        assert!(err.contains("missing required parameter 'to'"), "{err}");
311    }
312
313    #[test]
314    fn rejects_non_object_params() {
315        let err = OutboundMessage::from_tool_params(&json!("just a string")).unwrap_err();
316        assert!(err.contains("must be a JSON object"), "{err}");
317    }
318
319    #[test]
320    fn rejects_missing_channel() {
321        let err = OutboundMessage::from_tool_params(&json!({
322            "to": "+15551112222",
323            "body": "hi",
324        }))
325        .unwrap_err();
326        assert!(
327            err.contains("missing required parameter 'channel'"),
328            "{err}"
329        );
330    }
331
332    #[test]
333    fn rejects_blank_channel() {
334        let err = OutboundMessage::from_tool_params(&json!({
335            "channel": "   ",
336            "to": "+15551112222",
337            "body": "hi",
338        }))
339        .unwrap_err();
340        assert!(err.contains("'channel' must not be empty"), "{err}");
341    }
342
343    #[test]
344    fn rejects_missing_recipient() {
345        let err = OutboundMessage::from_tool_params(&json!({
346            "channel": "imessage",
347            "body": "hi",
348        }))
349        .unwrap_err();
350        assert!(err.contains("missing required parameter 'to'"), "{err}");
351    }
352
353    #[test]
354    fn rejects_missing_body() {
355        let err = OutboundMessage::from_tool_params(&json!({
356            "channel": "imessage",
357            "to": "+15551112222",
358        }))
359        .unwrap_err();
360        assert!(err.contains("missing required parameter 'body'"), "{err}");
361    }
362
363    #[test]
364    fn rejects_non_string_body() {
365        let err = OutboundMessage::from_tool_params(&json!({
366            "channel": "imessage",
367            "to": "+15551112222",
368            "body": 42,
369        }))
370        .unwrap_err();
371        assert!(err.contains("'body' must be a string"), "{err}");
372    }
373
374    #[test]
375    fn rejects_unknown_kind() {
376        let err = OutboundMessage::from_tool_params(&json!({
377            "channel": "imessage",
378            "to": "+15551112222",
379            "kind": "broadcast",
380            "body": "hi",
381        }))
382        .unwrap_err();
383        assert!(err.contains("unknown kind 'broadcast'"), "{err}");
384        assert!(err.contains("'direct'"), "{err}");
385    }
386
387    #[test]
388    fn rejects_non_string_kind() {
389        let err = OutboundMessage::from_tool_params(&json!({
390            "channel": "imessage",
391            "to": "+15551112222",
392            "kind": true,
393            "body": "hi",
394        }))
395        .unwrap_err();
396        assert!(err.contains("'kind' must be the string"), "{err}");
397    }
398
399    #[test]
400    fn rejects_blank_idempotency_key() {
401        let err = OutboundMessage::from_tool_params(&json!({
402            "channel": "imessage",
403            "to": "+15551112222",
404            "body": "hi",
405            "idempotency_key": "",
406        }))
407        .unwrap_err();
408        assert!(err.contains("'idempotency_key' must not be blank"), "{err}");
409    }
410
411    #[test]
412    fn recipient_serializes_with_the_tool_vocabulary() {
413        // The wire shape a sink (or a policy rule) sees must be the same
414        // `kind`/`to` pair the tool schema advertises.
415        let json = serde_json::to_value(Recipient::Direct("+15551112222".into())).unwrap();
416        assert_eq!(json, json!({ "kind": "direct", "to": "+15551112222" }));
417        let round: Recipient = serde_json::from_value(json).unwrap();
418        assert_eq!(round, Recipient::Direct("+15551112222".into()));
419
420        let json = serde_json::to_value(Recipient::Channel("C1".into())).unwrap();
421        assert_eq!(json, json!({ "kind": "channel", "to": "C1" }));
422    }
423
424    #[test]
425    fn receipt_round_trips() {
426        let receipt = MessageReceipt::delivered("imessage").with_message_id("m-1");
427        let json = serde_json::to_value(&receipt).unwrap();
428        assert_eq!(
429            json,
430            json!({ "channel": "imessage", "message_id": "m-1", "deduplicated": false, "queued": false, "delivered": true })
431        );
432        let round: MessageReceipt = serde_json::from_value(json).unwrap();
433        assert_eq!(round, receipt);
434    }
435}