Skip to main content

car_server_core/
host_channel.rs

1//! Host-backed outbound channel adapter — how a channel CAR has no built-in
2//! transport for still gets delivered.
3//!
4//! The runtime owns message *semantics* (validation, policy, rate limiting,
5//! idempotency, the event log); the host owns *channels*. Microsoft Teams is
6//! the motivating case: a Teams post goes out through a host-side HTTP
7//! service, and compiling a Teams client into the runtime would couple the
8//! runtime to a platform for no gain (Parslee-ai/car#885 marks it explicitly
9//! out of scope). So an unclaimed channel reaches the host instead.
10//!
11//! ## Why the tool-callback path rather than a new FFI surface
12//!
13//! Issue #885 left "what does adapter registration look like across the FFI
14//! boundary?" open. This answers it by *not* opening a new boundary: calling
15//! back out to the host through [`car_engine::ToolExecutor`] is exactly how
16//! the runtime already reaches host-owned capability today — the daemon's
17//! `WsToolExecutor` turns it into a `tools.execute` JSON-RPC request, and
18//! every host (WS client, NAPI, PyO3, UniFFI) already implements it. A host
19//! that wants Teams handles one more tool name; it registers nothing, links
20//! nothing, and no binding surface has to grow a `register_message_adapter`
21//! entry point that four FFI crates would then have to keep in sync. If a
22//! dedicated registration API is ever warranted, it can be added later without
23//! stranding the hosts that took this path.
24//!
25//! The callback is `messaging.channel_send` with
26//! `{channel, kind, to, body}` — deliberately the same `kind`/`to` vocabulary
27//! the `messaging.send` tool schema advertises to the model, so a host author
28//! reads one shape from the model's JSON through to their handler.
29
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use serde_json::{json, Value};
34
35use car_engine::messaging::{MessageReceipt, OutboundMessage, Recipient};
36use car_engine::ToolExecutor;
37use car_messaging::outbound::OutboundAdapter;
38
39/// The host-side tool name this adapter calls back on.
40pub const HOST_CHANNEL_SEND_TOOL: &str = "messaging.channel_send";
41
42/// The name this adapter reports as "its" channel.
43///
44/// Only meaningful if someone `register`s it as an exact-match adapter; in its
45/// intended role — `OutboundRegistry::set_fallback` — routing never consults
46/// it, because the whole point is that it answers for channels it cannot
47/// enumerate.
48pub const HOST_CHANNEL: &str = "host";
49
50/// Delivers a message by asking the host to do it.
51pub struct HostChannelAdapter {
52    executor: Arc<dyn ToolExecutor>,
53}
54
55impl HostChannelAdapter {
56    /// Wrap the session's composed tool executor — the same one the runtime
57    /// dispatches ordinary tool calls to, so the host sees this callback on
58    /// the channel it is already serving.
59    pub fn new(executor: Arc<dyn ToolExecutor>) -> Self {
60        Self { executor }
61    }
62}
63
64#[async_trait]
65impl OutboundAdapter for HostChannelAdapter {
66    fn channel(&self) -> &str {
67        HOST_CHANNEL
68    }
69
70    async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String> {
71        let (kind, to) = match &msg.to {
72            Recipient::Direct(handle) => ("direct", handle.as_str()),
73            Recipient::Channel(id) => ("channel", id.as_str()),
74        };
75        let params = json!({
76            "channel": msg.channel,
77            "kind": kind,
78            "to": to,
79            "body": msg.body,
80        });
81
82        match self.executor.execute(HOST_CHANNEL_SEND_TOOL, &params).await {
83            Ok(value) => Ok(receipt_from_host(&msg.channel, &value)),
84            // `unknown tool` is this codebase's established sentinel for "this
85            // executor does not handle that name" (see `SubstrateShadowExecutor`
86            // and the assistant's `ChainedDelegate`, both of which fall through
87            // on the same prefix). Reaching it here means the host simply has
88            // not implemented the callback — an UNCONFIGURED host, not a
89            // runtime fault. Letting the raw text through would tell the model
90            // and the operator that CAR is missing a tool it advertises, which
91            // sends them looking for a bug that does not exist.
92            Err(e) if e.starts_with("unknown tool") => Err(format!(
93                "no transport for messaging channel '{}': this host does not implement the \
94                 '{}' tool callback. A host that can deliver on '{}' should handle \
95                 '{}' with parameters {{channel, kind, to, body}} and return \
96                 {{\"message_id\": \"…\"}} (message_id optional).",
97                msg.channel, HOST_CHANNEL_SEND_TOOL, msg.channel, HOST_CHANNEL_SEND_TOOL
98            )),
99            // Any other error is the host's own account of why delivery failed
100            // ("not a member of that team", "rate limited"). That is exactly
101            // what the model needs to adapt, so it passes through verbatim.
102            Err(e) => Err(e),
103        }
104    }
105}
106
107/// Build the receipt from the host's reply.
108///
109/// `deduplicated` is always `false`: dedup belongs to `OutboundRegistry`, which
110/// consults its ledger BEFORE routing, so by the time an adapter runs the send
111/// has already been established as one the host has never been asked to make.
112/// A host claiming otherwise would be reporting on a ledger it does not own.
113fn receipt_from_host(channel: &str, value: &Value) -> MessageReceipt {
114    let receipt = MessageReceipt::delivered(channel);
115    match value
116        .get("message_id")
117        .and_then(|v| v.as_str())
118        .filter(|s| !s.trim().is_empty())
119    {
120        Some(id) => receipt.with_message_id(id),
121        // Plenty of channels hand back nothing addressable, and a host that
122        // returns `true` or `{}` has still delivered. An absent id is not a
123        // failure.
124        None => receipt,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::sync::Mutex;
132
133    /// Records the last callback and replays a canned result.
134    struct FakeExecutor {
135        seen: Mutex<Vec<(String, Value)>>,
136        result: Result<Value, String>,
137    }
138
139    impl FakeExecutor {
140        fn new(result: Result<Value, String>) -> Arc<Self> {
141            Arc::new(Self {
142                seen: Mutex::new(Vec::new()),
143                result,
144            })
145        }
146
147        fn last(&self) -> (String, Value) {
148            self.seen.lock().unwrap().last().cloned().expect("no call")
149        }
150
151        fn calls(&self) -> usize {
152            self.seen.lock().unwrap().len()
153        }
154    }
155
156    #[async_trait]
157    impl ToolExecutor for FakeExecutor {
158        async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
159            self.seen
160                .lock()
161                .unwrap()
162                .push((tool.to_string(), params.clone()));
163            self.result.clone()
164        }
165    }
166
167    fn msg(kind_channel: bool) -> OutboundMessage {
168        OutboundMessage {
169            channel: "teams".to_string(),
170            to: if kind_channel {
171                Recipient::Channel("19:meeting@thread.v2".to_string())
172            } else {
173                Recipient::Direct("keenan@parslee.ai".to_string())
174            },
175            body: "deploy is green".to_string(),
176            idempotency_key: Some("run-42".to_string()),
177        }
178    }
179
180    #[tokio::test]
181    async fn direct_send_builds_the_expected_callback() {
182        let exec = FakeExecutor::new(Ok(json!({ "message_id": "1700000000.1" })));
183        let adapter = HostChannelAdapter::new(exec.clone());
184
185        let receipt = adapter.send(&msg(false)).await.unwrap();
186        assert_eq!(receipt.channel, "teams");
187        assert_eq!(receipt.message_id.as_deref(), Some("1700000000.1"));
188        assert!(!receipt.deduplicated);
189
190        let (tool, params) = exec.last();
191        assert_eq!(tool, HOST_CHANNEL_SEND_TOOL);
192        assert_eq!(
193            params,
194            json!({
195                "channel": "teams",
196                "kind": "direct",
197                "to": "keenan@parslee.ai",
198                "body": "deploy is green",
199            })
200        );
201    }
202
203    #[tokio::test]
204    async fn channel_send_uses_the_channel_kind() {
205        let exec = FakeExecutor::new(Ok(json!({})));
206        let adapter = HostChannelAdapter::new(exec.clone());
207
208        let receipt = adapter.send(&msg(true)).await.unwrap();
209        // No id in the reply is fine — many channels have none to give.
210        assert_eq!(receipt.message_id, None);
211
212        let (_, params) = exec.last();
213        assert_eq!(params["kind"], "channel");
214        assert_eq!(params["to"], "19:meeting@thread.v2");
215    }
216
217    #[tokio::test]
218    async fn unknown_tool_becomes_an_actionable_message() {
219        let exec = FakeExecutor::new(Err("unknown tool: 'messaging.channel_send'".to_string()));
220        let adapter = HostChannelAdapter::new(exec.clone());
221
222        let err = adapter.send(&msg(false)).await.unwrap_err();
223        assert!(
224            !err.contains("unknown tool"),
225            "the raw sentinel must not surface: {err}"
226        );
227        assert!(
228            err.contains("no transport for messaging channel 'teams'"),
229            "{err}"
230        );
231        assert!(err.contains(HOST_CHANNEL_SEND_TOOL), "{err}");
232        assert_eq!(exec.calls(), 1);
233    }
234
235    #[tokio::test]
236    async fn a_host_error_passes_through() {
237        let exec = FakeExecutor::new(Err("not a member of that team".to_string()));
238        let adapter = HostChannelAdapter::new(exec.clone());
239
240        let err = adapter.send(&msg(false)).await.unwrap_err();
241        assert_eq!(err, "not a member of that team");
242    }
243
244    #[tokio::test]
245    async fn a_blank_message_id_is_treated_as_absent() {
246        let exec = FakeExecutor::new(Ok(json!({ "message_id": "   " })));
247        let adapter = HostChannelAdapter::new(exec.clone());
248
249        let receipt = adapter.send(&msg(false)).await.unwrap();
250        assert_eq!(receipt.message_id, None);
251    }
252}