Skip to main content

entelix_core/ir/
message.rs

1//! `Message` and `Role` — the conversational unit shared by every codec.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::content::{ContentPart, ToolResultContent};
6
7/// Conversational role assigned to a `Message`.
8#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Role {
12    /// Free-form user input.
13    User,
14    /// Model-produced reply (or partial reply during streaming).
15    Assistant,
16    /// System / instruction message. Some providers carry this out-of-band
17    /// (Anthropic `system` field); codecs handle the placement.
18    System,
19    /// A tool result message authored by the harness on behalf of a tool.
20    Tool,
21}
22
23/// A single turn in the conversation.
24///
25/// `content` is always a list of [`ContentPart`]s. Codecs that accept a string
26/// shorthand are responsible for collapsing a single `ContentPart::Text` to a
27/// bare string at encode time.
28///
29/// `Message` is an open data carrier: codec/runnable internals
30/// pattern-match exhaustively against the IR, so the type stays
31/// constructable via struct-literal syntax. New IR signals land as
32/// additional `ContentPart` variants (which `ContentPart`'s
33/// `#[non_exhaustive]` covers) or as new helpers on `Message`.
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35pub struct Message {
36    /// Who authored this message.
37    pub role: Role,
38    /// One or more content parts. Empty content is permitted (some providers
39    /// emit empty assistant messages alongside tool calls).
40    pub content: Vec<ContentPart>,
41}
42
43impl Message {
44    /// Construct a message with a typed role + content list. Use the
45    /// role-specific helpers (`user` / `assistant` / `system` / `tool_*`)
46    /// for the common single-text-part cases; reach for `new` when
47    /// assembling multi-part content (multimodal, tool-use blocks, etc.).
48    #[must_use]
49    pub fn new(role: Role, content: Vec<ContentPart>) -> Self {
50        Self { role, content }
51    }
52
53    /// Convenience: `user` message with a single text part.
54    pub fn user(text: impl Into<String>) -> Self {
55        Self {
56            role: Role::User,
57            content: vec![ContentPart::text(text)],
58        }
59    }
60
61    /// Convenience: `assistant` message with a single text part.
62    pub fn assistant(text: impl Into<String>) -> Self {
63        Self {
64            role: Role::Assistant,
65            content: vec![ContentPart::text(text)],
66        }
67    }
68
69    /// Convenience: `system` message with a single text part.
70    pub fn system(text: impl Into<String>) -> Self {
71        Self {
72            role: Role::System,
73            content: vec![ContentPart::text(text)],
74        }
75    }
76
77    /// Convenience: `tool` message wrapping a tool's reply to a
78    /// prior [`ContentPart::ToolUse`]. Mirrors LangChain's
79    /// `ToolMessage(content=…, tool_call_id=…, name=…)` shape so
80    /// the RAG / agent loop reads as a one-line append after each
81    /// tool call instead of hand-constructing a `Message { role:
82    /// Role::Tool, content: vec![ContentPart::ToolResult { … }] }`.
83    ///
84    /// Both `tool_use_id` and `name` are required: Anthropic /
85    /// OpenAI / Bedrock correlate by id, but Gemini's
86    /// `functionResponse` keys by `name`. Carrying both keeps
87    /// the IR provider-neutral so a single agent harness works
88    /// across all four codecs without per-vendor adaptation.
89    ///
90    /// `output` accepts any string-like — for structured payloads,
91    /// use [`Self::tool_result_json`] and the codec will emit native
92    /// JSON (or stringify with a `LossyEncode` warning if the
93    /// provider lacks structured tool-result support).
94    pub fn tool_result(
95        tool_use_id: impl Into<String>,
96        name: impl Into<String>,
97        output: impl Into<String>,
98    ) -> Self {
99        Self {
100            role: Role::Tool,
101            content: vec![ContentPart::ToolResult {
102                tool_use_id: tool_use_id.into(),
103                name: name.into(),
104                content: ToolResultContent::Text(output.into()),
105                is_error: false,
106                cache_control: None,
107                provider_echoes: Vec::new(),
108            }],
109        }
110    }
111
112    /// Same as [`Self::tool_result`] but carries a structured JSON
113    /// payload. Use when the tool returns objects/arrays the model
114    /// should reason over without re-parsing a stringified blob.
115    pub fn tool_result_json(
116        tool_use_id: impl Into<String>,
117        name: impl Into<String>,
118        output: serde_json::Value,
119    ) -> Self {
120        Self {
121            role: Role::Tool,
122            content: vec![ContentPart::ToolResult {
123                tool_use_id: tool_use_id.into(),
124                name: name.into(),
125                content: ToolResultContent::Json(output),
126                is_error: false,
127                cache_control: None,
128                provider_echoes: Vec::new(),
129            }],
130        }
131    }
132
133    /// Same as [`Self::tool_result`] but flagged as an error reply.
134    /// Anthropic and Bedrock surface the `is_error` flag natively;
135    /// other codecs prefix the text or emit a `LossyEncode` warning.
136    pub fn tool_error(
137        tool_use_id: impl Into<String>,
138        name: impl Into<String>,
139        output: impl Into<String>,
140    ) -> Self {
141        Self {
142            role: Role::Tool,
143            content: vec![ContentPart::ToolResult {
144                tool_use_id: tool_use_id.into(),
145                name: name.into(),
146                content: ToolResultContent::Text(output.into()),
147                is_error: true,
148                cache_control: None,
149                provider_echoes: Vec::new(),
150            }],
151        }
152    }
153}