Skip to main content

objectiveai_cli/db/logs/
row.rs

1//! Row-shape types: [`RowTable`] / [`MessageTable`] / [`RowValue`] /
2//! [`RowKey`] / [`OwnedRowKey`] / [`RowBody`].
3//!
4//! - [`RowValue<'a>`] — what the chunk-row iterators yield. Borrowed
5//!   sum type, one variant per streaming-content table. Every variant
6//!   carries `response_id` (the enclosing agent_completion_chunk's id)
7//!   AND `agent_instance_hierarchy` (the enclosing chunk's spawned
8//!   agent id) so the writer can address `objectiveai.messages` /
9//!   `objectiveai.messages_queue` without going back to the chunk.
10//! - [`RowKey<'a>`] — what [`RowValue::key`] returns. Borrowed key
11//!   used for shadow-map lookups; no allocation.
12//! - [`OwnedRowKey`] — same variants as `RowKey`, but with `String`
13//!   instead of `&str`. Only built on insert (cold path).
14//! - [`RowBody`] — owned body sum, one variant per table. Used by the
15//!   shadow to store the last-written body so the next tick can
16//!   [`RowValue::body_eq`] against it without re-hashing or
17//!   re-serializing.
18//!
19//! Hash invariant: `RowKey<'a>` and `OwnedRowKey` MUST produce the
20//! same `u64` hash for the same logical key. Since `&str` and `String`
21//! hash identically and the variant tag is determined by source
22//! position (matching variants are at matching positions in the two
23//! enums), `derive(Hash)` is sufficient — no manual impl needed.
24
25use objectiveai_sdk::agent::completions::message::{File, ImageUrl, InputAudio, VideoUrl};
26
27/// Every table in the `logs.*` schema, plus the synthetic
28/// `MessageQueueContent` variant for queue-consumption rows.
29/// The latter writes to `objectiveai.messages` with a `"table"` value
30/// chosen at write time via SQL CASE (one of `message_queue_text`,
31/// `_image`, `_audio`, `_video`, `_file`) — the Rust-side variant
32/// is kind-less because the dispatch happens in SQL.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum RowTable {
35    AgentCompletionRequests,
36    AgentCompletionResponses,
37    VectorCompletionRequests,
38    VectorCompletionResponses,
39    FunctionExecutionRequests,
40    FunctionExecutionResponses,
41
42    /// Synthetic — kind-less at the Rust level. The writer's
43    /// helper picks the per-kind `objectiveai.message_table` enum value
44    /// via SQL CASE against `message_queue_contents.kind` and
45    /// flips the parent `message_queue.active = FALSE` in the
46    /// same statement.
47    MessageQueueContent,
48
49    ToolResponse,
50
51    AssistantResponseRefusal,
52    AssistantResponseReasoning,
53    AssistantResponseToolCalls,
54
55    AssistantResponseContentText,
56    AssistantResponseContentImage,
57    AssistantResponseContentAudio,
58    AssistantResponseContentVideo,
59    AssistantResponseContentFile,
60
61    ToolResponseContentText,
62    ToolResponseContentImage,
63    ToolResponseContentAudio,
64    ToolResponseContentVideo,
65    ToolResponseContentFile,
66}
67
68/// The subset of [`RowTable`] that produces a `objectiveai.messages` event
69/// row when written. Maps 1:1 to the postgres `objectiveai.message_table`
70/// ENUM in `schema.sql` — same names, same order. The three
71/// response-blob tables are intentionally absent; they're not events,
72/// just the latest snapshot.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type)]
74#[sqlx(type_name = "objectiveai.message_table", rename_all = "snake_case")]
75pub enum MessageTable {
76    AgentCompletionRequest,
77    VectorCompletionRequest,
78    FunctionExecutionRequest,
79    MessageQueueText,
80    MessageQueueImage,
81    MessageQueueAudio,
82    MessageQueueVideo,
83    MessageQueueFile,
84    ToolResponse,
85    AssistantResponseRefusal,
86    AssistantResponseReasoning,
87    AssistantResponseToolCalls,
88    AssistantResponseContentText,
89    AssistantResponseContentImage,
90    AssistantResponseContentAudio,
91    AssistantResponseContentVideo,
92    AssistantResponseContentFile,
93    ToolResponseContentText,
94    ToolResponseContentImage,
95    ToolResponseContentAudio,
96    ToolResponseContentVideo,
97    ToolResponseContentFile,
98}
99
100impl MessageTable {
101    /// snake_case schema name — matches the postgres enum value
102    /// declared in `schema.sql`. Used to build `IN (...)` filter
103    /// clauses inline since sqlx doesn't auto-derive
104    /// `PgHasArrayType` for custom enum types.
105    pub fn schema_name(self) -> &'static str {
106        match self {
107            MessageTable::AgentCompletionRequest => "agent_completion_request",
108            MessageTable::VectorCompletionRequest => "vector_completion_request",
109            MessageTable::FunctionExecutionRequest => "function_execution_request",
110            MessageTable::MessageQueueText => "message_queue_text",
111            MessageTable::MessageQueueImage => "message_queue_image",
112            MessageTable::MessageQueueAudio => "message_queue_audio",
113            MessageTable::MessageQueueVideo => "message_queue_video",
114            MessageTable::MessageQueueFile => "message_queue_file",
115            MessageTable::ToolResponse => "tool_response",
116            MessageTable::AssistantResponseRefusal => "assistant_response_refusal",
117            MessageTable::AssistantResponseReasoning => "assistant_response_reasoning",
118            MessageTable::AssistantResponseToolCalls => "assistant_response_tool_calls",
119            MessageTable::AssistantResponseContentText => "assistant_response_content_text",
120            MessageTable::AssistantResponseContentImage => "assistant_response_content_image",
121            MessageTable::AssistantResponseContentAudio => "assistant_response_content_audio",
122            MessageTable::AssistantResponseContentVideo => "assistant_response_content_video",
123            MessageTable::AssistantResponseContentFile => "assistant_response_content_file",
124            MessageTable::ToolResponseContentText => "tool_response_content_text",
125            MessageTable::ToolResponseContentImage => "tool_response_content_image",
126            MessageTable::ToolResponseContentAudio => "tool_response_content_audio",
127            MessageTable::ToolResponseContentVideo => "tool_response_content_video",
128            MessageTable::ToolResponseContentFile => "tool_response_content_file",
129        }
130    }
131}
132
133impl RowTable {
134    /// The [`MessageTable`] for this table's events. Returns `None` for
135    /// the three response-blob tables (which don't emit messages).
136    pub fn message_table(self) -> Option<MessageTable> {
137        Some(match self {
138            RowTable::AgentCompletionRequests => MessageTable::AgentCompletionRequest,
139            RowTable::VectorCompletionRequests => MessageTable::VectorCompletionRequest,
140            RowTable::FunctionExecutionRequests => MessageTable::FunctionExecutionRequest,
141            // MessageQueueContent's table value is resolved at
142            // write time via SQL CASE — the standard
143            // `message_table()` path can't pick from the 5
144            // per-kind variants without the kind. Callers writing
145            // these rows skip this helper.
146            RowTable::MessageQueueContent => return None,
147            RowTable::ToolResponse => MessageTable::ToolResponse,
148            RowTable::AssistantResponseRefusal => MessageTable::AssistantResponseRefusal,
149            RowTable::AssistantResponseReasoning => MessageTable::AssistantResponseReasoning,
150            RowTable::AssistantResponseToolCalls => MessageTable::AssistantResponseToolCalls,
151            RowTable::AssistantResponseContentText => MessageTable::AssistantResponseContentText,
152            RowTable::AssistantResponseContentImage => MessageTable::AssistantResponseContentImage,
153            RowTable::AssistantResponseContentAudio => MessageTable::AssistantResponseContentAudio,
154            RowTable::AssistantResponseContentVideo => MessageTable::AssistantResponseContentVideo,
155            RowTable::AssistantResponseContentFile => MessageTable::AssistantResponseContentFile,
156            RowTable::ToolResponseContentText => MessageTable::ToolResponseContentText,
157            RowTable::ToolResponseContentImage => MessageTable::ToolResponseContentImage,
158            RowTable::ToolResponseContentAudio => MessageTable::ToolResponseContentAudio,
159            RowTable::ToolResponseContentVideo => MessageTable::ToolResponseContentVideo,
160            RowTable::ToolResponseContentFile => MessageTable::ToolResponseContentFile,
161            RowTable::AgentCompletionResponses
162            | RowTable::VectorCompletionResponses
163            | RowTable::FunctionExecutionResponses => return None,
164        })
165    }
166
167    pub fn fq_name(self) -> &'static str {
168        match self {
169            RowTable::AgentCompletionRequests => "objectiveai.agent_completion_requests",
170            RowTable::AgentCompletionResponses => "objectiveai.agent_completion_responses",
171            RowTable::VectorCompletionRequests => "objectiveai.vector_completion_requests",
172            RowTable::VectorCompletionResponses => "objectiveai.vector_completion_responses",
173            RowTable::FunctionExecutionRequests => "objectiveai.function_execution_requests",
174            RowTable::FunctionExecutionResponses => "objectiveai.function_execution_responses",
175            // Synthetic — kind chosen at write time via SQL CASE.
176            // No per-kind table name surfaces through fq_name();
177            // the writer's helper builds its SQL inline.
178            RowTable::MessageQueueContent => "message_queue_contents",
179            RowTable::ToolResponse => "objectiveai.tool_response",
180            RowTable::AssistantResponseRefusal => "objectiveai.assistant_response_refusal",
181            RowTable::AssistantResponseReasoning => "objectiveai.assistant_response_reasoning",
182            RowTable::AssistantResponseToolCalls => "objectiveai.assistant_response_tool_calls",
183            RowTable::AssistantResponseContentText => "objectiveai.assistant_response_content_text",
184            RowTable::AssistantResponseContentImage => "objectiveai.assistant_response_content_image",
185            RowTable::AssistantResponseContentAudio => "objectiveai.assistant_response_content_audio",
186            RowTable::AssistantResponseContentVideo => "objectiveai.assistant_response_content_video",
187            RowTable::AssistantResponseContentFile => "objectiveai.assistant_response_content_file",
188            RowTable::ToolResponseContentText => "objectiveai.tool_response_content_text",
189            RowTable::ToolResponseContentImage => "objectiveai.tool_response_content_image",
190            RowTable::ToolResponseContentAudio => "objectiveai.tool_response_content_audio",
191            RowTable::ToolResponseContentVideo => "objectiveai.tool_response_content_video",
192            RowTable::ToolResponseContentFile => "objectiveai.tool_response_content_file",
193        }
194    }
195}
196
197/// One streaming-content row to INSERT or UPDATE. Borrowed: every
198/// variant lifts string / media payloads from the owning chunk by
199/// reference. Every variant also carries the enclosing chunk's
200/// `agent_instance_hierarchy` so the writer can populate
201/// `objectiveai.messages.agent_instance_hierarchy` and key the
202/// `objectiveai.messages_queue` downgrade against the right spawned agent.
203#[derive(Debug, Clone)]
204pub enum RowValue<'a> {
205    /// Consumption signal: the API stamped this
206    /// `message_queue_contents.id` onto the chunk's
207    /// `request_message_ids` field. The writer's helper:
208    /// 1) resolves the row's kind via SQL CASE against
209    ///    `message_queue_contents.kind`,
210    /// 2) `UPDATE objectiveai.message_queue SET active=FALSE WHERE id =
211    ///    (SELECT message_queue_id FROM objectiveai.message_queue_contents
212    ///     WHERE id = $content_id) AND active=TRUE`, and
213    /// 3) `INSERT objectiveai.messages` with `"table"` picked from the
214    ///    five `message_queue_*` enum values matching the kind
215    ///    and `row_index = content_id`, so the read path
216    ///    dispatches directly to the right per-kind table.
217    ///
218    /// Multiple content_ids sharing one parent fire the flip
219    /// once; the rest are no-ops via the `AND active=TRUE` guard.
220    ///
221    /// Iterators yield these AHEAD of the per-message content
222    /// rows so the log chronicles consumption before the body
223    /// the agent produced.
224    MessageQueueContent {
225        response_id: &'a str,
226        agent_instance_hierarchy: &'a str,
227        message_queue_content_id: i64,
228    },
229    ToolResponse {
230        response_id: &'a str,
231        agent_instance_hierarchy: &'a str,
232        index: u64,
233        tool_call_id: &'a str,
234    },
235    AssistantResponseRefusal {
236        response_id: &'a str,
237        agent_instance_hierarchy: &'a str,
238        index: u64,
239        text: &'a str,
240    },
241    AssistantResponseReasoning {
242        response_id: &'a str,
243        agent_instance_hierarchy: &'a str,
244        index: u64,
245        text: &'a str,
246    },
247    AssistantResponseToolCalls {
248        response_id: &'a str,
249        agent_instance_hierarchy: &'a str,
250        index: u64,
251        tool_call_index: u64,
252        tool_call_id: &'a str,
253        function_name: &'a str,
254        arguments: &'a str,
255    },
256
257    AssistantResponseContentText {
258        response_id: &'a str,
259        agent_instance_hierarchy: &'a str,
260        index: u64,
261        part_index: u64,
262        text: &'a str,
263    },
264    AssistantResponseContentImage {
265        response_id: &'a str,
266        agent_instance_hierarchy: &'a str,
267        index: u64,
268        part_index: u64,
269        image_url: &'a ImageUrl,
270    },
271    AssistantResponseContentAudio {
272        response_id: &'a str,
273        agent_instance_hierarchy: &'a str,
274        index: u64,
275        part_index: u64,
276        input_audio: &'a InputAudio,
277    },
278    AssistantResponseContentVideo {
279        response_id: &'a str,
280        agent_instance_hierarchy: &'a str,
281        index: u64,
282        part_index: u64,
283        video_url: &'a VideoUrl,
284    },
285    AssistantResponseContentFile {
286        response_id: &'a str,
287        agent_instance_hierarchy: &'a str,
288        index: u64,
289        part_index: u64,
290        file: &'a File,
291    },
292
293    ToolResponseContentText {
294        response_id: &'a str,
295        agent_instance_hierarchy: &'a str,
296        index: u64,
297        part_index: u64,
298        text: &'a str,
299    },
300    ToolResponseContentImage {
301        response_id: &'a str,
302        agent_instance_hierarchy: &'a str,
303        index: u64,
304        part_index: u64,
305        image_url: &'a ImageUrl,
306    },
307    ToolResponseContentAudio {
308        response_id: &'a str,
309        agent_instance_hierarchy: &'a str,
310        index: u64,
311        part_index: u64,
312        input_audio: &'a InputAudio,
313    },
314    ToolResponseContentVideo {
315        response_id: &'a str,
316        agent_instance_hierarchy: &'a str,
317        index: u64,
318        part_index: u64,
319        video_url: &'a VideoUrl,
320    },
321    ToolResponseContentFile {
322        response_id: &'a str,
323        agent_instance_hierarchy: &'a str,
324        index: u64,
325        part_index: u64,
326        file: &'a File,
327    },
328}
329
330impl<'a> RowValue<'a> {
331    pub fn table(&self) -> RowTable {
332        match self {
333            RowValue::MessageQueueContent { .. } => RowTable::MessageQueueContent,
334            RowValue::ToolResponse { .. } => RowTable::ToolResponse,
335            RowValue::AssistantResponseRefusal { .. } => RowTable::AssistantResponseRefusal,
336            RowValue::AssistantResponseReasoning { .. } => RowTable::AssistantResponseReasoning,
337            RowValue::AssistantResponseToolCalls { .. } => RowTable::AssistantResponseToolCalls,
338            RowValue::AssistantResponseContentText { .. } => RowTable::AssistantResponseContentText,
339            RowValue::AssistantResponseContentImage { .. } => RowTable::AssistantResponseContentImage,
340            RowValue::AssistantResponseContentAudio { .. } => RowTable::AssistantResponseContentAudio,
341            RowValue::AssistantResponseContentVideo { .. } => RowTable::AssistantResponseContentVideo,
342            RowValue::AssistantResponseContentFile { .. } => RowTable::AssistantResponseContentFile,
343            RowValue::ToolResponseContentText { .. } => RowTable::ToolResponseContentText,
344            RowValue::ToolResponseContentImage { .. } => RowTable::ToolResponseContentImage,
345            RowValue::ToolResponseContentAudio { .. } => RowTable::ToolResponseContentAudio,
346            RowValue::ToolResponseContentVideo { .. } => RowTable::ToolResponseContentVideo,
347            RowValue::ToolResponseContentFile { .. } => RowTable::ToolResponseContentFile,
348        }
349    }
350
351    /// [`MessageTable`] for this row's table. Streaming-content rows
352    /// always have one.
353    pub fn message_table(&self) -> MessageTable {
354        self.table()
355            .message_table()
356            .expect("RowValue variants only cover streaming-content tables")
357    }
358
359    /// `response_id` borrowed from the immediately-enclosing
360    /// agent-completion chunk's `id`. Even for rows emitted under
361    /// `vector_completion_chunk_rows` /
362    /// `function_execution_chunk_rows`, the recursion bottoms out
363    /// at an agent-completion chunk and uses that chunk's id —
364    /// never the outer vector/function wrapper's id. This is what
365    /// `agents logs read all` / `read pending` use as the
366    /// per-block `response_id` boundary key.
367    pub fn response_id(&self) -> &'a str {
368        match self {
369            RowValue::MessageQueueContent { response_id, .. }
370            | RowValue::ToolResponse { response_id, .. }
371            | RowValue::AssistantResponseRefusal { response_id, .. }
372            | RowValue::AssistantResponseReasoning { response_id, .. }
373            | RowValue::AssistantResponseToolCalls { response_id, .. }
374            | RowValue::AssistantResponseContentText { response_id, .. }
375            | RowValue::AssistantResponseContentImage { response_id, .. }
376            | RowValue::AssistantResponseContentAudio { response_id, .. }
377            | RowValue::AssistantResponseContentVideo { response_id, .. }
378            | RowValue::AssistantResponseContentFile { response_id, .. }
379            | RowValue::ToolResponseContentText { response_id, .. }
380            | RowValue::ToolResponseContentImage { response_id, .. }
381            | RowValue::ToolResponseContentAudio { response_id, .. }
382            | RowValue::ToolResponseContentVideo { response_id, .. }
383            | RowValue::ToolResponseContentFile { response_id, .. } => response_id,
384        }
385    }
386
387    /// `agent_instance_hierarchy` borrowed from the enclosing
388    /// agent-completion chunk. Every streaming-content row lives
389    /// inside an agent completion, so this is always non-NULL.
390    pub fn agent_instance_hierarchy(&self) -> &'a str {
391        match self {
392            RowValue::MessageQueueContent { agent_instance_hierarchy, .. }
393            | RowValue::ToolResponse { agent_instance_hierarchy, .. }
394            | RowValue::AssistantResponseRefusal { agent_instance_hierarchy, .. }
395            | RowValue::AssistantResponseReasoning { agent_instance_hierarchy, .. }
396            | RowValue::AssistantResponseToolCalls { agent_instance_hierarchy, .. }
397            | RowValue::AssistantResponseContentText { agent_instance_hierarchy, .. }
398            | RowValue::AssistantResponseContentImage { agent_instance_hierarchy, .. }
399            | RowValue::AssistantResponseContentAudio { agent_instance_hierarchy, .. }
400            | RowValue::AssistantResponseContentVideo { agent_instance_hierarchy, .. }
401            | RowValue::AssistantResponseContentFile { agent_instance_hierarchy, .. }
402            | RowValue::ToolResponseContentText { agent_instance_hierarchy, .. }
403            | RowValue::ToolResponseContentImage { agent_instance_hierarchy, .. }
404            | RowValue::ToolResponseContentAudio { agent_instance_hierarchy, .. }
405            | RowValue::ToolResponseContentVideo { agent_instance_hierarchy, .. }
406            | RowValue::ToolResponseContentFile { agent_instance_hierarchy, .. } => {
407                agent_instance_hierarchy
408            }
409        }
410    }
411
412    /// `row_index` column value for the postgres `messages` /
413    /// `messages_queue` entry. Always populated for streaming-content
414    /// rows.
415    pub fn row_index(&self) -> i64 {
416        match self {
417            RowValue::MessageQueueContent { message_queue_content_id, .. } => {
418                *message_queue_content_id
419            }
420            RowValue::ToolResponse { index, .. }
421            | RowValue::AssistantResponseRefusal { index, .. }
422            | RowValue::AssistantResponseReasoning { index, .. }
423            | RowValue::AssistantResponseToolCalls { index, .. }
424            | RowValue::AssistantResponseContentText { index, .. }
425            | RowValue::AssistantResponseContentImage { index, .. }
426            | RowValue::AssistantResponseContentAudio { index, .. }
427            | RowValue::AssistantResponseContentVideo { index, .. }
428            | RowValue::AssistantResponseContentFile { index, .. }
429            | RowValue::ToolResponseContentText { index, .. }
430            | RowValue::ToolResponseContentImage { index, .. }
431            | RowValue::ToolResponseContentAudio { index, .. }
432            | RowValue::ToolResponseContentVideo { index, .. }
433            | RowValue::ToolResponseContentFile { index, .. } => *index as i64,
434        }
435    }
436
437    /// `row_sub_index` column value: `tool_call_index` for tool-call
438    /// rows, `part_index` for content-part rows, `None` for
439    /// tool_response / assistant refusal / assistant reasoning (whose
440    /// shape has no sub-index). The matching SQL column is nullable.
441    pub fn row_sub_index(&self) -> Option<i64> {
442        match self {
443            RowValue::MessageQueueContent { .. }
444            | RowValue::ToolResponse { .. }
445            | RowValue::AssistantResponseRefusal { .. }
446            | RowValue::AssistantResponseReasoning { .. } => None,
447            RowValue::AssistantResponseToolCalls { tool_call_index, .. } => {
448                Some(*tool_call_index as i64)
449            }
450            RowValue::AssistantResponseContentText { part_index, .. }
451            | RowValue::AssistantResponseContentImage { part_index, .. }
452            | RowValue::AssistantResponseContentAudio { part_index, .. }
453            | RowValue::AssistantResponseContentVideo { part_index, .. }
454            | RowValue::AssistantResponseContentFile { part_index, .. }
455            | RowValue::ToolResponseContentText { part_index, .. }
456            | RowValue::ToolResponseContentImage { part_index, .. }
457            | RowValue::ToolResponseContentAudio { part_index, .. }
458            | RowValue::ToolResponseContentVideo { part_index, .. }
459            | RowValue::ToolResponseContentFile { part_index, .. } => Some(*part_index as i64),
460        }
461    }
462
463    /// The borrowed key that identifies this row's slot in postgres.
464    /// Hashable + Eq — used by the shadow map to find the previously
465    /// stored body without any allocation. Two `RowValue`s targeting
466    /// the same row produce equal `RowKey`s.
467    pub fn key(&self) -> RowKey<'a> {
468        match self {
469            RowValue::MessageQueueContent {
470                response_id,
471                message_queue_content_id,
472                ..
473            } => RowKey::MessageQueueContent {
474                response_id,
475                message_queue_content_id: *message_queue_content_id,
476            },
477            RowValue::ToolResponse { response_id, index, .. } => {
478                RowKey::ToolResponse { response_id, index: *index }
479            }
480            RowValue::AssistantResponseRefusal { response_id, index, .. } => {
481                RowKey::AssistantRefusal { response_id, index: *index }
482            }
483            RowValue::AssistantResponseReasoning { response_id, index, .. } => {
484                RowKey::AssistantReasoning { response_id, index: *index }
485            }
486            RowValue::AssistantResponseToolCalls {
487                response_id, index, tool_call_index, ..
488            } => RowKey::AssistantToolCall {
489                response_id,
490                index: *index,
491                tool_call_index: *tool_call_index,
492            },
493            RowValue::AssistantResponseContentText { response_id, index, part_index, .. } => {
494                RowKey::AssistantContentText { response_id, index: *index, part_index: *part_index }
495            }
496            RowValue::AssistantResponseContentImage { response_id, index, part_index, .. } => {
497                RowKey::AssistantContentImage { response_id, index: *index, part_index: *part_index }
498            }
499            RowValue::AssistantResponseContentAudio { response_id, index, part_index, .. } => {
500                RowKey::AssistantContentAudio { response_id, index: *index, part_index: *part_index }
501            }
502            RowValue::AssistantResponseContentVideo { response_id, index, part_index, .. } => {
503                RowKey::AssistantContentVideo { response_id, index: *index, part_index: *part_index }
504            }
505            RowValue::AssistantResponseContentFile { response_id, index, part_index, .. } => {
506                RowKey::AssistantContentFile { response_id, index: *index, part_index: *part_index }
507            }
508            RowValue::ToolResponseContentText { response_id, index, part_index, .. } => {
509                RowKey::ToolContentText { response_id, index: *index, part_index: *part_index }
510            }
511            RowValue::ToolResponseContentImage { response_id, index, part_index, .. } => {
512                RowKey::ToolContentImage { response_id, index: *index, part_index: *part_index }
513            }
514            RowValue::ToolResponseContentAudio { response_id, index, part_index, .. } => {
515                RowKey::ToolContentAudio { response_id, index: *index, part_index: *part_index }
516            }
517            RowValue::ToolResponseContentVideo { response_id, index, part_index, .. } => {
518                RowKey::ToolContentVideo { response_id, index: *index, part_index: *part_index }
519            }
520            RowValue::ToolResponseContentFile { response_id, index, part_index, .. } => {
521                RowKey::ToolContentFile { response_id, index: *index, part_index: *part_index }
522            }
523        }
524    }
525
526    /// Field-by-field equality against a stored body. Returns true
527    /// when this row would write a byte-identical body — the writer
528    /// uses that signal to short-circuit the SQL.
529    pub fn body_eq(&self, stored: &RowBody) -> bool {
530        match (self, stored) {
531            // MessageQueueContent has no body — the row's identity is
532            // entirely in the (response_id, content_id) key; shadow
533            // skip-dedup makes the second write a no-op via this true.
534            (
535                RowValue::MessageQueueContent { .. },
536                RowBody::MessageQueueContent {},
537            ) => true,
538            (
539                RowValue::ToolResponse { tool_call_id: a, .. },
540                RowBody::ToolResponse { tool_call_id: b },
541            ) => *a == b.as_str(),
542            (
543                RowValue::AssistantResponseRefusal { text: a, .. },
544                RowBody::AssistantRefusal { text: b },
545            ) => *a == b.as_str(),
546            (
547                RowValue::AssistantResponseReasoning { text: a, .. },
548                RowBody::AssistantReasoning { text: b },
549            ) => *a == b.as_str(),
550            (
551                RowValue::AssistantResponseToolCalls { tool_call_id: a, arguments: aa, .. },
552                RowBody::AssistantToolCall { tool_call_id: b, arguments: bb },
553            ) => *a == b.as_str() && *aa == bb.as_str(),
554            (
555                RowValue::AssistantResponseContentText { text: a, .. },
556                RowBody::AssistantContentText { text: b },
557            ) => *a == b.as_str(),
558            (
559                RowValue::AssistantResponseContentImage { image_url: a, .. },
560                RowBody::AssistantContentImage { image_url: b },
561            ) => *a == b,
562            (
563                RowValue::AssistantResponseContentAudio { input_audio: a, .. },
564                RowBody::AssistantContentAudio { input_audio: b },
565            ) => *a == b,
566            (
567                RowValue::AssistantResponseContentVideo { video_url: a, .. },
568                RowBody::AssistantContentVideo { video_url: b },
569            ) => *a == b,
570            (
571                RowValue::AssistantResponseContentFile { file: a, .. },
572                RowBody::AssistantContentFile { file: b },
573            ) => *a == b,
574            (
575                RowValue::ToolResponseContentText { text: a, .. },
576                RowBody::ToolContentText { text: b },
577            ) => *a == b.as_str(),
578            (
579                RowValue::ToolResponseContentImage { image_url: a, .. },
580                RowBody::ToolContentImage { image_url: b },
581            ) => *a == b,
582            (
583                RowValue::ToolResponseContentAudio { input_audio: a, .. },
584                RowBody::ToolContentAudio { input_audio: b },
585            ) => *a == b,
586            (
587                RowValue::ToolResponseContentVideo { video_url: a, .. },
588                RowBody::ToolContentVideo { video_url: b },
589            ) => *a == b,
590            (
591                RowValue::ToolResponseContentFile { file: a, .. },
592                RowBody::ToolContentFile { file: b },
593            ) => *a == b,
594            _ => false,
595        }
596    }
597
598    /// Build an owned [`RowBody`] for storing on Insert / Update. Only
599    /// called on the cold path (when the shadow needs to remember a
600    /// new value); the Skip path never allocates here.
601    pub fn to_body(&self) -> RowBody {
602        match self {
603            RowValue::MessageQueueContent { .. } => RowBody::MessageQueueContent {},
604            RowValue::ToolResponse { tool_call_id, .. } => RowBody::ToolResponse {
605                tool_call_id: (*tool_call_id).to_owned(),
606            },
607            RowValue::AssistantResponseRefusal { text, .. } => RowBody::AssistantRefusal {
608                text: (*text).to_owned(),
609            },
610            RowValue::AssistantResponseReasoning { text, .. } => RowBody::AssistantReasoning {
611                text: (*text).to_owned(),
612            },
613            RowValue::AssistantResponseToolCalls { tool_call_id, arguments, .. } => {
614                RowBody::AssistantToolCall {
615                    tool_call_id: (*tool_call_id).to_owned(),
616                    arguments: (*arguments).to_owned(),
617                }
618            }
619            RowValue::AssistantResponseContentText { text, .. } => RowBody::AssistantContentText {
620                text: (*text).to_owned(),
621            },
622            RowValue::AssistantResponseContentImage { image_url, .. } => {
623                RowBody::AssistantContentImage { image_url: (*image_url).clone() }
624            }
625            RowValue::AssistantResponseContentAudio { input_audio, .. } => {
626                RowBody::AssistantContentAudio { input_audio: (*input_audio).clone() }
627            }
628            RowValue::AssistantResponseContentVideo { video_url, .. } => {
629                RowBody::AssistantContentVideo {
630                    video_url: (*video_url).clone(),
631                }
632            }
633            RowValue::AssistantResponseContentFile { file, .. } => {
634                RowBody::AssistantContentFile { file: (*file).clone() }
635            }
636            RowValue::ToolResponseContentText { text, .. } => RowBody::ToolContentText {
637                text: (*text).to_owned(),
638            },
639            RowValue::ToolResponseContentImage { image_url, .. } => {
640                RowBody::ToolContentImage { image_url: (*image_url).clone() }
641            }
642            RowValue::ToolResponseContentAudio { input_audio, .. } => {
643                RowBody::ToolContentAudio { input_audio: (*input_audio).clone() }
644            }
645            RowValue::ToolResponseContentVideo { video_url, .. } => RowBody::ToolContentVideo {
646                video_url: (*video_url).clone(),
647            },
648            RowValue::ToolResponseContentFile { file, .. } => RowBody::ToolContentFile {
649                file: (*file).clone(),
650            },
651        }
652    }
653}
654
655/// Boxed-iterator alias used at the recursive boundaries
656/// (function execution → vector completion → agent completion).
657/// One Box per recursive descent, never per leaf row.
658pub type RowsIter<'a> = Box<dyn Iterator<Item = RowValue<'a>> + Send + 'a>;
659
660// ---------------------------------------------------------------------
661// Shadow keys (borrowed + owned)
662// ---------------------------------------------------------------------
663
664/// Borrowed key that identifies one row's slot in postgres. Two
665/// `RowKey`s with equal variant + fields hash identically to an
666/// [`OwnedRowKey`] with the matching shape — that invariant lets the
667/// shadow's hashbrown `raw_entry_mut` look up by borrowed key without
668/// converting to owned first.
669#[derive(Debug, Clone, Hash, PartialEq, Eq)]
670pub enum RowKey<'a> {
671    /// `message_queue_contents.id` is globally unique across
672    /// kinds, so the key needs no kind discriminator — only the
673    /// id (the kind is recovered from `RowValue` for write
674    /// dispatch).
675    MessageQueueContent { response_id: &'a str, message_queue_content_id: i64 },
676    ToolResponse { response_id: &'a str, index: u64 },
677    AssistantRefusal { response_id: &'a str, index: u64 },
678    AssistantReasoning { response_id: &'a str, index: u64 },
679    AssistantToolCall { response_id: &'a str, index: u64, tool_call_index: u64 },
680    AssistantContentText { response_id: &'a str, index: u64, part_index: u64 },
681    AssistantContentImage { response_id: &'a str, index: u64, part_index: u64 },
682    AssistantContentAudio { response_id: &'a str, index: u64, part_index: u64 },
683    AssistantContentVideo { response_id: &'a str, index: u64, part_index: u64 },
684    AssistantContentFile { response_id: &'a str, index: u64, part_index: u64 },
685    ToolContentText { response_id: &'a str, index: u64, part_index: u64 },
686    ToolContentImage { response_id: &'a str, index: u64, part_index: u64 },
687    ToolContentAudio { response_id: &'a str, index: u64, part_index: u64 },
688    ToolContentVideo { response_id: &'a str, index: u64, part_index: u64 },
689    ToolContentFile { response_id: &'a str, index: u64, part_index: u64 },
690}
691
692/// Owned counterpart to [`RowKey`]. Stored in the shadow map. Built
693/// only on Insert.
694#[derive(Debug, Clone, Hash, PartialEq, Eq)]
695pub enum OwnedRowKey {
696    MessageQueueContent { response_id: String, message_queue_content_id: i64 },
697    ToolResponse { response_id: String, index: u64 },
698    AssistantRefusal { response_id: String, index: u64 },
699    AssistantReasoning { response_id: String, index: u64 },
700    AssistantToolCall { response_id: String, index: u64, tool_call_index: u64 },
701    AssistantContentText { response_id: String, index: u64, part_index: u64 },
702    AssistantContentImage { response_id: String, index: u64, part_index: u64 },
703    AssistantContentAudio { response_id: String, index: u64, part_index: u64 },
704    AssistantContentVideo { response_id: String, index: u64, part_index: u64 },
705    AssistantContentFile { response_id: String, index: u64, part_index: u64 },
706    ToolContentText { response_id: String, index: u64, part_index: u64 },
707    ToolContentImage { response_id: String, index: u64, part_index: u64 },
708    ToolContentAudio { response_id: String, index: u64, part_index: u64 },
709    ToolContentVideo { response_id: String, index: u64, part_index: u64 },
710    ToolContentFile { response_id: String, index: u64, part_index: u64 },
711}
712
713impl<'a> RowKey<'a> {
714    pub fn matches_owned(&self, owned: &OwnedRowKey) -> bool {
715        match (self, owned) {
716            (
717                RowKey::MessageQueueContent { response_id: a, message_queue_content_id: ai },
718                OwnedRowKey::MessageQueueContent { response_id: b, message_queue_content_id: bi },
719            ) => *a == b.as_str() && ai == bi,
720            (
721                RowKey::ToolResponse { response_id: a, index: ai },
722                OwnedRowKey::ToolResponse { response_id: b, index: bi },
723            ) => *a == b.as_str() && ai == bi,
724            (
725                RowKey::AssistantRefusal { response_id: a, index: ai },
726                OwnedRowKey::AssistantRefusal { response_id: b, index: bi },
727            ) => *a == b.as_str() && ai == bi,
728            (
729                RowKey::AssistantReasoning { response_id: a, index: ai },
730                OwnedRowKey::AssistantReasoning { response_id: b, index: bi },
731            ) => *a == b.as_str() && ai == bi,
732            (
733                RowKey::AssistantToolCall { response_id: a, index: ai, tool_call_index: at },
734                OwnedRowKey::AssistantToolCall { response_id: b, index: bi, tool_call_index: bt },
735            ) => *a == b.as_str() && ai == bi && at == bt,
736            (
737                RowKey::AssistantContentText { response_id: a, index: ai, part_index: ap },
738                OwnedRowKey::AssistantContentText { response_id: b, index: bi, part_index: bp },
739            ) => *a == b.as_str() && ai == bi && ap == bp,
740            (
741                RowKey::AssistantContentImage { response_id: a, index: ai, part_index: ap },
742                OwnedRowKey::AssistantContentImage { response_id: b, index: bi, part_index: bp },
743            ) => *a == b.as_str() && ai == bi && ap == bp,
744            (
745                RowKey::AssistantContentAudio { response_id: a, index: ai, part_index: ap },
746                OwnedRowKey::AssistantContentAudio { response_id: b, index: bi, part_index: bp },
747            ) => *a == b.as_str() && ai == bi && ap == bp,
748            (
749                RowKey::AssistantContentVideo { response_id: a, index: ai, part_index: ap },
750                OwnedRowKey::AssistantContentVideo { response_id: b, index: bi, part_index: bp },
751            ) => *a == b.as_str() && ai == bi && ap == bp,
752            (
753                RowKey::AssistantContentFile { response_id: a, index: ai, part_index: ap },
754                OwnedRowKey::AssistantContentFile { response_id: b, index: bi, part_index: bp },
755            ) => *a == b.as_str() && ai == bi && ap == bp,
756            (
757                RowKey::ToolContentText { response_id: a, index: ai, part_index: ap },
758                OwnedRowKey::ToolContentText { response_id: b, index: bi, part_index: bp },
759            ) => *a == b.as_str() && ai == bi && ap == bp,
760            (
761                RowKey::ToolContentImage { response_id: a, index: ai, part_index: ap },
762                OwnedRowKey::ToolContentImage { response_id: b, index: bi, part_index: bp },
763            ) => *a == b.as_str() && ai == bi && ap == bp,
764            (
765                RowKey::ToolContentAudio { response_id: a, index: ai, part_index: ap },
766                OwnedRowKey::ToolContentAudio { response_id: b, index: bi, part_index: bp },
767            ) => *a == b.as_str() && ai == bi && ap == bp,
768            (
769                RowKey::ToolContentVideo { response_id: a, index: ai, part_index: ap },
770                OwnedRowKey::ToolContentVideo { response_id: b, index: bi, part_index: bp },
771            ) => *a == b.as_str() && ai == bi && ap == bp,
772            (
773                RowKey::ToolContentFile { response_id: a, index: ai, part_index: ap },
774                OwnedRowKey::ToolContentFile { response_id: b, index: bi, part_index: bp },
775            ) => *a == b.as_str() && ai == bi && ap == bp,
776            _ => false,
777        }
778    }
779
780    pub fn to_owned_key(&self) -> OwnedRowKey {
781        match self {
782            RowKey::MessageQueueContent { response_id, message_queue_content_id } => {
783                OwnedRowKey::MessageQueueContent {
784                    response_id: (*response_id).to_owned(),
785                    message_queue_content_id: *message_queue_content_id,
786                }
787            }
788            RowKey::ToolResponse { response_id, index } => OwnedRowKey::ToolResponse {
789                response_id: (*response_id).to_owned(),
790                index: *index,
791            },
792            RowKey::AssistantRefusal { response_id, index } => OwnedRowKey::AssistantRefusal {
793                response_id: (*response_id).to_owned(),
794                index: *index,
795            },
796            RowKey::AssistantReasoning { response_id, index } => OwnedRowKey::AssistantReasoning {
797                response_id: (*response_id).to_owned(),
798                index: *index,
799            },
800            RowKey::AssistantToolCall { response_id, index, tool_call_index } => {
801                OwnedRowKey::AssistantToolCall {
802                    response_id: (*response_id).to_owned(),
803                    index: *index,
804                    tool_call_index: *tool_call_index,
805                }
806            }
807            RowKey::AssistantContentText { response_id, index, part_index } => {
808                OwnedRowKey::AssistantContentText {
809                    response_id: (*response_id).to_owned(),
810                    index: *index,
811                    part_index: *part_index,
812                }
813            }
814            RowKey::AssistantContentImage { response_id, index, part_index } => {
815                OwnedRowKey::AssistantContentImage {
816                    response_id: (*response_id).to_owned(),
817                    index: *index,
818                    part_index: *part_index,
819                }
820            }
821            RowKey::AssistantContentAudio { response_id, index, part_index } => {
822                OwnedRowKey::AssistantContentAudio {
823                    response_id: (*response_id).to_owned(),
824                    index: *index,
825                    part_index: *part_index,
826                }
827            }
828            RowKey::AssistantContentVideo { response_id, index, part_index } => {
829                OwnedRowKey::AssistantContentVideo {
830                    response_id: (*response_id).to_owned(),
831                    index: *index,
832                    part_index: *part_index,
833                }
834            }
835            RowKey::AssistantContentFile { response_id, index, part_index } => {
836                OwnedRowKey::AssistantContentFile {
837                    response_id: (*response_id).to_owned(),
838                    index: *index,
839                    part_index: *part_index,
840                }
841            }
842            RowKey::ToolContentText { response_id, index, part_index } => {
843                OwnedRowKey::ToolContentText {
844                    response_id: (*response_id).to_owned(),
845                    index: *index,
846                    part_index: *part_index,
847                }
848            }
849            RowKey::ToolContentImage { response_id, index, part_index } => {
850                OwnedRowKey::ToolContentImage {
851                    response_id: (*response_id).to_owned(),
852                    index: *index,
853                    part_index: *part_index,
854                }
855            }
856            RowKey::ToolContentAudio { response_id, index, part_index } => {
857                OwnedRowKey::ToolContentAudio {
858                    response_id: (*response_id).to_owned(),
859                    index: *index,
860                    part_index: *part_index,
861                }
862            }
863            RowKey::ToolContentVideo { response_id, index, part_index } => {
864                OwnedRowKey::ToolContentVideo {
865                    response_id: (*response_id).to_owned(),
866                    index: *index,
867                    part_index: *part_index,
868                }
869            }
870            RowKey::ToolContentFile { response_id, index, part_index } => {
871                OwnedRowKey::ToolContentFile {
872                    response_id: (*response_id).to_owned(),
873                    index: *index,
874                    part_index: *part_index,
875                }
876            }
877        }
878    }
879}
880
881// ---------------------------------------------------------------------
882// Shadow body
883// ---------------------------------------------------------------------
884
885/// Owned body stored in the shadow map. Compared by `PartialEq`
886/// against an incoming [`RowValue`] via [`RowValue::body_eq`].
887#[derive(Debug, Clone, PartialEq)]
888pub enum RowBody {
889    /// Empty marker — `MessageQueueContent` rows have no body; the
890    /// shadow uses presence-only for skip detection (body_eq
891    /// returns true for any matching key, so the second sight of
892    /// the same content_id is treated as Skip).
893    MessageQueueContent {},
894    ToolResponse { tool_call_id: String },
895    AssistantRefusal { text: String },
896    AssistantReasoning { text: String },
897    AssistantToolCall { tool_call_id: String, arguments: String },
898    AssistantContentText { text: String },
899    AssistantContentImage { image_url: ImageUrl },
900    AssistantContentAudio { input_audio: InputAudio },
901    AssistantContentVideo { video_url: VideoUrl },
902    AssistantContentFile { file: File },
903    ToolContentText { text: String },
904    ToolContentImage { image_url: ImageUrl },
905    ToolContentAudio { input_audio: InputAudio },
906    ToolContentVideo { video_url: VideoUrl },
907    ToolContentFile { file: File },
908}