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/// One provider-native reasoning block attached to an assistant message.
49///
50/// See ADR-034 for the full boundary design.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum ReasoningBlock {
54    /// Signed thinking (Anthropic `thinking` block). `text` may be empty when
55    /// the provider omits display text; `signature` is opaque and must be
56    /// replayed byte-for-byte, never inspected or trimmed.
57    Thinking {
58        text: String,
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        signature: Option<String>,
61    },
62    /// Encrypted redacted thinking (Anthropic `redacted_thinking`). Replayed
63    /// byte-for-byte; never rendered anywhere.
64    Redacted { data: String },
65    /// Plain reasoning text (OpenAI-compatible `reasoning_content`).
66    Plain { text: String },
67}
68
69/// Reasoning payload for one assistant message, stamped with the identity
70/// that produced it. Request-history metadata only — never display content.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct AssistantReasoning {
73    /// Config provider key that produced the blocks (e.g. `anthropic`, `my-gateway`).
74    pub provider: String,
75    /// Model id that produced the blocks (e.g. `claude-sonnet-4-5`).
76    pub model: String,
77    /// Provider-native blocks in stream order.
78    pub blocks: Vec<ReasoningBlock>,
79}
80
81/// Cryptographic digest of an image attachment's bytes at grant time.
82///
83/// Stored on `ContentPart::Image` so the provider adapter can detect
84/// same-path replacement attacks: when the adapter re-reads the file
85/// at request time, it recomputes the digest and compares. A mismatch
86/// means the file was replaced between grant and read, and the part
87/// MUST be omitted (Owner P1-B security rework, 2026-07-21).
88///
89/// Backed by SHA-256 (`[u8; 32]`). The hash itself is computed in
90/// `talos_cli::image_validation`; talos-core only carries the typed
91/// digest so it has no `sha2` dependency. Serde uses a lowercase hex
92/// string so TLOG dumps remain human-readable.
93///
94/// The default is the all-zero "unverified" sentinel: a freshly
95/// constructed ContentPart that has not yet been through
96/// `validate_image_path` carries this. Provider adapters treat the
97/// default as "verification intentionally skipped" — only test
98/// fixtures and unverified in-memory parts use this path.
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
100pub struct ContentDigest([u8; 32]);
101
102impl ContentDigest {
103    /// Wrap an existing raw SHA-256 digest.
104    pub const fn from_raw(bytes: [u8; 32]) -> Self {
105        Self(bytes)
106    }
107
108    /// Returns the raw 32-byte digest.
109    pub const fn as_bytes(&self) -> &[u8; 32] {
110        &self.0
111    }
112
113    /// Returns the digest formatted as a lowercase hex string.
114    pub fn to_hex(&self) -> String {
115        let mut out = String::with_capacity(64);
116        for byte in self.0 {
117            out.push_str(&format!("{byte:02x}"));
118        }
119        out
120    }
121
122    /// Parses a 64-char lowercase hex string into a digest.
123    pub fn from_hex(s: &str) -> Result<Self, String> {
124        if s.len() != 64 {
125            return Err(format!(
126                "content_digest must be 64 hex chars, got {}",
127                s.len()
128            ));
129        }
130        let mut bytes = [0u8; 32];
131        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
132            let hex = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
133            bytes[i] = u8::from_str_radix(hex, 16).map_err(|e| e.to_string())?;
134        }
135        Ok(Self(bytes))
136    }
137}
138
139impl std::fmt::Display for ContentDigest {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str(&self.to_hex())
142    }
143}
144
145impl serde::Serialize for ContentDigest {
146    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
147        s.serialize_str(&self.to_hex())
148    }
149}
150
151impl<'de> serde::Deserialize<'de> for ContentDigest {
152    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
153        let s = String::deserialize(d)?;
154        Self::from_hex(&s).map_err(serde::de::Error::custom)
155    }
156}
157
158/// One part of an ordered multimodal message content (ADR-050).
159///
160/// Provider wire format (data URL, base64 source) is constructed inside
161/// `talos-provider` adapters at request time. The core type carries the
162/// canonical path, MIME type, byte count, and a content digest — no
163/// image bytes.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
165#[serde(tag = "type", rename_all = "snake_case")]
166pub enum ContentPart {
167    Text {
168        text: String,
169    },
170    Image {
171        path: std::path::PathBuf,
172        mime: String,
173        byte_count: u64,
174        /// SHA-256 digest of the file bytes observed at grant time.
175        /// The provider adapter recomputes the digest at read time and
176        /// omits the part on mismatch. Defaults to all-zero for
177        /// newly-constructed parts that have not yet been validated.
178        #[serde(default)]
179        content_digest: ContentDigest,
180    },
181}
182
183/// A message in the conversation.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(tag = "role", rename_all = "snake_case")]
186pub enum Message {
187    /// System-level instruction (identity, rules, tool guide).
188    System {
189        /// System prompt content.
190        content: String,
191        /// Stable prompt ranges suitable for provider-side caching.
192        #[serde(default, skip_serializing_if = "Vec::is_empty")]
193        cache_markers: Vec<SystemCacheMarker>,
194    },
195    /// Workspace context (AGENTS.md, history summary, retrieved files).
196    Context {
197        /// Context content.
198        content: String,
199    },
200    /// Message from the user.
201    User {
202        /// The user's message text.
203        content: String,
204    },
205    /// Multimodal user message with ordered text and image parts (ADR-050).
206    ///
207    /// The existing `User { content: String }` variant is preserved for
208    /// text-only backward compatibility. This variant is additive and requires
209    /// a pre-1.0 minor release for exhaustive match migration.
210    Multimodal {
211        /// Ordered content parts — text and image interleaved in the order
212        /// the user composed them.
213        parts: Vec<ContentPart>,
214    },
215    /// Response from the assistant.
216    Assistant {
217        /// The assistant's response text.
218        content: String,
219        /// Tool calls requested by the assistant.
220        #[serde(default, skip_serializing_if = "Vec::is_empty")]
221        tool_calls: Vec<ToolCall>,
222        /// Provider-native reasoning blocks attached to this message.
223        ///
224        /// Request-history metadata only — never display content. See ADR-034.
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        reasoning: Option<AssistantReasoning>,
227    },
228    /// Result of a tool execution.
229    Tool {
230        /// The tool result.
231        result: MessageToolResult,
232    },
233}
234
235/// Reason the assistant stopped generating.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237#[serde(rename_all = "snake_case")]
238pub enum StopReason {
239    /// Assistant finished its response.
240    EndTurn,
241    /// Assistant wants to call a tool.
242    ToolUse,
243    /// Reached the maximum token limit.
244    MaxTokens,
245}
246
247/// Token usage statistics for a turn.
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
249pub struct Usage {
250    /// Tokens in the input prompt.
251    pub input_tokens: u32,
252    /// Tokens generated by the model.
253    pub output_tokens: u32,
254    /// Tokens read from cache.
255    #[serde(default)]
256    pub cache_read_tokens: u32,
257    /// Tokens written to cache.
258    #[serde(default)]
259    pub cache_write_tokens: u32,
260    /// Reasoning/thinking tokens — informational subset of `output_tokens`.
261    #[serde(default)]
262    pub reasoning_tokens: u32,
263}
264
265/// Events emitted during a turn for streaming.
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267#[serde(tag = "type", rename_all = "snake_case")]
268#[non_exhaustive]
269pub enum AgentEvent {
270    /// Turn has started.
271    TurnStart,
272    /// A text delta was received from the provider.
273    TextDelta {
274        /// The text chunk.
275        delta: String,
276    },
277    /// A transient thinking/reasoning delta was received from the provider.
278    ///
279    /// Thinking deltas are live UI preview data. They must not be persisted as normal
280    /// conversation history or included in the final assistant text.
281    ThinkingDelta {
282        /// The thinking text chunk.
283        delta: String,
284    },
285    /// Emitted once per provider response, before `TurnEnd`, when the response
286    /// carried reasoning blocks. Durable replay payload; never display content.
287    ReasoningComplete {
288        /// Provider-native reasoning blocks in stream order.
289        blocks: Vec<ReasoningBlock>,
290    },
291    /// Tool call detected: parameters still streaming.
292    ToolCallStarted {
293        /// Name of the tool being called.
294        name: String,
295    },
296    /// A tool call was requested.
297    ToolCall {
298        /// The tool call details.
299        call: ToolCall,
300        /// The provenance of the tool being called.
301        provenance: ToolProvenance,
302        /// Fields to display in the TUI summary (from tool summary_fields()).
303        summary_fields: Vec<String>,
304    },
305    /// A tool call completed.
306    ToolResult {
307        /// The tool result.
308        result: MessageToolResult,
309    },
310    /// Turn has ended.
311    TurnEnd {
312        /// Why the turn ended.
313        stop_reason: StopReason,
314        /// Token usage for this turn.
315        usage: Usage,
316    },
317    /// An error occurred.
318    Error {
319        /// Error message.
320        message: String,
321    },
322}
323
324#[cfg(test)]
325#[allow(warnings)]
326#[allow(warnings)]
327#[allow(warnings)]
328#[allow(warnings)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn message_roundtrip_user() {
334        let msg = Message::User {
335            content: "Hello, world!".into(),
336        };
337        let json = serde_json::to_string(&msg).expect("operation should succeed");
338        let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
339        assert_eq!(msg, decoded);
340    }
341
342    #[test]
343    fn message_roundtrip_assistant() {
344        let msg = Message::Assistant {
345            content: "I can help with that.".into(),
346            tool_calls: vec![ToolCall {
347                id: "call_1".into(),
348                name: "read_file".into(),
349                input: serde_json::json!({"path": "src/main.rs"}),
350            }],
351            reasoning: None,
352        };
353        let json = serde_json::to_string(&msg).expect("operation should succeed");
354        let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
355        assert_eq!(msg, decoded);
356    }
357
358    #[test]
359    fn message_roundtrip_tool() {
360        let msg = Message::Tool {
361            result: MessageToolResult {
362                tool_use_id: "call_1".into(),
363                content: "fn main() {}".into(),
364                is_error: false,
365            },
366        };
367        let json = serde_json::to_string(&msg).expect("operation should succeed");
368        let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
369        assert_eq!(msg, decoded);
370    }
371
372    #[test]
373    fn event_roundtrip() {
374        let events = vec![
375            AgentEvent::TurnStart,
376            AgentEvent::TextDelta {
377                delta: "Hello".into(),
378            },
379            AgentEvent::ToolCall {
380                call: ToolCall {
381                    id: "c1".into(),
382                    name: "bash".into(),
383                    input: serde_json::json!({"command": "ls"}),
384                },
385                provenance: ToolProvenance::Native,
386                summary_fields: vec![],
387            },
388            AgentEvent::ToolResult {
389                result: MessageToolResult {
390                    tool_use_id: "c1".into(),
391                    content: "file.rs".into(),
392                    is_error: false,
393                },
394            },
395            AgentEvent::TurnEnd {
396                stop_reason: StopReason::EndTurn,
397                usage: Usage {
398                    input_tokens: 100,
399                    output_tokens: 50,
400                    cache_read_tokens: 80,
401                    cache_write_tokens: 20,
402                    reasoning_tokens: 0,
403                },
404            },
405            AgentEvent::Error {
406                message: "something failed".into(),
407            },
408        ];
409        for event in events {
410            let json = serde_json::to_string(&event).expect("operation should succeed");
411            let decoded: AgentEvent =
412                serde_json::from_str(&json).expect("operation should succeed");
413            assert_eq!(event, decoded);
414        }
415    }
416
417    #[test]
418    fn extract_tool_calls_preserves_id_from_json_tool_block() {
419        let text = r#"I'll run that for you.
420```json-tool
421{"id":"call_abc123","args":{"command":"ls"},"name":"bash"}
422```
423Done."#;
424        let calls = extract_tool_calls_from_text(text);
425        assert_eq!(calls.len(), 1);
426        assert_eq!(calls[0].id, "call_abc123");
427        assert_eq!(calls[0].name, "bash");
428        assert_eq!(calls[0].input, serde_json::json!({"command": "ls"}));
429    }
430
431    #[test]
432    fn extract_tool_calls_falls_back_to_synthetic_id_when_missing() {
433        let text = r#"```json-tool
434{"args":{"command":"ls"},"name":"bash"}
435```"#;
436        let calls = extract_tool_calls_from_text(text);
437        assert_eq!(calls.len(), 1);
438        assert_eq!(calls[0].id, "tc_0");
439        assert_eq!(calls[0].name, "bash");
440    }
441
442    #[test]
443    fn extract_tool_calls_falls_back_when_id_is_empty() {
444        let text = r#"```json-tool
445{"id":"","args":{"command":"ls"},"name":"bash"}
446```"#;
447        let calls = extract_tool_calls_from_text(text);
448        assert_eq!(calls.len(), 1);
449        assert_eq!(calls[0].id, "tc_0");
450    }
451
452    #[test]
453    fn content_part_text_roundtrip() {
454        let part = ContentPart::Text {
455            text: "Hello, image!".into(),
456        };
457        let json = serde_json::to_string(&part).expect("operation should succeed");
458        let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
459        assert_eq!(part, decoded);
460    }
461
462    #[test]
463    fn content_part_image_roundtrip() {
464        let part = ContentPart::Image {
465            path: "/tmp/test.png".into(),
466            mime: "image/png".into(),
467            byte_count: 12345,
468            content_digest: ContentDigest::from_raw([7u8; 32]),
469        };
470        let json = serde_json::to_string(&part).expect("operation should succeed");
471        let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
472        assert_eq!(part, decoded);
473    }
474
475    #[test]
476    fn message_multimodal_roundtrip() {
477        let msg = Message::Multimodal {
478            parts: vec![
479                ContentPart::Text {
480                    text: "What is in this image?".into(),
481                },
482                ContentPart::Image {
483                    path: "/tmp/screenshot.png".into(),
484                    mime: "image/png".into(),
485                    byte_count: 67890,
486                    content_digest: ContentDigest::from_raw([9u8; 32]),
487                },
488            ],
489        };
490        let json = serde_json::to_string(&msg).expect("operation should succeed");
491        let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
492        assert_eq!(msg, decoded);
493    }
494
495    #[test]
496    fn message_user_still_works_after_multimodal_addition() {
497        let msg = Message::User {
498            content: "text only".into(),
499        };
500        let json = serde_json::to_string(&msg).expect("operation should succeed");
501        let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
502        assert_eq!(msg, decoded);
503    }
504}
505
506pub fn extract_tool_calls_from_text(text: &str) -> Vec<ToolCall> {
507    let mut calls = Vec::new();
508    let mut remaining = text;
509
510    while let Some(start) = remaining.find("```json-tool") {
511        let inner_start = start + "```json-tool".len();
512        let inner = remaining[inner_start..].trim_start();
513        let end = inner.find("```").unwrap_or(inner.len());
514        let content = inner[..end].trim();
515
516        if let Ok(obj) = serde_json::from_str::<serde_json::Value>(content)
517            && let (Some(name), Some(args)) = (obj["name"].as_str(), Some(obj["args"].clone()))
518        {
519            let id = obj["id"]
520                .as_str()
521                .filter(|s| !s.is_empty())
522                .map(String::from)
523                .unwrap_or_else(|| format!("tc_{}", calls.len()));
524            calls.push(ToolCall {
525                id,
526                name: name.to_string(),
527                input: args,
528            });
529        }
530
531        remaining = &inner[end..];
532        if end + 3 < remaining.len() {
533            remaining = &remaining[3..];
534        } else {
535            break;
536        }
537    }
538
539    calls
540}
541
542pub fn strip_tool_syntax(text: &str) -> String {
543    let mut result = text.to_string();
544    while let Some(start) = result.find("```json-tool") {
545        let inner_start = start + "```json-tool".len();
546        let inner = &result[inner_start..];
547        let end = inner_start + inner.find("```").unwrap_or(inner.len()) + 3;
548        result.replace_range(start..end, "");
549    }
550    result.trim().to_string()
551}
552
553pub fn project_displayable_reasoning(ar: &AssistantReasoning) -> Option<String> {
554    let mut parts = Vec::new();
555    for block in &ar.blocks {
556        match block {
557            ReasoningBlock::Thinking { text, .. } if !text.is_empty() => parts.push(text.clone()),
558            ReasoningBlock::Plain { text } if !text.is_empty() => parts.push(text.clone()),
559            _ => {}
560        }
561    }
562    if parts.is_empty() {
563        None
564    } else {
565        Some(parts.join("\n"))
566    }
567}