Skip to main content

talos_core/
message.rs

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