Skip to main content

cpex_core/cmf/
message.rs

1// Location: ./crates/cpex-core/src/cmf/message.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// CMF Message — canonical message representation.
7//
8// A Message is the storage and wire format for a single turn in a
9// conversation. It preserves structure exactly as the LLM or
10// framework sent it.
11//
12// Extensions are NOT part of the Message. They are passed separately
13// to handlers via the framework's Extensions type. This allows
14// extensions to be shared across payload types and avoids copying
15// the message when extensions change.
16//
17// Mirrors the Python Message in cpex/framework/cmf/message.py.
18
19use serde::{Deserialize, Serialize};
20
21use super::content::*;
22use super::enums::{Channel, Role};
23use crate::hooks::trait_def::PluginResult;
24
25// ---------------------------------------------------------------------------
26// Message
27// ---------------------------------------------------------------------------
28
29/// Canonical CMF message representing a single turn in a conversation.
30///
31/// All content is carried as typed ContentPart variants. Extensions
32/// (identity, security, HTTP, agent context) are passed separately
33/// to handlers — not inside the message.
34///
35/// Mirrors the Python `Message` in `cpex/framework/cmf/message.py`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Message {
38    /// Message schema version.
39    #[serde(default = "default_schema_version")]
40    pub schema_version: String,
41
42    /// Who is speaking.
43    pub role: Role,
44
45    /// List of typed content parts (multimodal).
46    #[serde(default)]
47    pub content: Vec<ContentPart>,
48
49    /// Optional output classification.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub channel: Option<Channel>,
52}
53
54fn default_schema_version() -> String {
55    super::constants::SCHEMA_VERSION.to_string()
56}
57
58impl Message {
59    /// Create a simple text message.
60    pub fn text(role: Role, text: impl Into<String>) -> Self {
61        Self {
62            schema_version: super::constants::SCHEMA_VERSION.to_string(),
63            role,
64            content: vec![ContentPart::Text { text: text.into() }],
65            channel: None,
66        }
67    }
68
69    /// Create a message from an arbitrary list of typed content
70    /// parts. The schema version is set from `SCHEMA_VERSION` —
71    /// callers never hardcode it. Use this when the content isn't a
72    /// single text blob (tool calls, prompt requests, resource refs,
73    /// multimodal mixes).
74    pub fn with_content(role: Role, content: Vec<ContentPart>) -> Self {
75        Self {
76            schema_version: super::constants::SCHEMA_VERSION.to_string(),
77            role,
78            content,
79            channel: None,
80        }
81    }
82
83    /// Extract all text content from the message.
84    ///
85    /// Concatenates text from all `Text` content parts.
86    pub fn get_text_content(&self) -> String {
87        let mut texts = Vec::new();
88        for part in &self.content {
89            if let ContentPart::Text { text } = part {
90                texts.push(text.as_str());
91            }
92        }
93        texts.join("")
94    }
95
96    /// Extract thinking/reasoning content if present.
97    pub fn get_thinking_content(&self) -> Option<String> {
98        let mut texts = Vec::new();
99        for part in &self.content {
100            if let ContentPart::Thinking { text } = part {
101                texts.push(text.as_str());
102            }
103        }
104        if texts.is_empty() {
105            None
106        } else {
107            Some(texts.join(""))
108        }
109    }
110
111    /// Get all tool calls in this message.
112    pub fn get_tool_calls(&self) -> Vec<&ToolCall> {
113        self.content
114            .iter()
115            .filter_map(|part| match part {
116                ContentPart::ToolCall { content } => Some(content),
117                _ => None,
118            })
119            .collect()
120    }
121
122    /// Get all tool results in this message.
123    pub fn get_tool_results(&self) -> Vec<&ToolResult> {
124        self.content
125            .iter()
126            .filter_map(|part| match part {
127                ContentPart::ToolResult { content } => Some(content),
128                _ => None,
129            })
130            .collect()
131    }
132
133    /// Whether this message contains any tool calls.
134    pub fn is_tool_call(&self) -> bool {
135        self.content
136            .iter()
137            .any(|p| matches!(p, ContentPart::ToolCall { .. }))
138    }
139
140    /// Whether this message contains any tool results.
141    pub fn is_tool_result(&self) -> bool {
142        self.content
143            .iter()
144            .any(|p| matches!(p, ContentPart::ToolResult { .. }))
145    }
146
147    /// Get all embedded resources in this message.
148    pub fn get_resources(&self) -> Vec<&Resource> {
149        self.content
150            .iter()
151            .filter_map(|part| match part {
152                ContentPart::Resource { content } => Some(content),
153                _ => None,
154            })
155            .collect()
156    }
157
158    /// Get all resource references in this message.
159    pub fn get_resource_refs(&self) -> Vec<&ResourceReference> {
160        self.content
161            .iter()
162            .filter_map(|part| match part {
163                ContentPart::ResourceRef { content } => Some(content),
164                _ => None,
165            })
166            .collect()
167    }
168
169    /// Get all resource URIs (both embedded and references).
170    pub fn get_all_resource_uris(&self) -> Vec<&str> {
171        self.content
172            .iter()
173            .filter_map(|part| match part {
174                ContentPart::Resource { content } => Some(content.uri.as_str()),
175                ContentPart::ResourceRef { content } => Some(content.uri.as_str()),
176                _ => None,
177            })
178            .collect()
179    }
180
181    /// Whether this message contains any resources or resource references.
182    pub fn has_resources(&self) -> bool {
183        self.content.iter().any(|p| {
184            matches!(
185                p,
186                ContentPart::Resource { .. } | ContentPart::ResourceRef { .. }
187            )
188        })
189    }
190
191    /// Get all prompt requests in this message.
192    pub fn get_prompt_requests(&self) -> Vec<&PromptRequest> {
193        self.content
194            .iter()
195            .filter_map(|part| match part {
196                ContentPart::PromptRequest { content } => Some(content),
197                _ => None,
198            })
199            .collect()
200    }
201
202    /// Get all prompt results in this message.
203    pub fn get_prompt_results(&self) -> Vec<&PromptResult> {
204        self.content
205            .iter()
206            .filter_map(|part| match part {
207                ContentPart::PromptResult { content } => Some(content),
208                _ => None,
209            })
210            .collect()
211    }
212}
213
214// ---------------------------------------------------------------------------
215// MessagePayload — PluginPayload wrapper
216// ---------------------------------------------------------------------------
217
218/// CMF Message wrapped as a PluginPayload for hook dispatch.
219///
220/// This is the payload type for all `cmf.*` hooks. Plugins that
221/// handle CMF hooks implement `HookHandler<CmfHook>` and receive
222/// `&MessagePayload` in their handler.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct MessagePayload {
225    /// The CMF message.
226    pub message: Message,
227}
228
229crate::impl_plugin_payload!(MessagePayload);
230
231// ---------------------------------------------------------------------------
232// CmfHook — Hook Type Definition
233// ---------------------------------------------------------------------------
234
235crate::define_hook! {
236    /// CMF message evaluation hook.
237    ///
238    /// Plugins implement `HookHandler<CmfHook>` and register under
239    /// one or more `cmf.*` hook names (e.g., `cmf.tool_pre_invoke`,
240    /// `cmf.llm_input`). The same handler covers all CMF hook points.
241    CmfHook, "cmf" => {
242        payload: MessagePayload,
243        result: PluginResult<MessagePayload>,
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::hooks::payload::PluginPayload;
251    use crate::hooks::trait_def::HookTypeDef;
252
253    #[test]
254    fn test_message_text_helper() {
255        let msg = Message::text(Role::User, "What is the weather?");
256        assert_eq!(msg.get_text_content(), "What is the weather?");
257        assert_eq!(msg.role, Role::User);
258        assert_eq!(msg.schema_version, "2.0");
259    }
260
261    #[test]
262    fn test_message_multi_part_text() {
263        let msg = Message {
264            schema_version: "2.0".into(),
265            role: Role::Assistant,
266            content: vec![
267                ContentPart::Text {
268                    text: "Hello ".into(),
269                },
270                ContentPart::Text {
271                    text: "world!".into(),
272                },
273            ],
274            channel: None,
275        };
276        assert_eq!(msg.get_text_content(), "Hello world!");
277    }
278
279    #[test]
280    fn test_message_thinking_content() {
281        let msg = Message {
282            schema_version: "2.0".into(),
283            role: Role::Assistant,
284            content: vec![
285                ContentPart::Thinking {
286                    text: "Let me think...".into(),
287                },
288                ContentPart::Text {
289                    text: "Here's my answer.".into(),
290                },
291            ],
292            channel: Some(Channel::Final),
293        };
294        assert_eq!(
295            msg.get_thinking_content(),
296            Some("Let me think...".to_string())
297        );
298        assert_eq!(msg.get_text_content(), "Here's my answer.");
299    }
300
301    #[test]
302    fn test_message_tool_calls() {
303        let msg = Message {
304            schema_version: "2.0".into(),
305            role: Role::Assistant,
306            content: vec![
307                ContentPart::Text {
308                    text: "Let me check.".into(),
309                },
310                ContentPart::ToolCall {
311                    content: ToolCall {
312                        tool_call_id: "tc_001".into(),
313                        name: "get_weather".into(),
314                        arguments: [("city".to_string(), serde_json::json!("London"))].into(),
315                        namespace: None,
316                    },
317                },
318                ContentPart::ToolCall {
319                    content: ToolCall {
320                        tool_call_id: "tc_002".into(),
321                        name: "get_time".into(),
322                        arguments: [("timezone".to_string(), serde_json::json!("UTC"))].into(),
323                        namespace: None,
324                    },
325                },
326            ],
327            channel: None,
328        };
329        assert!(msg.is_tool_call());
330        assert!(!msg.is_tool_result());
331        let calls = msg.get_tool_calls();
332        assert_eq!(calls.len(), 2);
333        assert_eq!(calls[0].name, "get_weather");
334        assert_eq!(calls[1].name, "get_time");
335    }
336
337    #[test]
338    fn test_message_tool_results() {
339        let msg = Message {
340            schema_version: "2.0".into(),
341            role: Role::Tool,
342            content: vec![ContentPart::ToolResult {
343                content: ToolResult {
344                    tool_call_id: "tc_001".into(),
345                    tool_name: "get_weather".into(),
346                    content: serde_json::json!({"temp": 20}),
347                    is_error: false,
348                },
349            }],
350            channel: None,
351        };
352        assert!(msg.is_tool_result());
353        assert!(!msg.is_tool_call());
354        let results = msg.get_tool_results();
355        assert_eq!(results.len(), 1);
356        assert_eq!(results[0].tool_name, "get_weather");
357    }
358
359    #[test]
360    fn test_message_resources() {
361        let msg = Message {
362            schema_version: "2.0".into(),
363            role: Role::Assistant,
364            content: vec![
365                ContentPart::Resource {
366                    content: Resource {
367                        resource_request_id: "rr_001".into(),
368                        uri: "file:///data.txt".into(),
369                        name: Some("Data File".into()),
370                        description: None,
371                        resource_type: super::super::enums::ResourceType::File,
372                        content: Some("file contents".into()),
373                        blob: None,
374                        mime_type: None,
375                        size_bytes: None,
376                        annotations: std::collections::HashMap::new(),
377                        version: None,
378                    },
379                },
380                ContentPart::ResourceRef {
381                    content: ResourceReference {
382                        resource_request_id: "rr_002".into(),
383                        uri: "db://users/42".into(),
384                        name: None,
385                        resource_type: super::super::enums::ResourceType::Database,
386                        range_start: None,
387                        range_end: None,
388                        selector: None,
389                    },
390                },
391            ],
392            channel: None,
393        };
394        assert!(msg.has_resources());
395        assert_eq!(msg.get_resources().len(), 1);
396        assert_eq!(msg.get_resource_refs().len(), 1);
397        let uris = msg.get_all_resource_uris();
398        assert_eq!(uris.len(), 2);
399        assert!(uris.contains(&"file:///data.txt"));
400        assert!(uris.contains(&"db://users/42"));
401    }
402
403    #[test]
404    fn test_message_no_resources() {
405        let msg = Message::text(Role::User, "Hello");
406        assert!(!msg.has_resources());
407        assert!(msg.get_resources().is_empty());
408    }
409
410    #[test]
411    fn test_message_serde_roundtrip() {
412        let msg = Message {
413            schema_version: "2.0".into(),
414            role: Role::Assistant,
415            content: vec![
416                ContentPart::Thinking {
417                    text: "Analyzing...".into(),
418                },
419                ContentPart::Text {
420                    text: "Here's the answer.".into(),
421                },
422                ContentPart::ToolCall {
423                    content: ToolCall {
424                        tool_call_id: "tc_001".into(),
425                        name: "search".into(),
426                        arguments: [("q".to_string(), serde_json::json!("rust"))].into(),
427                        namespace: None,
428                    },
429                },
430            ],
431            channel: Some(Channel::Final),
432        };
433
434        let json = serde_json::to_string(&msg).unwrap();
435        let deserialized: Message = serde_json::from_str(&json).unwrap();
436
437        assert_eq!(deserialized.role, Role::Assistant);
438        assert_eq!(deserialized.schema_version, "2.0");
439        assert_eq!(deserialized.channel, Some(Channel::Final));
440        assert_eq!(deserialized.content.len(), 3);
441        assert_eq!(deserialized.get_text_content(), "Here's the answer.");
442        assert_eq!(deserialized.get_tool_calls().len(), 1);
443    }
444
445    #[test]
446    fn test_message_payload_as_plugin_payload() {
447        let payload = MessagePayload {
448            message: Message::text(Role::User, "Hello"),
449        };
450
451        // Test clone_boxed
452        let boxed: Box<dyn PluginPayload> = Box::new(payload.clone());
453        let cloned = boxed.clone_boxed();
454
455        // Test as_any downcast
456        let downcasted = cloned
457            .as_any()
458            .downcast_ref::<MessagePayload>()
459            .expect("should downcast to MessagePayload");
460        assert_eq!(downcasted.message.get_text_content(), "Hello");
461    }
462
463    #[test]
464    fn test_cmf_hook_type_def() {
465        assert_eq!(CmfHook::NAME, "cmf");
466    }
467
468    #[test]
469    fn test_message_default_schema_version() {
470        let json = r#"{"role":"user","content":[]}"#;
471        let msg: Message = serde_json::from_str(json).unwrap();
472        assert_eq!(msg.schema_version, "2.0");
473    }
474}