Skip to main content

rightkit_agent_engine_protocol/
message.rs

1use crate::references::ReferenceMeta;
2use crate::refs::OutputRef;
3use crate::tool::ToolCall;
4use crate::trust::TrustEnvelope;
5use serde::{Deserialize, Serialize};
6
7/// A transcript entry. Discriminated union over the conversation roles plus the compaction boundary
8/// marker. Reasoning content is a normalized channel on `Assistant` — never folded into ordinary
9/// `content`, because per-provider re-feed rules differ (DeepSeek forbids re-feeding CoT; Kimi
10/// requires echoing it in tool-call turns).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "$kind")]
13pub enum TranscriptMessage {
14    #[serde(rename = "system", rename_all = "camelCase")]
15    System {
16        id: String,
17        created_at: String,
18        content: String,
19        pinned: bool,
20    },
21
22    #[serde(rename = "user", rename_all = "camelCase")]
23    User {
24        id: String,
25        created_at: String,
26        content: String,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        trust: Option<TrustEnvelope>,
29        pinned: bool,
30        /// D2 — typed, persisted references attached to this user turn.
31        /// Metadata-only on the wire (no image bytes — those continue to
32        /// travel through the legacy `Attachment` channel). `serde-default`
33        /// + skip-when-empty keeps every pre-D2 transcript row wire-
34        ///   compatible: rows written before D2 deserialize into an empty
35        ///   vec, rows written after D2 skip the key when no references are
36        ///   attached (so an empty user turn still serializes to the legacy
37        ///   5-field shape).
38        #[serde(default, skip_serializing_if = "Vec::is_empty")]
39        attachments: Vec<ReferenceMeta>,
40    },
41
42    #[serde(rename = "assistant", rename_all = "camelCase")]
43    Assistant {
44        id: String,
45        created_at: String,
46        content: String,
47        #[serde(skip_serializing_if = "Option::is_none")]
48        reasoning_content: Option<String>,
49        #[serde(default, skip_serializing_if = "Vec::is_empty")]
50        tool_calls: Vec<ToolCall>,
51        pinned: bool,
52    },
53
54    /// The result of a tool call. `summary` is the compact transcript line; the full (possibly
55    /// large) payload is referenced by `output` and kept out of context.
56    #[serde(rename = "tool", rename_all = "camelCase")]
57    Tool {
58        id: String,
59        created_at: String,
60        tool_call_id: String,
61        summary: String,
62        #[serde(skip_serializing_if = "Option::is_none")]
63        output: Option<OutputRef>,
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        trust: Option<TrustEnvelope>,
66        is_error: bool,
67        pinned: bool,
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        process_id: Option<String>,
70    },
71
72    /// A compaction/tombstone boundary (G7). Marks where older turns were summarized and dropped.
73    /// Visible in the transcript — compaction is never silent.
74    #[serde(rename = "compact", rename_all = "camelCase")]
75    Compact {
76        id: String,
77        created_at: String,
78        summary: String,
79        dropped_count: i32,
80        pinned: bool,
81    },
82
83    /// A context-fold boundary. Marks where a subtask's sub-trajectory was folded into
84    /// a summary. The full sub-trajectory is archived to disk and retrievable via `archive_ref`.
85    /// Structural (per-subtask), complementary to the 70% emergency compactor.
86    #[serde(rename = "fold", rename_all = "camelCase")]
87    Fold {
88        id: String,
89        created_at: String,
90        subtask_id: String,
91        summary: String,
92        dropped_count: i32,
93        /// Reference to the archived sub-trajectory on disk.
94        archive_ref: String,
95        pinned: bool,
96    },
97}
98
99impl TranscriptMessage {
100    /// Rewrite the message id. Used at the storage boundary to migrate legacy
101    /// constant IDs (`tr`, `final`, etc.) to the row-derived canonical form
102    /// and by the live engine when a re-emitted assistant completion
103    /// disagrees with the persisted row sequence.
104    pub fn set_id(&mut self, value: String) {
105        match self {
106            Self::System { id, .. }
107            | Self::User { id, .. }
108            | Self::Assistant { id, .. }
109            | Self::Tool { id, .. }
110            | Self::Compact { id, .. }
111            | Self::Fold { id, .. } => *id = value,
112        }
113    }
114
115    /// Read the message id without disturbing the variant.
116    pub fn id(&self) -> &str {
117        match self {
118            Self::System { id, .. }
119            | Self::User { id, .. }
120            | Self::Assistant { id, .. }
121            | Self::Tool { id, .. }
122            | Self::Compact { id, .. }
123            | Self::Fold { id, .. } => id.as_str(),
124        }
125    }
126
127    /// Created-at timestamp; same regardless of variant.
128    pub fn created_at(&self) -> &str {
129        match self {
130            Self::System { created_at, .. }
131            | Self::User { created_at, .. }
132            | Self::Assistant { created_at, .. }
133            | Self::Tool { created_at, .. }
134            | Self::Compact { created_at, .. }
135            | Self::Fold { created_at, .. } => created_at.as_str(),
136        }
137    }
138
139    /// Canonical constructor for a `User` transcript row. Defaults
140    /// `attachments` to empty so the many call sites that don't carry
141    /// references don't have to spell out the field. Callers that DO
142    /// attach references should use [`TranscriptMessage::user_with_attachments`].
143    pub fn user(
144        id: impl Into<String>,
145        created_at: impl Into<String>,
146        content: impl Into<String>,
147        trust: Option<TrustEnvelope>,
148        pinned: bool,
149    ) -> Self {
150        Self::User {
151            id: id.into(),
152            created_at: created_at.into(),
153            content: content.into(),
154            trust,
155            pinned,
156            attachments: Vec::new(),
157        }
158    }
159
160    /// Canonical constructor for a `User` transcript row that carries typed
161    /// D2 references. Use this at the canonical write path (`transcript.rs`)
162    /// and the message-submission entry point (`api_sessions.rs`); all other
163    /// call sites should use [`TranscriptMessage::user`].
164    pub fn user_with_attachments(
165        id: impl Into<String>,
166        created_at: impl Into<String>,
167        content: impl Into<String>,
168        trust: Option<TrustEnvelope>,
169        pinned: bool,
170        attachments: Vec<ReferenceMeta>,
171    ) -> Self {
172        Self::User {
173            id: id.into(),
174            created_at: created_at.into(),
175            content: content.into(),
176            trust,
177            pinned,
178            attachments,
179        }
180    }
181
182    pub fn is_provider_visible(&self) -> bool {
183        match self {
184            Self::Tool { output, .. } => output
185                .as_ref()
186                .map(|output| output.kind != crate::refs::TOOL_OUTPUT_INTERNAL_KIND)
187                .unwrap_or(true),
188            _ => true,
189        }
190    }
191
192    /// Borrow the user-row attachments (empty vec for any non-user variant).
193    /// Used by the persistence layer to round-trip references across
194    /// write / read boundaries without coupling to `..` destructure shape.
195    pub fn user_attachments(&self) -> &[ReferenceMeta] {
196        match self {
197            Self::User { attachments, .. } => attachments.as_slice(),
198            _ => &[],
199        }
200    }
201
202    /// Owned copy of the user-row attachments (empty vec for any non-user
203    /// variant). Useful at submission time when the row needs to forward
204    /// attachments to a downstream consumer.
205    pub fn into_user_attachments(self) -> Vec<ReferenceMeta> {
206        match self {
207            Self::User { attachments, .. } => attachments,
208            _ => Vec::new(),
209        }
210    }
211}