Skip to main content

telos_agent/model/
message.rs

1//! Message types used in conversations with the model and between tools.
2//!
3//! A conversation is an ordered list of [`Message`]s. Each message has a [`Role`]
4//! (system / user / assistant / tool) and one or more [`ContentBlock`]s. Blocks
5//! carry the actual payload — plain text, a tool call requested by the model,
6//! or the result of executing a tool call.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Speaker of a message in the conversation.
12///
13/// Providers may map these to their own role taxonomies (e.g. OpenAI-compatible
14/// APIs render `Tool` results as separate `tool` messages — see provider implementations).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum Role {
17    /// Instructions/persona supplied before the conversation starts.
18    System,
19    /// Input authored by the human user.
20    User,
21    /// Output produced by the model.
22    Assistant,
23    /// Result of executing a tool call previously requested by the assistant.
24    Tool,
25}
26
27/// A single message in the conversation: a role plus an ordered list of content blocks.
28///
29/// A message can be heterogeneous — an assistant message often contains a
30/// [`TextBlock`] explaining its reasoning followed by one or more [`ToolCall`]s.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Message {
33    pub role: Role,
34    pub blocks: Vec<ContentBlock>,
35}
36
37impl Message {
38    pub fn system(text: impl Into<String>) -> Self {
39        Self::text(Role::System, text)
40    }
41
42    pub fn user(text: impl Into<String>) -> Self {
43        Self::text(Role::User, text)
44    }
45
46    pub fn assistant(text: impl Into<String>) -> Self {
47        Self::text(Role::Assistant, text)
48    }
49
50    pub fn tool(result: ToolResult) -> Self {
51        Self { role: Role::Tool, blocks: vec![ContentBlock::ToolResult(result)] }
52    }
53
54    pub fn tool_results(results: Vec<ToolResult>) -> Self {
55        Self {
56            role: Role::Tool,
57            blocks: results.into_iter().map(ContentBlock::ToolResult).collect(),
58        }
59    }
60
61    pub fn text(role: Role, text: impl Into<String>) -> Self {
62        Self { role, blocks: vec![ContentBlock::text(text)] }
63    }
64
65    pub fn text_content(&self) -> String {
66        self.blocks
67            .iter()
68            .filter_map(|block| match block {
69                ContentBlock::Text(text) => Some(text.text.as_str()),
70                _ => None,
71            })
72            .collect::<Vec<_>>()
73            .join("\n")
74    }
75
76    pub fn thinking_content(&self) -> String {
77        self.blocks
78            .iter()
79            .filter_map(|block| match block {
80                ContentBlock::Thinking(thinking) => Some(thinking.text.as_str()),
81                _ => None,
82            })
83            .collect::<Vec<_>>()
84            .join("\n")
85    }
86
87    pub fn tool_calls(&self) -> impl Iterator<Item = &ToolCall> {
88        self.blocks.iter().filter_map(|block| match block {
89            ContentBlock::ToolCall(call) => Some(call),
90            _ => None,
91        })
92    }
93
94    pub fn tool_results_iter(&self) -> impl Iterator<Item = &ToolResult> {
95        self.blocks.iter().filter_map(|block| match block {
96            ContentBlock::ToolResult(result) => Some(result),
97            _ => None,
98        })
99    }
100}
101
102/// A single piece of content within a [`Message`].
103///
104/// Messages are made of heterogeneous blocks because modern LLMs interleave
105/// natural language, tool invocations, tool outputs, and reasoning traces
106/// within a single turn.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub enum ContentBlock {
109    /// Natural-language text.
110    Text(TextBlock),
111    /// A request from the assistant to invoke a tool.
112    ToolCall(ToolCall),
113    /// The result of having invoked a tool.
114    ToolResult(ToolResult),
115    /// A reasoning trace emitted by a thinking-capable model.
116    Thinking(ThinkingBlock),
117}
118
119/// A plain-text block of content.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct TextBlock {
122    pub text: String,
123}
124
125/// A reasoning trace produced by a thinking-capable model.
126///
127/// Kept separate from [`TextBlock`] so consumers can choose whether to surface
128/// the reasoning to end users.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct ThinkingBlock {
131    /// The raw reasoning text.
132    pub text: String,
133    /// Cryptographic signature provided by some providers (e.g. Claude 3.7
134    /// thinking) to verify the reasoning block was not tampered with.
135    pub signature: Option<String>,
136    /// Whether the reasoning content was redacted by the provider.
137    pub is_redacted: bool,
138}
139
140/// A request from the assistant to invoke a named tool with structured arguments.
141///
142/// `id` is the provider-assigned identifier used to correlate a call with its
143/// [`ToolResult`] on the next turn.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct ToolCall {
146    pub id: String,
147    pub name: String,
148    pub arguments: Value,
149}
150
151/// The outcome of executing a [`ToolCall`].
152///
153/// `tool_call_id` must match the originating call's `id`. `content` is the JSON
154/// payload returned to the model; `is_error` flags execution failures so the
155/// model can recover.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct ToolResult {
158    pub tool_call_id: String,
159    pub name: String,
160    pub content: Value,
161    pub is_error: bool,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum SystemReminder {
166    PlanMode,
167    Compaction { reason: String },
168    ProviderContext,
169    PolicyFeedback { point: String, name: String },
170    ToolResult { tool_name: String, note: String },
171    MemoryInjection { content: String },
172    SkillDiscovery { content: String },
173}
174
175impl SystemReminder {
176    pub fn render(&self) -> String {
177        let body = match self {
178            Self::PlanMode => "You are entering plan mode. Follow the plan instructions and do not write implementation code until the plan is approved.".to_string(),
179            Self::Compaction { reason } => format!(
180                "Prior messages were compacted (reason: {reason}). Some context may have been summarized."
181            ),
182            Self::ProviderContext => "The provider/model context has changed. Adjust to any new instructions or constraints.".to_string(),
183            Self::PolicyFeedback { point, name } => format!(
184                "A policy added feedback at {point} ({name}). Treat it as user feedback."
185            ),
186            Self::ToolResult { tool_name, note } => format!("Tool `{tool_name}` reported: {note}"),
187            Self::MemoryInjection { content } | Self::SkillDiscovery { content } => content.clone(),
188        };
189        format!("<system-reminder>\n{body}\n</system-reminder>")
190    }
191}
192
193impl ContentBlock {
194    /// Create a new text block.
195    pub fn text(text: impl Into<String>) -> Self {
196        ContentBlock::Text(TextBlock { text: text.into() })
197    }
198
199    /// Create a new tool call block.
200    pub fn tool_call(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
201        ContentBlock::ToolCall(ToolCall { id: id.into(), name: name.into(), arguments })
202    }
203
204    /// Create a new tool result block.
205    pub fn tool_result(
206        tool_call_id: impl Into<String>,
207        name: impl Into<String>,
208        content: Value,
209        is_error: bool,
210    ) -> Self {
211        ContentBlock::ToolResult(ToolResult {
212            tool_call_id: tool_call_id.into(),
213            name: name.into(),
214            content,
215            is_error,
216        })
217    }
218
219    /// Create a new thinking block.
220    pub fn thinking(
221        text: impl Into<String>,
222        signature: Option<impl Into<String>>,
223        is_redacted: bool,
224    ) -> Self {
225        ContentBlock::Thinking(ThinkingBlock {
226            text: text.into(),
227            signature: signature.map(|s| s.into()),
228            is_redacted,
229        })
230    }
231}