Skip to main content

talos_core/
message.rs

1//! Core message types and event protocol.
2
3use serde::{Deserialize, Serialize};
4
5use crate::tool::ToolProvenance;
6
7/// Provider-side caching behavior for a system prompt range.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum SystemCacheType {
11    /// Cache this prompt range ephemerally when the provider supports it.
12    Ephemeral,
13}
14
15/// A byte range in the system prompt that is stable enough for provider caching.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct SystemCacheMarker {
18    /// Starting byte offset in the system prompt content.
19    pub offset: usize,
20    /// Length of the cacheable range in bytes.
21    pub length: usize,
22    /// Cache behavior requested for this range.
23    pub cache_type: SystemCacheType,
24}
25
26/// A tool call requested by the assistant.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct ToolCall {
29    /// Unique identifier for this tool call.
30    pub id: String,
31    /// Name of the tool to invoke.
32    pub name: String,
33    /// JSON-encoded arguments for the tool.
34    pub input: serde_json::Value,
35}
36
37/// Result of a tool execution (message-layer).
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct MessageToolResult {
40    /// ID of the tool call this result corresponds to.
41    pub tool_use_id: String,
42    /// Text output from the tool.
43    pub content: String,
44    /// Whether the tool execution failed.
45    pub is_error: bool,
46}
47
48/// A message in the conversation.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(tag = "role", rename_all = "snake_case")]
51pub enum Message {
52    /// System-level instruction (identity, rules, tool guide).
53    System {
54        /// System prompt content.
55        content: String,
56        /// Stable prompt ranges suitable for provider-side caching.
57        #[serde(default, skip_serializing_if = "Vec::is_empty")]
58        cache_markers: Vec<SystemCacheMarker>,
59    },
60    /// Workspace context (AGENTS.md, history summary, retrieved files).
61    Context {
62        /// Context content.
63        content: String,
64    },
65    /// Message from the user.
66    User {
67        /// The user's message text.
68        content: String,
69    },
70    /// Response from the assistant.
71    Assistant {
72        /// The assistant's response text.
73        content: String,
74        /// Tool calls requested by the assistant.
75        #[serde(default, skip_serializing_if = "Vec::is_empty")]
76        tool_calls: Vec<ToolCall>,
77    },
78    /// Result of a tool execution.
79    Tool {
80        /// The tool result.
81        result: MessageToolResult,
82    },
83}
84
85/// Reason the assistant stopped generating.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87#[serde(rename_all = "snake_case")]
88pub enum StopReason {
89    /// Assistant finished its response.
90    EndTurn,
91    /// Assistant wants to call a tool.
92    ToolUse,
93    /// Reached the maximum token limit.
94    MaxTokens,
95}
96
97/// Token usage statistics for a turn.
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99pub struct Usage {
100    /// Tokens in the input prompt.
101    pub input_tokens: u32,
102    /// Tokens generated by the model.
103    pub output_tokens: u32,
104    /// Tokens read from cache.
105    #[serde(default)]
106    pub cache_read_tokens: u32,
107    /// Tokens written to cache.
108    #[serde(default)]
109    pub cache_write_tokens: u32,
110}
111
112/// Events emitted during a turn for streaming.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114#[serde(tag = "type", rename_all = "snake_case")]
115#[non_exhaustive]
116pub enum AgentEvent {
117    /// Turn has started.
118    TurnStart,
119    /// A text delta was received from the provider.
120    TextDelta {
121        /// The text chunk.
122        delta: String,
123    },
124    /// Tool call detected: parameters still streaming.
125    ToolCallStarted {
126        /// Name of the tool being called.
127        name: String,
128    },
129    /// A tool call was requested.
130    ToolCall {
131        /// The tool call details.
132        call: ToolCall,
133        /// The provenance of the tool being called.
134        provenance: ToolProvenance,
135        /// Fields to display in the TUI summary (from tool summary_fields()).
136        summary_fields: Vec<String>,
137    },
138    /// A tool call completed.
139    ToolResult {
140        /// The tool result.
141        result: MessageToolResult,
142    },
143    /// Turn has ended.
144    TurnEnd {
145        /// Why the turn ended.
146        stop_reason: StopReason,
147        /// Token usage for this turn.
148        usage: Usage,
149    },
150    /// An error occurred.
151    Error {
152        /// Error message.
153        message: String,
154    },
155}
156
157#[cfg(test)]
158#[allow(warnings)]
159#[allow(warnings)]
160#[allow(warnings)]
161#[allow(warnings)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn message_roundtrip_user() {
167        let msg = Message::User {
168            content: "Hello, world!".into(),
169        };
170        let json = serde_json::to_string(&msg).unwrap();
171        let decoded: Message = serde_json::from_str(&json).unwrap();
172        assert_eq!(msg, decoded);
173    }
174
175    #[test]
176    fn message_roundtrip_assistant() {
177        let msg = Message::Assistant {
178            content: "I can help with that.".into(),
179            tool_calls: vec![ToolCall {
180                id: "call_1".into(),
181                name: "read_file".into(),
182                input: serde_json::json!({"path": "src/main.rs"}),
183            }],
184        };
185        let json = serde_json::to_string(&msg).unwrap();
186        let decoded: Message = serde_json::from_str(&json).unwrap();
187        assert_eq!(msg, decoded);
188    }
189
190    #[test]
191    fn message_roundtrip_tool() {
192        let msg = Message::Tool {
193            result: MessageToolResult {
194                tool_use_id: "call_1".into(),
195                content: "fn main() {}".into(),
196                is_error: false,
197            },
198        };
199        let json = serde_json::to_string(&msg).unwrap();
200        let decoded: Message = serde_json::from_str(&json).unwrap();
201        assert_eq!(msg, decoded);
202    }
203
204    #[test]
205    fn event_roundtrip() {
206        let events = vec![
207            AgentEvent::TurnStart,
208            AgentEvent::TextDelta {
209                delta: "Hello".into(),
210            },
211            AgentEvent::ToolCall {
212                call: ToolCall {
213                    id: "c1".into(),
214                    name: "bash".into(),
215                    input: serde_json::json!({"command": "ls"}),
216                },
217                provenance: ToolProvenance::Native,
218                summary_fields: vec![],
219            },
220            AgentEvent::ToolResult {
221                result: MessageToolResult {
222                    tool_use_id: "c1".into(),
223                    content: "file.rs".into(),
224                    is_error: false,
225                },
226            },
227            AgentEvent::TurnEnd {
228                stop_reason: StopReason::EndTurn,
229                usage: Usage {
230                    input_tokens: 100,
231                    output_tokens: 50,
232                    cache_read_tokens: 80,
233                    cache_write_tokens: 20,
234                },
235            },
236            AgentEvent::Error {
237                message: "something failed".into(),
238            },
239        ];
240        for event in events {
241            let json = serde_json::to_string(&event).unwrap();
242            let decoded: AgentEvent = serde_json::from_str(&json).unwrap();
243            assert_eq!(event, decoded);
244        }
245    }
246}
247
248pub fn extract_tool_calls_from_text(text: &str) -> Vec<ToolCall> {
249    let mut calls = Vec::new();
250    let mut remaining = text;
251
252    while let Some(start) = remaining.find("```json-tool") {
253        let inner_start = start + "```json-tool".len();
254        let inner = remaining[inner_start..].trim_start();
255        let end = inner.find("```").unwrap_or(inner.len());
256        let content = inner[..end].trim();
257
258        if let Ok(obj) = serde_json::from_str::<serde_json::Value>(content)
259            && let (Some(name), Some(args)) = (obj["name"].as_str(), Some(obj["args"].clone()))
260        {
261            calls.push(ToolCall {
262                id: format!("tc_{}", calls.len()),
263                name: name.to_string(),
264                input: args,
265            });
266        }
267
268        remaining = &inner[end..];
269        if end + 3 < remaining.len() {
270            remaining = &remaining[3..];
271        } else {
272            break;
273        }
274    }
275
276    calls
277}
278
279pub fn strip_tool_syntax(text: &str) -> String {
280    let mut result = text.to_string();
281    while let Some(start) = result.find("```json-tool") {
282        let inner_start = start + "```json-tool".len();
283        let inner = &result[inner_start..];
284        let end = inner_start + inner.find("```").unwrap_or(inner.len()) + 3;
285        result.replace_range(start..end, "");
286    }
287    result.trim().to_string()
288}