Skip to main content

cookie_agent_engine/runtime/
messaging_api.rs

1//! Agent-to-agent messaging: tree-peer authorization, chain and congestion
2//! guards, and durable producer-backed delivery for the `send_message` tool
3//! surface.
4//!
5//! Delivery always goes through the producer mailbox path: the message is
6//! durably accepted as a `ProducerMessageAccepted` event on the recipient
7//! session, and the existing producer machinery admits it into the recipient's
8//! run at the next safe boundary (`steer`), claims it at the next run start
9//! (`queue`), or wakes an idle/finished session through post-send reconcile.
10//! `SessionCommand::Steer` is intentionally not used for agent mail; using
11//! both paths would double-deliver the same message.
12//!
13//! Both guards are enforced at acceptance: `max_hops` bounds inherited chain
14//! depth (computed from the sender's log) and `max_inflight_per_pair` bounds
15//! one directed sender→recipient window of unacknowledged mail. A limit of `0`
16//! disables its guard.
17
18use cookie_agent_protocol::{
19    ProducerDeliveryMode, ProducerId, ProducerIdempotencyKey, ProducerMessageId, ProducerOwner,
20    RunId, SessionId, SessionOrigin, SessionProducersParams, SessionStatus, ToolCallId,
21};
22
23use super::producers::{ProducerAuthority, ProducerCommand, producer_description};
24use super::{Engine, EngineError, SessionCommand};
25
26/// Stable `send_message:<code>` error codes carried by
27/// [`EngineError::Messaging`]. The tool layer surfaces these strings verbatim
28/// so sending models can react predictably (retry, give up, or report).
29pub const MESSAGE_DISABLED: &str = "send_message:disabled";
30pub const MESSAGE_INVALID_BODY: &str = "send_message:invalid_body";
31pub const MESSAGE_INVALID_ARGUMENTS: &str = "send_message:invalid_arguments";
32pub const MESSAGE_UNKNOWN_SESSION: &str = "send_message:unknown_session";
33pub const MESSAGE_NOT_TREE_PEER: &str = "send_message:not_tree_peer";
34pub const MESSAGE_SELF_SEND: &str = "send_message:self_send";
35pub const MESSAGE_INBOX_FULL: &str = "send_message:inbox_full";
36pub const MESSAGE_MAX_HOPS_EXCEEDED: &str = "send_message:max_hops_exceeded";
37pub const MESSAGE_INFLIGHT_FULL: &str = "send_message:inflight_full";
38pub const MESSAGE_ENGINE_SHUTDOWN: &str = "send_message:engine_shutdown";
39
40/// Immutable arguments for one `send_message` tool invocation. The sender
41/// triple is engine-derived tool context, never model arguments.
42#[derive(Clone, Debug)]
43pub struct AgentMessageInvocation {
44    pub sender_session_id: SessionId,
45    pub sender_run_id: RunId,
46    pub sender_tool_call_id: ToolCallId,
47    pub recipient_session_id: SessionId,
48    pub body: String,
49    pub mode: ProducerDeliveryMode,
50}
51
52/// Recipient state observed at send time. Delivery mode is never coerced: the
53/// requested mode stays effective in every state (safe-boundary admission
54/// covers running recipients, claim-at-start covers queued ones, and the
55/// post-send producer reconcile wakes idle or finished ones).
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum AgentRecipientState {
58    Running,
59    Queued,
60    WakingFinished,
61}
62
63impl AgentRecipientState {
64    #[must_use]
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::Running => "running",
68            Self::Queued => "queued",
69            Self::WakingFinished => "waking_finished",
70        }
71    }
72}
73
74/// The `send_message` result contract: success means the message is durably
75/// accepted (`ProducerMessageAccepted` persisted in the recipient's event
76/// log), not that the recipient has consumed it.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct AgentMessageHandle {
79    pub message_id: ProducerMessageId,
80    pub mode: ProducerDeliveryMode,
81    pub recipient_state: AgentRecipientState,
82}
83
84fn messaging_error(code: &'static str) -> EngineError {
85    EngineError::Messaging(code.to_owned())
86}
87
88/// Maps a shutdown race (`ActorStopped` from actor, registration, or send
89/// requests) onto the stable tool-facing code; passes others through.
90fn map_shutdown(error: EngineError) -> EngineError {
91    match error {
92        EngineError::ActorStopped => messaging_error(MESSAGE_ENGINE_SHUTDOWN),
93        other => other,
94    }
95}
96
97/// Maps a missing session onto the stable `unknown_session` code. Malformed
98/// IDs cannot reach this layer; the typed `SessionId` argument rejects them at
99/// the tool boundary.
100fn map_unknown_session(error: EngineError) -> EngineError {
101    match error {
102        EngineError::Session(crate::session::SessionError::Missing(_)) => {
103            messaging_error(MESSAGE_UNKNOWN_SESSION)
104        }
105        other => other,
106    }
107}
108
109/// Renders the durable agent-mail envelope, materialized as a user turn at
110/// the recipient's claim point. The body stays readable to the model; the
111/// sender handle is enough to reply when `send_message` is visible.
112pub(super) fn render_agent_message_envelope(
113    sender_session_id: SessionId,
114    sender_short_id: Option<&str>,
115    body: &str,
116    include_reply_hint: bool,
117) -> String {
118    let sender_label = sender_short_id
119        .map(str::to_owned)
120        .unwrap_or_else(|| sender_session_id.to_string());
121    let reply_hint = include_reply_hint
122        .then(|| format!("\n\n[use `send_message(to=\"{sender_label}\", ...)` to reply]"));
123    format!(
124        "<agent_message from=\"{sender_label}\">\n{}{}\n</agent_message>",
125        body,
126        reply_hint.unwrap_or_default(),
127    )
128}
129
130/// Relationship label for one send: the recipient's relationship to the
131/// sender, derived from stored `SessionOrigin` metadata only. This is purely
132/// labeling for the ordinary permission pipeline; there is no
133/// relationship-based authority check anywhere in the engine.
134pub(crate) fn relationship_label(
135    sender_origin: &SessionOrigin,
136    recipient_origin: &SessionOrigin,
137    sender: SessionId,
138    recipient: SessionId,
139) -> &'static str {
140    let parent_of = |origin: &SessionOrigin| match origin {
141        SessionOrigin::Delegated {
142            parent_session_id, ..
143        } => Some(*parent_session_id),
144        SessionOrigin::Root => None,
145    };
146    let sender_parent = parent_of(sender_origin);
147    let recipient_parent = parent_of(recipient_origin);
148    if sender_parent == Some(recipient) {
149        "parent"
150    } else if recipient_parent == Some(sender) {
151        "child"
152    } else if sender_parent.is_some() && sender_parent == recipient_parent {
153        "sibling"
154    } else {
155        "*"
156    }
157}
158
159impl Engine {
160    /// Whether the recipient's current governing agent exposes the built-in
161    /// `send_message` tool. The provider is checked as well so engine-only
162    /// fixtures without the message provider do not receive a misleading
163    /// reply hint.
164    pub(crate) fn send_message_tool_visible(
165        &self,
166        session: SessionId,
167    ) -> Result<bool, EngineError> {
168        let projection = self.messaging_projection(session)?;
169        let agent = crate::permissions::governing_agent_for_skills(&projection);
170        let visible = crate::permissions::PermissionPipeline::tool_visible_with_overlay(
171            &agent,
172            Some(&projection.permission_overlay),
173            "message",
174        );
175        if !visible {
176            return Ok(false);
177        }
178        let providers = self
179            .inner
180            .tools
181            .lock()
182            .unwrap_or_else(|poisoned| poisoned.into_inner())
183            .clone();
184        Ok(providers.iter().any(|provider| {
185            provider
186                .tools_for_session(&crate::tool_api::SessionToolContext::new(session))
187                .ok()
188                .is_some_and(|tools| tools.iter().any(|tool| tool.name == "send_message"))
189        }))
190    }
191
192    fn messaging_projection(
193        &self,
194        session: SessionId,
195    ) -> Result<crate::session::SessionProjection, EngineError> {
196        self.inner
197            .store
198            .get(session)
199            .map_err(|error| map_unknown_session(error.into()))
200    }
201
202    /// The root session of the delegation tree a session belongs to.
203    pub(crate) fn session_root(&self, session: SessionId) -> Result<SessionId, EngineError> {
204        Ok(match self.messaging_projection(session)?.meta.origin {
205            SessionOrigin::Root => session,
206            SessionOrigin::Delegated {
207                root_session_id, ..
208            } => root_session_id,
209        })
210    }
211
212    /// Relationship label used as the `message` permission resource for a
213    /// sender→recipient pair: `parent`, `child`, `sibling`, or `*`.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`EngineError::Messaging`] with `send_message:unknown_session`
218    /// when either session does not exist.
219    pub fn message_relationship(
220        &self,
221        sender: SessionId,
222        recipient: SessionId,
223    ) -> Result<&'static str, EngineError> {
224        let sender_origin = self.messaging_projection(sender)?.meta.origin;
225        let recipient_origin = self.messaging_projection(recipient)?.meta.origin;
226        Ok(relationship_label(
227            &sender_origin,
228            &recipient_origin,
229            sender,
230            recipient,
231        ))
232    }
233
234    /// Tree-peer authorization: two sessions may exchange mail iff they carry
235    /// the same stored `root_session_id`. No live registry walk, ancestry, or
236    /// taint validation (spec Resolved Decision 1; ghost sessions left by a
237    /// reverted branch remain reachable by design).
238    pub(crate) fn ensure_tree_peer(
239        &self,
240        sender: SessionId,
241        recipient: SessionId,
242    ) -> Result<(), EngineError> {
243        if sender == recipient {
244            return Err(messaging_error(MESSAGE_SELF_SEND));
245        }
246        if self.session_root(sender)? != self.session_root(recipient)? {
247            return Err(messaging_error(MESSAGE_NOT_TREE_PEER));
248        }
249        Ok(())
250    }
251
252    pub(crate) fn agent_recipient_state(
253        &self,
254        recipient: SessionId,
255    ) -> Result<AgentRecipientState, EngineError> {
256        let projection = self.messaging_projection(recipient)?;
257        let running = projection.status == SessionStatus::Running
258            || self
259                .inner
260                .sessions
261                .active
262                .lock()
263                .unwrap_or_else(|error| error.into_inner())
264                .values()
265                .any(|active| active.session == recipient);
266        if running {
267            return Ok(AgentRecipientState::Running);
268        }
269        if matches!(
270            projection.status,
271            SessionStatus::Completed
272                | SessionStatus::Failed
273                | SessionStatus::Interrupted
274                | SessionStatus::Cancelled
275        ) {
276            return Ok(AgentRecipientState::WakingFinished);
277        }
278        // Idle and delegation-queued recipients share one state: the durable
279        // acceptance lands now and is claimed when that recipient's next run
280        // starts.
281        Ok(AgentRecipientState::Queued)
282    }
283
284    /// Durably accepts one agent message into the recipient's producer inbox.
285    ///
286    /// The sender's `ProducerOwner::Agent` registration on the recipient is
287    /// found-or-registered per send; registrations are runtime-only by
288    /// contract, so re-registration after a process restart is expected and
289    /// cheap. The idempotency key derives from `(sender session, run, tool
290    /// call)`: a retried call returns the original `message_id` and delivers
291    /// once. The inbox-cap check, the pair-window check, the hop guard, and the
292    /// acceptance run atomically inside the recipient's actor
293    /// (`ProducerCommand::SendAgentMessage`).
294    ///
295    /// Chain depth is computed here, from the *sender's* log, because the hop
296    /// basis is the sender run's own durable observation of agent mail — a
297    /// different actor's state that the recipient must not reach into. Depth is
298    /// monotonic within a run, so two concurrent sends from one run resolve to
299    /// the same value, which is the correct answer for both.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`EngineError::Messaging`] stable codes for disabled messaging,
304    /// invalid bodies, unknown recipients, non-tree-peer recipients, self
305    /// sends, full inboxes, exhausted pair windows, chains past `max_hops`,
306    /// and shutdown races; [`EngineError::Producer`] for idempotency payload
307    /// conflicts.
308    pub async fn send_agent_message(
309        &self,
310        invocation: AgentMessageInvocation,
311    ) -> Result<AgentMessageHandle, EngineError> {
312        let messaging = &self.inner.config.runtime.messaging;
313        if !messaging.enabled {
314            return Err(messaging_error(MESSAGE_DISABLED));
315        }
316        if invocation.body.trim().is_empty() || invocation.body.len() > messaging.max_body_bytes {
317            return Err(messaging_error(MESSAGE_INVALID_BODY));
318        }
319        self.ensure_tree_peer(
320            invocation.sender_session_id,
321            invocation.recipient_session_id,
322        )?;
323        let recipient_state = self.agent_recipient_state(invocation.recipient_session_id)?;
324        let include_reply_hint = self.send_message_tool_visible(invocation.recipient_session_id)?;
325        let sender = self.messaging_projection(invocation.sender_session_id)?;
326        let sender_agent_type = sender.creation_agent.agent.to_string();
327        let hop = self
328            .goal_producer_projection(invocation.sender_session_id)?
329            .inherited_agent_hop(invocation.sender_run_id);
330        let authority = ProducerAuthority {
331            owner: ProducerOwner::Agent {
332                session_id: invocation.sender_session_id,
333            },
334            connection_epoch: None,
335        };
336        let producer_id = self
337            .find_or_register_agent_producer(invocation.recipient_session_id, &authority)
338            .await
339            .map_err(map_shutdown)?;
340        let key = ProducerIdempotencyKey::new(format!(
341            "agent-message:{}:{}:{}",
342            invocation.sender_session_id, invocation.sender_run_id, invocation.sender_tool_call_id
343        ))
344        .expect("agent message idempotency key fits the producer key bound");
345        let description = producer_description("Agent message from ", &sender_agent_type);
346        let message_id = self
347            .request(invocation.recipient_session_id, |reply| {
348                SessionCommand::Producer(ProducerCommand::SendAgentMessage {
349                    authority,
350                    producer_id,
351                    mode: invocation.mode,
352                    key,
353                    description,
354                    sender: invocation.sender_session_id,
355                    sender_agent_type,
356                    body: invocation.body,
357                    include_reply_hint,
358                    hop,
359                    reply,
360                })
361            })
362            .await
363            .map_err(map_shutdown)?;
364        Ok(AgentMessageHandle {
365            message_id,
366            mode: invocation.mode,
367            recipient_state,
368        })
369    }
370
371    async fn find_or_register_agent_producer(
372        &self,
373        recipient: SessionId,
374        authority: &ProducerAuthority,
375    ) -> Result<ProducerId, EngineError> {
376        let producers = self
377            .session_producers(SessionProducersParams {
378                session_id: recipient,
379            })
380            .await?;
381        if let Some(registration) = producers
382            .producers
383            .into_iter()
384            .find(|registration| registration.producer_owner == authority.owner)
385        {
386            return Ok(registration.producer_id);
387        }
388        self.register_producer(recipient, authority.clone()).await
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::render_agent_message_envelope;
395    use cookie_agent_protocol::SessionId;
396
397    #[test]
398    fn agent_message_envelope_keeps_body_readable_and_reply_hint_optional() {
399        let sender = SessionId::new_v7();
400        let with_hint = render_agent_message_envelope(
401            sender,
402            Some("explore_1a2b3c4d"),
403            "Message written by the sending agent",
404            true,
405        );
406        assert_eq!(
407            with_hint,
408            "<agent_message from=\"explore_1a2b3c4d\">\nMessage written by the sending agent\n\n[use `send_message(to=\"explore_1a2b3c4d\", ...)` to reply]\n</agent_message>"
409        );
410
411        let without_hint = render_agent_message_envelope(
412            sender,
413            Some("explore_1a2b3c4d"),
414            "Message written by the sending agent",
415            false,
416        );
417        assert_eq!(
418            without_hint,
419            "<agent_message from=\"explore_1a2b3c4d\">\nMessage written by the sending agent\n</agent_message>"
420        );
421    }
422}