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}