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