Skip to main content

meerkat_core/
interaction.rs

1//! Interaction types for the core agent loop.
2//!
3//! These types provide a simplified adapter layer in core (no comms dependency).
4//! `CommsContent` in meerkat-comms remains canonical with richer types.
5//! The comms runtime converts at the boundary.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use uuid::Uuid;
10
11use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
12
13/// Unique identifier for an interaction.
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
17
18impl std::fmt::Display for InteractionId {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        self.0.fmt(f)
21    }
22}
23
24/// Typed status for response interactions.
25///
26/// Mirrors `CommsStatus` from `meerkat-comms` — the comms runtime converts at the boundary.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ResponseStatus {
30    Accepted,
31    Completed,
32    Failed,
33}
34
35/// Simplified interaction content for the core agent loop.
36///
37/// This is an adapter type — `CommsContent` in meerkat-comms has richer types
38/// (`MessageIntent`, `CommsStatus`, etc.). The comms runtime converts at the boundary.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum InteractionContent {
42    /// A simple text message.
43    Message {
44        body: String,
45        /// Optional multimodal content blocks.
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        blocks: Option<Vec<ContentBlock>>,
48    },
49    /// A request for the agent to perform an action.
50    Request { intent: String, params: Value },
51    /// A response to a previous request.
52    Response {
53        in_reply_to: InteractionId,
54        status: ResponseStatus,
55        result: Value,
56    },
57}
58
59/// An interaction drained from the inbox, ready for classification.
60#[derive(Debug, Clone)]
61pub struct InboxInteraction {
62    /// Unique identifier for this interaction.
63    pub id: InteractionId,
64    /// Who sent this interaction (peer name or source label).
65    pub from: String,
66    /// The interaction content.
67    pub content: InteractionContent,
68    /// Pre-rendered text suitable for injection into an LLM session.
69    pub rendered_text: String,
70    /// Runtime-owned handling hint for ordinary work admitted from plain events.
71    pub handling_mode: HandlingMode,
72    /// Optional normalized rendering metadata carried alongside the interaction.
73    pub render_metadata: Option<RenderMetadata>,
74}
75
76/// Canonical model-facing text projection for an external event.
77///
78/// The visible identity of an external event is its source label
79/// (`webhook`, `rpc`, `stdin`, etc.). Optional body text may follow, but
80/// structured payload remains typed metadata rather than prompt text.
81pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
82    let label = format!("[EVENT via {source_name}]");
83    let body = body.map(str::trim).filter(|body| !body.is_empty());
84
85    match body {
86        Some(body) => format!("{label} {body}"),
87        None => label,
88    }
89}
90
91/// Classification result for incoming peer/event traffic.
92///
93/// Stored with each inbox entry at ingress time. Downstream consumers
94/// switch on this enum instead of re-classifying.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum PeerInputClass {
97    /// A peer message that should route through canonical runtime admission.
98    ActionableMessage,
99    /// A peer request that should route through canonical runtime admission.
100    ActionableRequest,
101    /// A response to a previous outbound request (non-interrupting context).
102    Response,
103    /// Peer added lifecycle event.
104    PeerLifecycleAdded,
105    /// Peer retired lifecycle event.
106    PeerLifecycleRetired,
107    /// Peer unwired lifecycle event.
108    PeerLifecycleUnwired,
109    /// Member kickoff failed lifecycle event.
110    PeerLifecycleKickoffFailed,
111    /// Member kickoff cancelled lifecycle event.
112    PeerLifecycleKickoffCancelled,
113    /// A request whose intent is in the silent-intents set (inline-only, no LLM turn).
114    SilentRequest,
115    /// An ack envelope (filtered at ingress, never reaches agent loop).
116    Ack,
117    /// A plain (unauthenticated) event from an external source.
118    PlainEvent,
119}
120
121impl PeerInputClass {
122    /// Returns true if this class is actionable runtime ingress.
123    pub fn is_actionable(&self) -> bool {
124        matches!(
125            self,
126            Self::ActionableMessage
127                | Self::ActionableRequest
128                | Self::Response
129                | Self::PlainEvent
130                | Self::PeerLifecycleKickoffFailed
131                | Self::PeerLifecycleKickoffCancelled
132        )
133    }
134}
135
136/// Canonical peer/event ingress candidate handed to runtime admission.
137///
138/// This is the typed, machine-authored drain unit for runtime-backed peer
139/// ingress. It preserves ingress classification so downstream code does not
140/// re-derive semantics after drain.
141#[derive(Debug, Clone)]
142pub struct PeerInputCandidate {
143    /// The original interaction data.
144    pub interaction: InboxInteraction,
145    /// Pre-computed classification from ingress.
146    pub class: PeerInputClass,
147    /// For lifecycle events, the peer name that was added/retired.
148    pub lifecycle_peer: Option<String>,
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn interaction_id_json_roundtrip() {
158        let id = InteractionId(Uuid::new_v4());
159        let json = serde_json::to_string(&id).unwrap();
160        let parsed: InteractionId = serde_json::from_str(&json).unwrap();
161        assert_eq!(id, parsed);
162    }
163
164    #[test]
165    fn interaction_content_message_json_roundtrip() {
166        let content = InteractionContent::Message {
167            body: "hello".to_string(),
168            blocks: None,
169        };
170        let json = serde_json::to_value(&content).unwrap();
171        assert_eq!(json["type"], "message");
172        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
173        assert_eq!(content, parsed);
174    }
175
176    #[test]
177    fn interaction_content_request_json_roundtrip() {
178        let content = InteractionContent::Request {
179            intent: "review".to_string(),
180            params: serde_json::json!({"pr": 42}),
181        };
182        let json = serde_json::to_value(&content).unwrap();
183        assert_eq!(json["type"], "request");
184        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
185        assert_eq!(content, parsed);
186    }
187
188    #[test]
189    fn interaction_content_response_json_roundtrip() {
190        let id = InteractionId(Uuid::new_v4());
191        let content = InteractionContent::Response {
192            in_reply_to: id,
193            status: ResponseStatus::Completed,
194            result: serde_json::json!({"ok": true}),
195        };
196        let json = serde_json::to_value(&content).unwrap();
197        assert_eq!(json["type"], "response");
198        assert_eq!(json["status"], "completed");
199        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
200        assert_eq!(content, parsed);
201    }
202
203    #[test]
204    fn response_status_json_roundtrip_all_variants() {
205        for (variant, expected_str) in [
206            (ResponseStatus::Accepted, "accepted"),
207            (ResponseStatus::Completed, "completed"),
208            (ResponseStatus::Failed, "failed"),
209        ] {
210            let json = serde_json::to_value(variant).unwrap();
211            assert_eq!(json, expected_str);
212            let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
213            assert_eq!(variant, parsed);
214        }
215    }
216
217    #[test]
218    fn interaction_message_with_blocks_roundtrip() {
219        let content = InteractionContent::Message {
220            body: "hello".to_string(),
221            blocks: Some(vec![
222                ContentBlock::Text {
223                    text: "hello".to_string(),
224                },
225                ContentBlock::Image {
226                    media_type: "image/png".to_string(),
227                    data: "iVBORw0KGgo=".into(),
228                },
229            ]),
230        };
231        let json = serde_json::to_value(&content).unwrap();
232        assert_eq!(json["type"], "message");
233        assert!(json["blocks"].is_array());
234        let parsed: InteractionContent = serde_json::from_value(json).unwrap();
235        assert_eq!(content, parsed);
236    }
237
238    #[test]
239    fn inbox_interaction_preserves_runtime_hints() {
240        let interaction = InboxInteraction {
241            id: InteractionId(Uuid::new_v4()),
242            from: "event:webhook".into(),
243            content: InteractionContent::Message {
244                body: "hello".into(),
245                blocks: None,
246            },
247            rendered_text: "[EVENT via webhook] hello".into(),
248            handling_mode: HandlingMode::Steer,
249            render_metadata: Some(RenderMetadata {
250                class: crate::types::RenderClass::SystemNotice,
251                salience: crate::types::RenderSalience::Urgent,
252            }),
253        };
254
255        assert_eq!(interaction.handling_mode, HandlingMode::Steer);
256        assert!(interaction.render_metadata.is_some());
257    }
258
259    #[test]
260    fn interaction_message_without_blocks_compat() {
261        // Old format (no blocks field) should deserialize with blocks: None
262        let old_json = r#"{"type":"message","body":"hello"}"#;
263        let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
264        match parsed {
265            InteractionContent::Message { body, blocks } => {
266                assert_eq!(body, "hello");
267                assert_eq!(blocks, None);
268            }
269            other => panic!("Expected Message, got {other:?}"),
270        }
271
272        // Serialize with blocks: None should omit the field
273        let content = InteractionContent::Message {
274            body: "test".to_string(),
275            blocks: None,
276        };
277        let json = serde_json::to_string(&content).unwrap();
278        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
279        assert!(
280            value.get("blocks").is_none(),
281            "blocks: None should not appear in JSON"
282        );
283    }
284}