Skip to main content

pe_core/
message.rs

1//! Message type hierarchy — flows through every agent and LLM call.
2//!
3//! Based on Group 16 of the pre-plan. Every message has content, optional id,
4//! optional name, and provider-specific metadata passthrough.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10/// Top-level message enum — all message types the system handles.
11///
12/// # REVIEW(002): `#[non_exhaustive]` per library-design.md
13/// This enum will grow (e.g. FunctionMessage, ToolResultMessage, RemoveMessage).
14/// Without non_exhaustive, adding a variant is a breaking change for downstream
15/// match arms. With it, users must have a `_ =>` arm, enabling safe evolution.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "role")]
18#[non_exhaustive]
19pub enum Message {
20    Human(HumanMessage),
21    Ai(AiMessage),
22    System(SystemMessage),
23    Tool(ToolMessage),
24}
25
26/// User input — can contain text, images, audio, files (multimodal)
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct HumanMessage {
29    pub content: MessageContent,
30    #[serde(default)]
31    pub id: Option<String>,
32    #[serde(default)]
33    pub name: Option<String>,
34}
35
36/// LLM output — contains tool calls, usage metadata, provider metadata
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AiMessage {
39    pub content: MessageContent,
40    #[serde(default)]
41    pub tool_calls: Vec<ToolCall>,
42    #[serde(default)]
43    pub invalid_tool_calls: Vec<InvalidToolCall>,
44    #[serde(default)]
45    pub usage_metadata: Option<UsageMetadata>,
46    #[serde(default)]
47    pub response_metadata: HashMap<String, serde_json::Value>,
48    #[serde(default)]
49    pub id: Option<String>,
50}
51
52/// Instructions injected before conversation
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SystemMessage {
55    pub content: String,
56    #[serde(default)]
57    pub id: Option<String>,
58}
59
60/// Result of a tool execution — must match tool_call_id from AiMessage
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ToolMessage {
63    pub content: String,
64    pub tool_call_id: String,
65    #[serde(default)]
66    pub name: Option<String>,
67    #[serde(default)]
68    pub id: Option<String>,
69    /// Tool-specific metadata from execution (result count, source, etc.).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub metadata: Option<HashMap<String, Value>>,
72    /// Execution duration in milliseconds.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub duration_ms: Option<u64>,
75}
76
77/// Message content — either plain text or multimodal content blocks
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(untagged)]
80#[non_exhaustive]
81pub enum MessageContent {
82    Text(String),
83    Blocks(Vec<ContentBlock>),
84}
85
86/// Content block types for multimodal messages
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(tag = "type")]
89#[non_exhaustive]
90pub enum ContentBlock {
91    Text { text: String },
92    Image { url: String },
93    Audio { data: String },
94    File { path: String, mime_type: String },
95    Reasoning { content: String },
96    Citation { source: String, quote: String },
97}
98
99/// Structured tool invocation request from LLM
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ToolCall {
102    pub id: String,
103    pub name: String,
104    pub args: serde_json::Value,
105}
106
107/// Malformed tool call — LLM tried but failed schema validation
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct InvalidToolCall {
110    pub id: String,
111    pub name: String,
112    pub args: String,
113    pub error: String,
114}
115
116/// Token usage from a single LLM call
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UsageMetadata {
119    pub input_tokens: u32,
120    pub output_tokens: u32,
121    pub total_tokens: u32,
122    #[serde(default)]
123    pub input_token_details: Option<InputTokenDetails>,
124    #[serde(default)]
125    pub output_token_details: Option<OutputTokenDetails>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129pub struct InputTokenDetails {
130    #[serde(default)]
131    pub cache_read: u32,
132    #[serde(default)]
133    pub cache_write: u32,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct OutputTokenDetails {
138    #[serde(default)]
139    pub reasoning_tokens: u32,
140}
141
142/// Sentinel value to clear a message list entirely
143pub const REMOVE_ALL_MESSAGES: &str = "__REMOVE_ALL_MESSAGES__";
144
145impl Message {
146    pub fn human(content: impl Into<String>) -> Self {
147        Message::Human(HumanMessage {
148            content: MessageContent::Text(content.into()),
149            id: None,
150            name: None,
151        })
152    }
153
154    pub fn system(content: impl Into<String>) -> Self {
155        Message::System(SystemMessage {
156            content: content.into(),
157            id: None,
158        })
159    }
160
161    pub fn ai(content: impl Into<String>) -> Self {
162        Message::Ai(AiMessage {
163            content: MessageContent::Text(content.into()),
164            tool_calls: vec![],
165            invalid_tool_calls: vec![],
166            usage_metadata: None,
167            response_metadata: HashMap::new(),
168            id: None,
169        })
170    }
171
172    pub fn tool(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
173        Message::Tool(ToolMessage {
174            content: content.into(),
175            tool_call_id: tool_call_id.into(),
176            name: None,
177            id: None,
178            metadata: None,
179            duration_ms: None,
180        })
181    }
182
183    /// Create a tool message with metadata and duration.
184    pub fn tool_with_metadata(
185        content: impl Into<String>,
186        tool_call_id: impl Into<String>,
187        metadata: Option<HashMap<String, Value>>,
188        duration_ms: Option<u64>,
189    ) -> Self {
190        Message::Tool(ToolMessage {
191            content: content.into(),
192            tool_call_id: tool_call_id.into(),
193            name: None,
194            id: None,
195            metadata,
196            duration_ms,
197        })
198    }
199
200    pub fn id(&self) -> Option<&str> {
201        match self {
202            Message::Human(m) => m.id.as_deref(),
203            Message::Ai(m) => m.id.as_deref(),
204            Message::System(m) => m.id.as_deref(),
205            Message::Tool(m) => m.id.as_deref(),
206        }
207    }
208}
209
210impl MessageContent {
211    pub fn as_text(&self) -> Option<&str> {
212        match self {
213            MessageContent::Text(t) => Some(t),
214            MessageContent::Blocks(_) => None,
215        }
216    }
217}
218
219// NOTE: add_messages reducer lives in reducers.rs (single source of truth).
220// The canonical version is pe_core::reducers::add_messages, re-exported at crate root.