Skip to main content

objectiveai_cli/db/logs/
write.rs

1//! Per-table flat INSERT and UPDATE helpers — each issuing one CTE
2//! that fires the streaming-content / request-blob write AND the
3//! `objectiveai.messages` / `objectiveai.messages_queue` bookkeeping in a single
4//! postgres round-trip.
5//!
6//! Insert path (streaming-content INSERT or request-blob INSERT):
7//! ```sql
8//! WITH data_ins AS (
9//!     INSERT INTO logs.<table> (…) VALUES (…) RETURNING response_id
10//! )
11//! INSERT INTO objectiveai.messages (response_id, "table", row_index,
12//!                            row_sub_index, "index",
13//!                            agent_instance_hierarchy, "timestamp")
14//! SELECT $resp, $msg_table, $row_idx, $row_sub_idx,
15//!        nextval('objectiveai.messages_index_seq'),
16//!        $hier, $ts
17//! FROM data_ins;
18//! ```
19//!
20//! Update path (streaming-content UPDATE):
21//! ```sql
22//! WITH
23//!     data_upd AS (
24//!         UPDATE logs.<table> SET … WHERE … RETURNING response_id
25//!     ),
26//!     msg AS (
27//!         SELECT "index" AS msg_index FROM objectiveai.messages
28//!         WHERE response_id = $resp AND "table" = $msg_table
29//!           AND row_index IS NOT DISTINCT FROM $row_idx
30//!           AND row_sub_index IS NOT DISTINCT FROM $row_sub_idx
31//!     )
32//! UPDATE objectiveai.messages_queue
33//! SET read_index = msg.msg_index - 1
34//! FROM msg, data_upd
35//! WHERE spawned_agent_instance_hierarchy = $hier
36//!   AND read_index >= msg.msg_index;
37//! ```
38//!
39//! Response-blob writes (the three `_responses` tables) DON'T touch
40//! `objectiveai.messages` — they're not events, just the latest snapshot.
41
42use objectiveai_sdk::agent::completions::message::{File, ImageUrl, InputAudio, VideoUrl};
43use serde::Serialize;
44
45use crate::db::{Error, Pool};
46
47use super::row::{MessageTable, RowValue};
48use super::shadow::WriteOp;
49
50/// Overwrite an AIH's most-recent agent-completion `total_tokens`
51/// (last-write-wins snapshot — not a running sum).
52pub async fn update_agent_token_usage(
53    pool: &Pool,
54    agent_instance_hierarchy: &str,
55    total_tokens: i64,
56) -> Result<(), Error> {
57    sqlx::query(
58        "INSERT INTO objectiveai.agent_token_usage \
59            (agent_instance_hierarchy, total_tokens) \
60         VALUES ($1, $2) \
61         ON CONFLICT (agent_instance_hierarchy) \
62         DO UPDATE SET total_tokens = EXCLUDED.total_tokens",
63    )
64    .bind(agent_instance_hierarchy)
65    .bind(total_tokens)
66    .execute(&**pool)
67    .await?;
68    Ok(())
69}
70
71/// Dispatch SQL for `value` per `op`. `Skip` is a no-op.
72pub async fn write_value<'a>(
73    pool: &Pool,
74    op: WriteOp,
75    value: &RowValue<'a>,
76    timestamp: i64,
77) -> Result<(), Error> {
78    match op {
79        WriteOp::Skip => Ok(()),
80        WriteOp::Insert => insert_value(pool, value, timestamp).await,
81        WriteOp::Update => update_value(pool, value).await,
82    }
83}
84
85async fn insert_value<'a>(
86    pool: &Pool,
87    value: &RowValue<'a>,
88    timestamp: i64,
89) -> Result<(), Error> {
90    // MessageQueueContent: branch early, its helper resolves
91    // both the kind (and thus the objectiveai.message_table enum value)
92    // and the parent message_queue.id from `message_queue_contents`
93    // via SQL CASE/subquery. No call into `value.message_table()`
94    // — that returns `None` for this variant by design.
95    if let RowValue::MessageQueueContent {
96        response_id,
97        agent_instance_hierarchy,
98        message_queue_content_id,
99    } = *value
100    {
101        return insert_message_queue_content_with_msg(
102            pool,
103            response_id,
104            agent_instance_hierarchy,
105            message_queue_content_id,
106            timestamp,
107        )
108        .await;
109    }
110
111    // ToolResponse: the head row is written to `objectiveai.tool_response`
112    // purely as the `tool_call_id` lookup for its content rows (JOINed at
113    // read time by `read_all`). It emits NO `objectiveai.messages` event,
114    // so branch early — `value.message_table()` returns `None` for this
115    // variant and would panic the `.expect()` below.
116    if let RowValue::ToolResponse { response_id, index, tool_call_id, .. } = *value {
117        sqlx::query(
118            "INSERT INTO objectiveai.tool_response (response_id, \"index\", tool_call_id) \
119             VALUES ($1, $2, $3)",
120        )
121        .bind(response_id)
122        .bind(index as i64)
123        .bind(tool_call_id)
124        .execute(&**pool)
125        .await?;
126        return Ok(());
127    }
128
129    // RequestMessageTool head: `tool_call_id` lookup for its content
130    // rows (JOINed at read). No messages event — like `tool_response`.
131    if let RowValue::RequestMessageTool { response_id, index, tool_call_id, .. } = *value {
132        sqlx::query(
133            "INSERT INTO objectiveai.request_message_tool (response_id, \"index\", tool_call_id) \
134             VALUES ($1, $2, $3)",
135        )
136        .bind(response_id)
137        .bind(index as i64)
138        .bind(tool_call_id)
139        .execute(&**pool)
140        .await?;
141        return Ok(());
142    }
143
144    // RequestVectorChoice head: carries this agent's inline voting
145    // `key` per choice; JOIN target for the key. No messages event.
146    if let RowValue::RequestVectorChoice { response_id, choice_index, key, .. } = *value {
147        sqlx::query(
148            "INSERT INTO objectiveai.request_vector_choice (response_id, \"index\", key) \
149             VALUES ($1, $2, $3)",
150        )
151        .bind(response_id)
152        .bind(choice_index as i64)
153        .bind(key)
154        .execute(&**pool)
155        .await?;
156        return Ok(());
157    }
158
159    // ResponseVectorVote: inline per-agent vote array + a messages
160    // event with NULL row indices (JOINed by (response_id, aih) at
161    // read). Distinct shape from the generic row_index path.
162    if let RowValue::ResponseVectorVote { response_id, agent_instance_hierarchy, vote } = *value {
163        sqlx::query(
164            "WITH data_ins AS (\
165                INSERT INTO objectiveai.response_vector_vote (response_id, agent_instance_hierarchy, vote) \
166                VALUES ($1, $2, $3) RETURNING response_id\
167             )\
168             INSERT INTO objectiveai.messages \
169                (response_id, \"table\", row_index, row_sub_index, \
170                 agent_instance_hierarchy, \"timestamp\") \
171             SELECT $1, $4, NULL, NULL, $2, $5 FROM data_ins",
172        )
173        .bind(response_id)
174        .bind(agent_instance_hierarchy)
175        .bind(sqlx::types::Json(serde_json::to_value(vote)?))
176        .bind(MessageTable::ResponseVectorVote)
177        .bind(timestamp)
178        .execute(&**pool)
179        .await?;
180        return Ok(());
181    }
182
183    let mt = value.message_table();
184    let hier = value.agent_instance_hierarchy();
185    let row_index = value.row_index();
186    let row_sub_index = value.row_sub_index();
187    let response_id = value.response_id();
188
189    match *value {
190        RowValue::MessageQueueContent { .. } => unreachable!(
191            "MessageQueueContent handled by early-return branch above"
192        ),
193        RowValue::ToolResponse { .. } => unreachable!(
194            "ToolResponse handled by early-return branch above"
195        ),
196        RowValue::AssistantResponseRefusal { text, .. } => {
197            sqlx::query(
198                "WITH data_ins AS (\
199                    INSERT INTO objectiveai.assistant_response_refusal (response_id, \"index\", text) \
200                    VALUES ($1, $2, $3) RETURNING response_id\
201                 )\
202                 INSERT INTO objectiveai.messages \
203                    (response_id, \"table\", row_index, row_sub_index, \
204                     agent_instance_hierarchy, \"timestamp\") \
205                 SELECT $1, $4, $5, $6, $7, $8 FROM data_ins",
206            )
207            .bind(response_id)
208            .bind(row_index)
209            .bind(text)
210            .bind(mt)
211            .bind(row_index)
212            .bind(row_sub_index)
213            .bind(hier)
214            .bind(timestamp)
215            .execute(&**pool)
216            .await?;
217        }
218        RowValue::AssistantResponseReasoning { text, .. } => {
219            sqlx::query(
220                "WITH data_ins AS (\
221                    INSERT INTO objectiveai.assistant_response_reasoning (response_id, \"index\", text) \
222                    VALUES ($1, $2, $3) RETURNING response_id\
223                 )\
224                 INSERT INTO objectiveai.messages \
225                    (response_id, \"table\", row_index, row_sub_index, \
226                     agent_instance_hierarchy, \"timestamp\") \
227                 SELECT $1, $4, $5, $6, $7, $8 FROM data_ins",
228            )
229            .bind(response_id)
230            .bind(row_index)
231            .bind(text)
232            .bind(mt)
233            .bind(row_index)
234            .bind(row_sub_index)
235            .bind(hier)
236            .bind(timestamp)
237            .execute(&**pool)
238            .await?;
239        }
240        RowValue::AssistantResponseToolCalls {
241            tool_call_index, tool_call_id, function_name, arguments, ..
242        } => {
243            sqlx::query(
244                "WITH data_ins AS (\
245                    INSERT INTO objectiveai.assistant_response_tool_calls \
246                        (response_id, \"index\", tool_call_index, tool_call_id, function_name, arguments) \
247                    VALUES ($1, $2, $3, $4, $5, $6) RETURNING response_id\
248                 )\
249                 INSERT INTO objectiveai.messages \
250                    (response_id, \"table\", row_index, row_sub_index, \
251                     agent_instance_hierarchy, \"timestamp\") \
252                 SELECT $1, $7, $8, $9, $10, $11 FROM data_ins",
253            )
254            .bind(response_id)
255            .bind(row_index)
256            .bind(tool_call_index as i64)
257            .bind(tool_call_id)
258            .bind(function_name)
259            .bind(arguments)
260            .bind(mt)
261            .bind(row_index)
262            .bind(row_sub_index)
263            .bind(hier)
264            .bind(timestamp)
265            .execute(&**pool)
266            .await?;
267        }
268        RowValue::AssistantResponseContentText { text, .. } => {
269            insert_text_part_with_msg(pool, "objectiveai.assistant_response_content_text", value, text, timestamp).await?;
270        }
271        RowValue::ToolResponseContentText { text, .. } => {
272            insert_text_part_with_msg(pool, "objectiveai.tool_response_content_text", value, text, timestamp).await?;
273        }
274        RowValue::AssistantResponseContentImage { image_url, .. } => {
275            insert_image_part_with_msg(pool, "objectiveai.assistant_response_content_image", value, image_url, timestamp).await?;
276        }
277        RowValue::ToolResponseContentImage { image_url, .. } => {
278            insert_image_part_with_msg(pool, "objectiveai.tool_response_content_image", value, image_url, timestamp).await?;
279        }
280        RowValue::AssistantResponseContentAudio { input_audio, .. } => {
281            insert_audio_part_with_msg(pool, "objectiveai.assistant_response_content_audio", value, input_audio, timestamp).await?;
282        }
283        RowValue::ToolResponseContentAudio { input_audio, .. } => {
284            insert_audio_part_with_msg(pool, "objectiveai.tool_response_content_audio", value, input_audio, timestamp).await?;
285        }
286        RowValue::AssistantResponseContentVideo { video_url, .. } => {
287            insert_video_part_with_msg(pool, "objectiveai.assistant_response_content_video", value, video_url, timestamp).await?;
288        }
289        RowValue::ToolResponseContentVideo { video_url, .. } => {
290            insert_video_part_with_msg(pool, "objectiveai.tool_response_content_video", value, video_url, timestamp).await?;
291        }
292        RowValue::AssistantResponseContentFile { file, .. } => {
293            insert_file_part_with_msg(pool, "objectiveai.assistant_response_content_file", value, file, timestamp).await?;
294        }
295        RowValue::ToolResponseContentFile { file, .. } => {
296            insert_file_part_with_msg(pool, "objectiveai.tool_response_content_file", value, file, timestamp).await?;
297        }
298        RowValue::RequestMessageTool { .. }
299        | RowValue::RequestVectorChoice { .. }
300        | RowValue::ResponseVectorVote { .. } => unreachable!(
301            "RequestMessageTool / RequestVectorChoice / ResponseVectorVote handled by early-return branches above"
302        ),
303        // --- request_message assistant scalar slots (inline, mirror the response) ---
304        RowValue::RequestMessageAssistantRefusal { text, .. } => {
305            sqlx::query(
306                "WITH data_ins AS (\
307                    INSERT INTO objectiveai.request_message_assistant_refusal (response_id, \"index\", text) \
308                    VALUES ($1, $2, $3) RETURNING response_id\
309                 )\
310                 INSERT INTO objectiveai.messages \
311                    (response_id, \"table\", row_index, row_sub_index, \
312                     agent_instance_hierarchy, \"timestamp\") \
313                 SELECT $1, $4, $5, $6, $7, $8 FROM data_ins",
314            )
315            .bind(response_id).bind(row_index).bind(text)
316            .bind(mt).bind(row_index).bind(row_sub_index).bind(hier).bind(timestamp)
317            .execute(&**pool).await?;
318        }
319        RowValue::RequestMessageAssistantReasoning { text, .. } => {
320            sqlx::query(
321                "WITH data_ins AS (\
322                    INSERT INTO objectiveai.request_message_assistant_reasoning (response_id, \"index\", text) \
323                    VALUES ($1, $2, $3) RETURNING response_id\
324                 )\
325                 INSERT INTO objectiveai.messages \
326                    (response_id, \"table\", row_index, row_sub_index, \
327                     agent_instance_hierarchy, \"timestamp\") \
328                 SELECT $1, $4, $5, $6, $7, $8 FROM data_ins",
329            )
330            .bind(response_id).bind(row_index).bind(text)
331            .bind(mt).bind(row_index).bind(row_sub_index).bind(hier).bind(timestamp)
332            .execute(&**pool).await?;
333        }
334        RowValue::RequestMessageAssistantToolCalls {
335            tool_call_index, tool_call_id, function_name, arguments, ..
336        } => {
337            sqlx::query(
338                "WITH data_ins AS (\
339                    INSERT INTO objectiveai.request_message_assistant_tool_calls \
340                        (response_id, \"index\", tool_call_index, tool_call_id, function_name, arguments) \
341                    VALUES ($1, $2, $3, $4, $5, $6) RETURNING response_id\
342                 )\
343                 INSERT INTO objectiveai.messages \
344                    (response_id, \"table\", row_index, row_sub_index, \
345                     agent_instance_hierarchy, \"timestamp\") \
346                 SELECT $1, $7, $8, $9, $10, $11 FROM data_ins",
347            )
348            .bind(response_id).bind(row_index).bind(tool_call_index as i64)
349            .bind(tool_call_id).bind(function_name).bind(arguments)
350            .bind(mt).bind(row_index).bind(row_sub_index).bind(hier).bind(timestamp)
351            .execute(&**pool).await?;
352        }
353        // --- request_message + vector-choice content (reuse the shared helpers) ---
354        RowValue::RequestMessageUserContentText { text, .. } => {
355            insert_text_part_with_msg(pool, "objectiveai.request_message_user_content_text", value, text, timestamp).await?;
356        }
357        RowValue::RequestMessageUserContentImage { image_url, .. } => {
358            insert_image_part_with_msg(pool, "objectiveai.request_message_user_content_image", value, image_url, timestamp).await?;
359        }
360        RowValue::RequestMessageUserContentAudio { input_audio, .. } => {
361            insert_audio_part_with_msg(pool, "objectiveai.request_message_user_content_audio", value, input_audio, timestamp).await?;
362        }
363        RowValue::RequestMessageUserContentVideo { video_url, .. } => {
364            insert_video_part_with_msg(pool, "objectiveai.request_message_user_content_video", value, video_url, timestamp).await?;
365        }
366        RowValue::RequestMessageUserContentFile { file, .. } => {
367            insert_file_part_with_msg(pool, "objectiveai.request_message_user_content_file", value, file, timestamp).await?;
368        }
369        RowValue::RequestMessageAssistantContentText { text, .. } => {
370            insert_text_part_with_msg(pool, "objectiveai.request_message_assistant_content_text", value, text, timestamp).await?;
371        }
372        RowValue::RequestMessageAssistantContentImage { image_url, .. } => {
373            insert_image_part_with_msg(pool, "objectiveai.request_message_assistant_content_image", value, image_url, timestamp).await?;
374        }
375        RowValue::RequestMessageAssistantContentAudio { input_audio, .. } => {
376            insert_audio_part_with_msg(pool, "objectiveai.request_message_assistant_content_audio", value, input_audio, timestamp).await?;
377        }
378        RowValue::RequestMessageAssistantContentVideo { video_url, .. } => {
379            insert_video_part_with_msg(pool, "objectiveai.request_message_assistant_content_video", value, video_url, timestamp).await?;
380        }
381        RowValue::RequestMessageAssistantContentFile { file, .. } => {
382            insert_file_part_with_msg(pool, "objectiveai.request_message_assistant_content_file", value, file, timestamp).await?;
383        }
384        RowValue::RequestMessageToolContentText { text, .. } => {
385            insert_text_part_with_msg(pool, "objectiveai.request_message_tool_content_text", value, text, timestamp).await?;
386        }
387        RowValue::RequestMessageToolContentImage { image_url, .. } => {
388            insert_image_part_with_msg(pool, "objectiveai.request_message_tool_content_image", value, image_url, timestamp).await?;
389        }
390        RowValue::RequestMessageToolContentAudio { input_audio, .. } => {
391            insert_audio_part_with_msg(pool, "objectiveai.request_message_tool_content_audio", value, input_audio, timestamp).await?;
392        }
393        RowValue::RequestMessageToolContentVideo { video_url, .. } => {
394            insert_video_part_with_msg(pool, "objectiveai.request_message_tool_content_video", value, video_url, timestamp).await?;
395        }
396        RowValue::RequestMessageToolContentFile { file, .. } => {
397            insert_file_part_with_msg(pool, "objectiveai.request_message_tool_content_file", value, file, timestamp).await?;
398        }
399        RowValue::RequestVectorChoiceContentText { text, .. } => {
400            insert_text_part_with_msg(pool, "objectiveai.request_vector_choice_content_text", value, text, timestamp).await?;
401        }
402        RowValue::RequestVectorChoiceContentImage { image_url, .. } => {
403            insert_image_part_with_msg(pool, "objectiveai.request_vector_choice_content_image", value, image_url, timestamp).await?;
404        }
405        RowValue::RequestVectorChoiceContentAudio { input_audio, .. } => {
406            insert_audio_part_with_msg(pool, "objectiveai.request_vector_choice_content_audio", value, input_audio, timestamp).await?;
407        }
408        RowValue::RequestVectorChoiceContentVideo { video_url, .. } => {
409            insert_video_part_with_msg(pool, "objectiveai.request_vector_choice_content_video", value, video_url, timestamp).await?;
410        }
411        RowValue::RequestVectorChoiceContentFile { file, .. } => {
412            insert_file_part_with_msg(pool, "objectiveai.request_vector_choice_content_file", value, file, timestamp).await?;
413        }
414    }
415    Ok(())
416}
417
418async fn update_value<'a>(pool: &Pool, value: &RowValue<'a>) -> Result<(), Error> {
419    // MessageQueueContent has no updatable body — the shadow's
420    // body_eq returns true for any matching key, so this branch
421    // is unreachable in practice. Short-circuit defensively.
422    if matches!(value, RowValue::MessageQueueContent { .. }) {
423        return Ok(());
424    }
425
426    // ToolResponse: its head row has no `messages` event (it's a
427    // `tool_call_id` lookup only), so there is nothing to downgrade.
428    // `tool_call_id` is immutable in practice (the shadow's `body_eq`
429    // returns true, so an Update is never dispatched), making this
430    // branch effectively unreachable; do a bare UPDATE defensively.
431    // Branch early — `value.message_table()` returns `None` for this
432    // variant and would panic the `.expect()` below.
433    if let RowValue::ToolResponse { response_id, index, tool_call_id, .. } = *value {
434        sqlx::query(
435            "UPDATE objectiveai.tool_response SET tool_call_id = $1 \
436             WHERE response_id = $2 AND \"index\" = $3",
437        )
438        .bind(tool_call_id)
439        .bind(response_id)
440        .bind(index as i64)
441        .execute(&**pool)
442        .await?;
443        return Ok(());
444    }
445
446    // Head rows + vote: no messages downgrade (write-once in practice —
447    // the shadow returns Skip, so Update is never dispatched). Bare
448    // UPDATE defensively, early-branched before the message_table path.
449    if let RowValue::RequestMessageTool { response_id, index, tool_call_id, .. } = *value {
450        sqlx::query(
451            "UPDATE objectiveai.request_message_tool SET tool_call_id = $1 \
452             WHERE response_id = $2 AND \"index\" = $3",
453        )
454        .bind(tool_call_id).bind(response_id).bind(index as i64)
455        .execute(&**pool).await?;
456        return Ok(());
457    }
458    if let RowValue::RequestVectorChoice { response_id, choice_index, key, .. } = *value {
459        sqlx::query(
460            "UPDATE objectiveai.request_vector_choice SET key = $1 \
461             WHERE response_id = $2 AND \"index\" = $3",
462        )
463        .bind(key).bind(response_id).bind(choice_index as i64)
464        .execute(&**pool).await?;
465        return Ok(());
466    }
467    if let RowValue::ResponseVectorVote { response_id, agent_instance_hierarchy, vote } = *value {
468        sqlx::query(
469            "UPDATE objectiveai.response_vector_vote SET vote = $1 \
470             WHERE response_id = $2 AND agent_instance_hierarchy = $3",
471        )
472        .bind(sqlx::types::Json(serde_json::to_value(vote)?))
473        .bind(response_id).bind(agent_instance_hierarchy)
474        .execute(&**pool).await?;
475        return Ok(());
476    }
477
478    let mt = value.message_table();
479    let hier = value.agent_instance_hierarchy();
480    let row_index = value.row_index();
481    let row_sub_index = value.row_sub_index();
482    let response_id = value.response_id();
483
484    match *value {
485        RowValue::MessageQueueContent { .. } => unreachable!(
486            "MessageQueueContent handled by short-circuit above"
487        ),
488        RowValue::ToolResponse { .. } => unreachable!(
489            "ToolResponse handled by short-circuit above"
490        ),
491        RowValue::AssistantResponseRefusal { text, .. }
492        | RowValue::RequestMessageAssistantRefusal { text, .. } => {
493            let table = match *value {
494                RowValue::AssistantResponseRefusal { .. } => "objectiveai.assistant_response_refusal",
495                _ => "objectiveai.request_message_assistant_refusal",
496            };
497            let sql = format!("UPDATE {table} SET text = $A WHERE response_id = $RESP AND \"index\" = $RI");
498            run_update_with_downgrade(
499                pool, &sql,
500                response_id, row_index, row_sub_index, mt, hier,
501                &[("A", BindVal::Str(text))],
502                &[BindIdx::Resp, BindIdx::Ri],
503            ).await?;
504        }
505        RowValue::AssistantResponseReasoning { text, .. }
506        | RowValue::RequestMessageAssistantReasoning { text, .. } => {
507            let table = match *value {
508                RowValue::AssistantResponseReasoning { .. } => "objectiveai.assistant_response_reasoning",
509                _ => "objectiveai.request_message_assistant_reasoning",
510            };
511            let sql = format!("UPDATE {table} SET text = $A WHERE response_id = $RESP AND \"index\" = $RI");
512            run_update_with_downgrade(
513                pool, &sql,
514                response_id, row_index, row_sub_index, mt, hier,
515                &[("A", BindVal::Str(text))],
516                &[BindIdx::Resp, BindIdx::Ri],
517            ).await?;
518        }
519        RowValue::AssistantResponseToolCalls { tool_call_index, tool_call_id, function_name, arguments, .. }
520        | RowValue::RequestMessageAssistantToolCalls { tool_call_index, tool_call_id, function_name, arguments, .. } => {
521            let table = match *value {
522                RowValue::AssistantResponseToolCalls { .. } => "objectiveai.assistant_response_tool_calls",
523                _ => "objectiveai.request_message_assistant_tool_calls",
524            };
525            let sql = format!(
526                "UPDATE {table} SET tool_call_id = $A, function_name = $B, arguments = $C \
527                 WHERE response_id = $RESP AND \"index\" = $RI AND tool_call_index = $RSI"
528            );
529            run_update_with_downgrade(
530                pool, &sql,
531                response_id, row_index, row_sub_index, mt, hier,
532                &[("A", BindVal::Str(tool_call_id)), ("B", BindVal::Str(function_name)), ("C", BindVal::Str(arguments))],
533                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
534            ).await?;
535            let _ = tool_call_index;
536        }
537        RowValue::AssistantResponseContentText { text, .. }
538        | RowValue::ToolResponseContentText { text, .. }
539        | RowValue::RequestMessageUserContentText { text, .. }
540        | RowValue::RequestMessageAssistantContentText { text, .. }
541        | RowValue::RequestMessageToolContentText { text, .. }
542        | RowValue::RequestVectorChoiceContentText { text, .. } => {
543            let table = match *value {
544                RowValue::AssistantResponseContentText { .. } => "objectiveai.assistant_response_content_text",
545                RowValue::ToolResponseContentText { .. } => "objectiveai.tool_response_content_text",
546                RowValue::RequestMessageUserContentText { .. } => "objectiveai.request_message_user_content_text",
547                RowValue::RequestMessageAssistantContentText { .. } => "objectiveai.request_message_assistant_content_text",
548                RowValue::RequestMessageToolContentText { .. } => "objectiveai.request_message_tool_content_text",
549                _ => "objectiveai.request_vector_choice_content_text",
550            };
551            let sql = format!(
552                "UPDATE {table} SET text = $A \
553                 WHERE response_id = $RESP AND \"index\" = $RI AND part_index = $RSI"
554            );
555            run_update_with_downgrade(
556                pool, &sql,
557                response_id, row_index, row_sub_index, mt, hier,
558                &[("A", BindVal::Str(text))],
559                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
560            ).await?;
561        }
562        RowValue::AssistantResponseContentImage { image_url, .. }
563        | RowValue::ToolResponseContentImage { image_url, .. }
564        | RowValue::RequestMessageUserContentImage { image_url, .. }
565        | RowValue::RequestMessageAssistantContentImage { image_url, .. }
566        | RowValue::RequestMessageToolContentImage { image_url, .. }
567        | RowValue::RequestVectorChoiceContentImage { image_url, .. } => {
568            let table = match *value {
569                RowValue::AssistantResponseContentImage { .. } => "objectiveai.assistant_response_content_image",
570                RowValue::ToolResponseContentImage { .. } => "objectiveai.tool_response_content_image",
571                RowValue::RequestMessageUserContentImage { .. } => "objectiveai.request_message_user_content_image",
572                RowValue::RequestMessageAssistantContentImage { .. } => "objectiveai.request_message_assistant_content_image",
573                RowValue::RequestMessageToolContentImage { .. } => "objectiveai.request_message_tool_content_image",
574                _ => "objectiveai.request_vector_choice_content_image",
575            };
576            let detail = image_url.detail.as_ref().and_then(|d| serde_json::to_string(d).ok());
577            let sql = format!(
578                "UPDATE {table} SET url = $A, detail = $B \
579                 WHERE response_id = $RESP AND \"index\" = $RI AND part_index = $RSI"
580            );
581            run_update_with_downgrade(
582                pool, &sql,
583                response_id, row_index, row_sub_index, mt, hier,
584                &[("A", BindVal::Str(image_url.url.as_str())), ("B", BindVal::OptString(detail))],
585                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
586            ).await?;
587        }
588        RowValue::AssistantResponseContentAudio { input_audio, .. }
589        | RowValue::ToolResponseContentAudio { input_audio, .. }
590        | RowValue::RequestMessageUserContentAudio { input_audio, .. }
591        | RowValue::RequestMessageAssistantContentAudio { input_audio, .. }
592        | RowValue::RequestMessageToolContentAudio { input_audio, .. }
593        | RowValue::RequestVectorChoiceContentAudio { input_audio, .. } => {
594            let table = match *value {
595                RowValue::AssistantResponseContentAudio { .. } => "objectiveai.assistant_response_content_audio",
596                RowValue::ToolResponseContentAudio { .. } => "objectiveai.tool_response_content_audio",
597                RowValue::RequestMessageUserContentAudio { .. } => "objectiveai.request_message_user_content_audio",
598                RowValue::RequestMessageAssistantContentAudio { .. } => "objectiveai.request_message_assistant_content_audio",
599                RowValue::RequestMessageToolContentAudio { .. } => "objectiveai.request_message_tool_content_audio",
600                _ => "objectiveai.request_vector_choice_content_audio",
601            };
602            let sql = format!(
603                "UPDATE {table} SET data = $A, format = $B \
604                 WHERE response_id = $RESP AND \"index\" = $RI AND part_index = $RSI"
605            );
606            run_update_with_downgrade(
607                pool, &sql,
608                response_id, row_index, row_sub_index, mt, hier,
609                &[
610                    ("A", BindVal::Str(input_audio.data.as_str())),
611                    ("B", BindVal::Str(input_audio.format.as_str())),
612                ],
613                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
614            ).await?;
615        }
616        RowValue::AssistantResponseContentVideo { video_url, .. }
617        | RowValue::ToolResponseContentVideo { video_url, .. }
618        | RowValue::RequestMessageUserContentVideo { video_url, .. }
619        | RowValue::RequestMessageAssistantContentVideo { video_url, .. }
620        | RowValue::RequestMessageToolContentVideo { video_url, .. }
621        | RowValue::RequestVectorChoiceContentVideo { video_url, .. } => {
622            let table = match *value {
623                RowValue::AssistantResponseContentVideo { .. } => "objectiveai.assistant_response_content_video",
624                RowValue::ToolResponseContentVideo { .. } => "objectiveai.tool_response_content_video",
625                RowValue::RequestMessageUserContentVideo { .. } => "objectiveai.request_message_user_content_video",
626                RowValue::RequestMessageAssistantContentVideo { .. } => "objectiveai.request_message_assistant_content_video",
627                RowValue::RequestMessageToolContentVideo { .. } => "objectiveai.request_message_tool_content_video",
628                _ => "objectiveai.request_vector_choice_content_video",
629            };
630            let sql = format!(
631                "UPDATE {table} SET url = $A \
632                 WHERE response_id = $RESP AND \"index\" = $RI AND part_index = $RSI"
633            );
634            run_update_with_downgrade(
635                pool, &sql,
636                response_id, row_index, row_sub_index, mt, hier,
637                &[("A", BindVal::Str(video_url.url.as_str()))],
638                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
639            ).await?;
640        }
641        RowValue::AssistantResponseContentFile { file, .. }
642        | RowValue::ToolResponseContentFile { file, .. }
643        | RowValue::RequestMessageUserContentFile { file, .. }
644        | RowValue::RequestMessageAssistantContentFile { file, .. }
645        | RowValue::RequestMessageToolContentFile { file, .. }
646        | RowValue::RequestVectorChoiceContentFile { file, .. } => {
647            let table = match *value {
648                RowValue::AssistantResponseContentFile { .. } => "objectiveai.assistant_response_content_file",
649                RowValue::ToolResponseContentFile { .. } => "objectiveai.tool_response_content_file",
650                RowValue::RequestMessageUserContentFile { .. } => "objectiveai.request_message_user_content_file",
651                RowValue::RequestMessageAssistantContentFile { .. } => "objectiveai.request_message_assistant_content_file",
652                RowValue::RequestMessageToolContentFile { .. } => "objectiveai.request_message_tool_content_file",
653                _ => "objectiveai.request_vector_choice_content_file",
654            };
655            let sql = format!(
656                "UPDATE {table} SET file_data = $A, file_id = $B, filename = $C, file_url = $D \
657                 WHERE response_id = $RESP AND \"index\" = $RI AND part_index = $RSI"
658            );
659            run_update_with_downgrade(
660                pool, &sql,
661                response_id, row_index, row_sub_index, mt, hier,
662                &[
663                    ("A", BindVal::OptStr(file.file_data.as_deref())),
664                    ("B", BindVal::OptStr(file.file_id.as_deref())),
665                    ("C", BindVal::OptStr(file.filename.as_deref())),
666                    ("D", BindVal::OptStr(file.file_url.as_deref())),
667                ],
668                &[BindIdx::Resp, BindIdx::Ri, BindIdx::Rsi],
669            ).await?;
670        }
671        RowValue::RequestMessageTool { .. }
672        | RowValue::RequestVectorChoice { .. }
673        | RowValue::ResponseVectorVote { .. } => unreachable!(
674            "RequestMessageTool / RequestVectorChoice / ResponseVectorVote handled by early-return branches above"
675        ),
676    }
677    Ok(())
678}
679
680// ---- INSERT helpers for content parts (shared CTE shape) -------------
681
682async fn insert_text_part_with_msg<'a>(
683    pool: &Pool,
684    table: &str,
685    value: &RowValue<'a>,
686    text: &str,
687    timestamp: i64,
688) -> Result<(), Error> {
689    let sql = format!(
690        "WITH data_ins AS (\
691            INSERT INTO {table} (response_id, \"index\", part_index, text) \
692            VALUES ($1, $2, $3, $4) RETURNING response_id\
693         )\
694         INSERT INTO objectiveai.messages \
695            (response_id, \"table\", row_index, row_sub_index, \
696             agent_instance_hierarchy, \"timestamp\") \
697         SELECT $1, $5, $6, $7, $8, $9 FROM data_ins"
698    );
699    sqlx::query(&sql)
700        .bind(value.response_id())
701        .bind(value.row_index())
702        .bind(value.row_sub_index())
703        .bind(text)
704        .bind(value.message_table())
705        .bind(value.row_index())
706        .bind(value.row_sub_index())
707        .bind(value.agent_instance_hierarchy())
708        .bind(timestamp)
709        .execute(&**pool)
710        .await?;
711    Ok(())
712}
713
714async fn insert_image_part_with_msg<'a>(
715    pool: &Pool,
716    table: &str,
717    value: &RowValue<'a>,
718    image: &ImageUrl,
719    timestamp: i64,
720) -> Result<(), Error> {
721    let detail = image.detail.as_ref().and_then(|d| serde_json::to_string(d).ok());
722    let sql = format!(
723        "WITH data_ins AS (\
724            INSERT INTO {table} (response_id, \"index\", part_index, url, detail) \
725            VALUES ($1, $2, $3, $4, $5) RETURNING response_id\
726         )\
727         INSERT INTO objectiveai.messages \
728            (response_id, \"table\", row_index, row_sub_index, \
729             agent_instance_hierarchy, \"timestamp\") \
730         SELECT $1, $6, $7, $8, $9, $10 FROM data_ins"
731    );
732    sqlx::query(&sql)
733        .bind(value.response_id())
734        .bind(value.row_index())
735        .bind(value.row_sub_index())
736        .bind(image.url.as_str())
737        .bind(detail)
738        .bind(value.message_table())
739        .bind(value.row_index())
740        .bind(value.row_sub_index())
741        .bind(value.agent_instance_hierarchy())
742        .bind(timestamp)
743        .execute(&**pool)
744        .await?;
745    Ok(())
746}
747
748async fn insert_audio_part_with_msg<'a>(
749    pool: &Pool,
750    table: &str,
751    value: &RowValue<'a>,
752    audio: &InputAudio,
753    timestamp: i64,
754) -> Result<(), Error> {
755    let sql = format!(
756        "WITH data_ins AS (\
757            INSERT INTO {table} (response_id, \"index\", part_index, data, format) \
758            VALUES ($1, $2, $3, $4, $5) RETURNING response_id\
759         )\
760         INSERT INTO objectiveai.messages \
761            (response_id, \"table\", row_index, row_sub_index, \
762             agent_instance_hierarchy, \"timestamp\") \
763         SELECT $1, $6, $7, $8, $9, $10 FROM data_ins"
764    );
765    sqlx::query(&sql)
766        .bind(value.response_id())
767        .bind(value.row_index())
768        .bind(value.row_sub_index())
769        .bind(audio.data.as_str())
770        .bind(audio.format.as_str())
771        .bind(value.message_table())
772        .bind(value.row_index())
773        .bind(value.row_sub_index())
774        .bind(value.agent_instance_hierarchy())
775        .bind(timestamp)
776        .execute(&**pool)
777        .await?;
778    Ok(())
779}
780
781/// Consumption-flip + log emit for a single
782/// `message_queue_contents.id`. One SQL statement:
783///
784/// 1. `content` CTE looks up the content row to get its `kind`
785///    and parent `message_queue_id`.
786/// 2. `flip` CTE flips `message_queue.active = FALSE` for the
787///    parent (no-op if already false via the `AND active = TRUE`
788///    guard, so repeat content_ids sharing one parent fire the
789///    flip exactly once).
790/// 3. INSERT a `objectiveai.messages` row with `"table"` chosen by SQL
791///    CASE off the content's kind (`message_queue_text` / `_image`
792///    / `_audio` / `_video` / `_file`), `row_index = content_id`,
793///    no sub-index.
794async fn insert_message_queue_content_with_msg(
795    pool: &Pool,
796    response_id: &str,
797    agent_instance_hierarchy: &str,
798    message_queue_content_id: i64,
799    timestamp: i64,
800) -> Result<(), Error> {
801    sqlx::query(
802        "WITH content AS (\
803             SELECT id, kind, message_queue_id \
804             FROM objectiveai.message_queue_contents \
805             WHERE id = $1 \
806         ), \
807         flip AS (\
808             UPDATE objectiveai.message_queue \
809             SET active = FALSE \
810             WHERE id = (SELECT message_queue_id FROM content) \
811               AND active = TRUE \
812             RETURNING id \
813         ) \
814         INSERT INTO objectiveai.messages \
815             (response_id, \"table\", row_index, row_sub_index, \
816              agent_instance_hierarchy, \"timestamp\") \
817         SELECT $2, \
818                CASE (SELECT kind FROM content) \
819                    WHEN 'text'  THEN 'message_queue_text'::objectiveai.message_table \
820                    WHEN 'image' THEN 'message_queue_image'::objectiveai.message_table \
821                    WHEN 'audio' THEN 'message_queue_audio'::objectiveai.message_table \
822                    WHEN 'video' THEN 'message_queue_video'::objectiveai.message_table \
823                    WHEN 'file'  THEN 'message_queue_file'::objectiveai.message_table \
824                END, \
825                $1, NULL, $3, $4 \
826         FROM content",
827    )
828    .bind(message_queue_content_id)
829    .bind(response_id)
830    .bind(agent_instance_hierarchy)
831    .bind(timestamp)
832    .execute(&**pool)
833    .await?;
834    Ok(())
835}
836
837async fn insert_video_part_with_msg<'a>(
838    pool: &Pool,
839    table: &str,
840    value: &RowValue<'a>,
841    video: &VideoUrl,
842    timestamp: i64,
843) -> Result<(), Error> {
844    let sql = format!(
845        "WITH data_ins AS (\
846            INSERT INTO {table} (response_id, \"index\", part_index, url) \
847            VALUES ($1, $2, $3, $4) RETURNING response_id\
848         )\
849         INSERT INTO objectiveai.messages \
850            (response_id, \"table\", row_index, row_sub_index, \
851             agent_instance_hierarchy, \"timestamp\") \
852         SELECT $1, $5, $6, $7, $8, $9 FROM data_ins"
853    );
854    sqlx::query(&sql)
855        .bind(value.response_id())
856        .bind(value.row_index())
857        .bind(value.row_sub_index())
858        .bind(video.url.as_str())
859        .bind(value.message_table())
860        .bind(value.row_index())
861        .bind(value.row_sub_index())
862        .bind(value.agent_instance_hierarchy())
863        .bind(timestamp)
864        .execute(&**pool)
865        .await?;
866    Ok(())
867}
868
869async fn insert_file_part_with_msg<'a>(
870    pool: &Pool,
871    table: &str,
872    value: &RowValue<'a>,
873    file: &File,
874    timestamp: i64,
875) -> Result<(), Error> {
876    let sql = format!(
877        "WITH data_ins AS (\
878            INSERT INTO {table} (response_id, \"index\", part_index, file_data, file_id, filename, file_url) \
879            VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING response_id\
880         )\
881         INSERT INTO objectiveai.messages \
882            (response_id, \"table\", row_index, row_sub_index, \
883             agent_instance_hierarchy, \"timestamp\") \
884         SELECT $1, $8, $9, $10, $11, $12 FROM data_ins"
885    );
886    sqlx::query(&sql)
887        .bind(value.response_id())
888        .bind(value.row_index())
889        .bind(value.row_sub_index())
890        .bind(file.file_data.as_deref())
891        .bind(file.file_id.as_deref())
892        .bind(file.filename.as_deref())
893        .bind(file.file_url.as_deref())
894        .bind(value.message_table())
895        .bind(value.row_index())
896        .bind(value.row_sub_index())
897        .bind(value.agent_instance_hierarchy())
898        .bind(timestamp)
899        .execute(&**pool)
900        .await?;
901    Ok(())
902}
903
904// ---- UPDATE helper: streaming row + messages_queue downgrade -----------
905//
906// The update is parameterized on a placeholder SQL template that uses
907// named tokens for the response_id / row_index / row_sub_index binds
908// plus arbitrary per-table value binds (A, B, C, D). The helper
909// rewrites the tokens into positional $N placeholders and appends the
910// messages-queue downgrade CTE.
911
912#[derive(Clone, Copy)]
913enum BindIdx {
914    Resp,
915    Ri,
916    Rsi,
917}
918
919enum BindVal<'a> {
920    Str(&'a str),
921    OptStr(Option<&'a str>),
922    OptString(Option<String>),
923    Bool(bool),
924}
925
926#[allow(clippy::too_many_arguments)]
927async fn run_update_with_downgrade<'a>(
928    pool: &Pool,
929    update_sql_template: &str,
930    response_id: &str,
931    row_index: i64,
932    row_sub_index: Option<i64>,
933    message_table: MessageTable,
934    agent_instance_hierarchy: &str,
935    extra_binds: &[(&str, BindVal<'a>)],
936    update_where_binds: &[BindIdx],
937) -> Result<(), Error> {
938    // Assign positional indices. Order in the final SQL:
939    //   $1..$N  = update_where_binds in order, then extra_binds in
940    //             declaration order. (We rewrite the template's
941    //             named tokens accordingly.)
942    //   $(N+1)  = message_table
943    //   $(N+2)  = row_index
944    //   $(N+3)  = row_sub_index
945    //   $(N+4)  = agent_instance_hierarchy
946    let mut sql = update_sql_template.to_string();
947    let mut pos = 1usize;
948
949    // Replace each WHERE bind token with its positional index. The
950    // resp/ri/rsi positions inside `sql` are written back into the
951    // template via `sql.replace(...)`; we only need to remember
952    // resp_pos for the downgrade CTE below.
953    let mut resp_pos: Option<usize> = None;
954    for slot in update_where_binds {
955        let idx = pos;
956        pos += 1;
957        match slot {
958            BindIdx::Resp => {
959                sql = sql.replace("$RESP", &format!("${idx}"));
960                resp_pos = Some(idx);
961            }
962            BindIdx::Ri => {
963                sql = sql.replace("$RI", &format!("${idx}"));
964            }
965            BindIdx::Rsi => {
966                sql = sql.replace("$RSI", &format!("${idx}"));
967            }
968        }
969    }
970    let resp_pos = resp_pos.expect("Resp bind required");
971
972    // Replace extra-bind tokens ($A, $B, $C, $D) with positional.
973    for (token, _val) in extra_binds {
974        let idx = pos;
975        pos += 1;
976        sql = sql.replace(&format!("${token}"), &format!("${idx}"));
977    }
978
979    let mt_pos = pos; pos += 1;
980    let ri_for_msg_pos = pos; pos += 1;
981    let rsi_for_msg_pos = pos; pos += 1;
982    let hier_pos = pos;
983
984    let final_sql = format!(
985        "WITH \
986            data_upd AS ({sql} RETURNING response_id),\
987            msg AS (\
988                SELECT \"index\" AS msg_index FROM objectiveai.messages \
989                WHERE response_id = ${resp_pos} \
990                  AND \"table\" = ${mt_pos} \
991                  AND row_index IS NOT DISTINCT FROM ${ri_for_msg_pos} \
992                  AND row_sub_index IS NOT DISTINCT FROM ${rsi_for_msg_pos}\
993            )\
994         UPDATE objectiveai.messages_queue \
995         SET read_index = msg.msg_index - 1 \
996         FROM msg, data_upd \
997         WHERE spawned_agent_instance_hierarchy = ${hier_pos} \
998           AND read_index >= msg.msg_index",
999    );
1000
1001    let mut q = sqlx::query(&final_sql);
1002    // Bind WHERE clause values in their declared order.
1003    for slot in update_where_binds {
1004        q = match slot {
1005            BindIdx::Resp => q.bind(response_id),
1006            BindIdx::Ri => q.bind(row_index),
1007            BindIdx::Rsi => q.bind(row_sub_index),
1008        };
1009    }
1010    // Bind extra values.
1011    for (_, val) in extra_binds {
1012        q = match val {
1013            BindVal::Str(s) => q.bind(*s),
1014            BindVal::OptStr(s) => q.bind(*s),
1015            BindVal::OptString(s) => q.bind(s.clone()),
1016            BindVal::Bool(b) => q.bind(*b),
1017        };
1018    }
1019    // Bind messages-row identification + agent hierarchy.
1020    q = q.bind(message_table);
1021    q = q.bind(row_index);
1022    q = q.bind(row_sub_index);
1023    q = q.bind(agent_instance_hierarchy);
1024
1025    q.execute(&**pool).await?;
1026    Ok(())
1027}
1028
1029// =====================================================================
1030// Tier blob writes
1031// =====================================================================
1032
1033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1034pub enum Tier {
1035    Agent,
1036    Vector,
1037    Function,
1038}
1039
1040impl Tier {
1041    pub fn request_table(self) -> &'static str {
1042        match self {
1043            Tier::Agent => "objectiveai.agent_completion_requests",
1044            Tier::Vector => "objectiveai.vector_completion_requests",
1045            Tier::Function => "objectiveai.function_execution_requests",
1046        }
1047    }
1048    pub fn response_table(self) -> &'static str {
1049        match self {
1050            Tier::Agent => "objectiveai.agent_completion_responses",
1051            Tier::Vector => "objectiveai.vector_completion_responses",
1052            Tier::Function => "objectiveai.function_execution_responses",
1053        }
1054    }
1055    /// The matching [`MessageTable`] for this tier's request blob.
1056    /// Response blobs don't emit messages so there's no equivalent.
1057    pub fn request_message_table(self) -> MessageTable {
1058        match self {
1059            Tier::Agent => MessageTable::AgentCompletionRequest,
1060            Tier::Vector => MessageTable::VectorCompletionRequest,
1061            Tier::Function => MessageTable::FunctionExecutionRequest,
1062        }
1063    }
1064}
1065
1066/// INSERT the request blob. Called once per stream, on first chunk
1067/// arrival. Request blobs don't carry `agent_instance_hierarchy` —
1068/// they're shared across every agent that participates in the stream.
1069/// The per-agent "the request was made for me" linkage lives in
1070/// `objectiveai.messages` and is written separately by
1071/// [`insert_request_messages_row`] the first time each agent appears
1072/// in the chunk's row iterator.
1073pub async fn insert_request_blob<P: Serialize>(
1074    pool: &Pool,
1075    tier: Tier,
1076    response_id: &str,
1077    params: &P,
1078    sender_agent_instance_hierarchy: &str,
1079    timestamp: i64,
1080) -> Result<(), Error> {
1081    let body = serde_json::to_value(params)?;
1082    let sql = format!(
1083        "INSERT INTO {table} \
1084            (response_id, body, created_at, sender_agent_instance_hierarchy) \
1085         VALUES ($1, $2, $3, $4)",
1086        table = tier.request_table()
1087    );
1088    sqlx::query(&sql)
1089        .bind(response_id)
1090        .bind(sqlx::types::Json(body))
1091        .bind(timestamp)
1092        .bind(sender_agent_instance_hierarchy)
1093        .execute(&**pool)
1094        .await?;
1095    Ok(())
1096}
1097
1098/// INSERT a `objectiveai.messages` row that registers this stream's request
1099/// blob in the agent's history. Called once per (stream, agent) pair
1100/// — the writer tracks which agents it has already seen and only
1101/// emits this row the first time it encounters a new one in the row
1102/// iterator. By postgres's BIGSERIAL `"index"` assignment, this row
1103/// is guaranteed to land earlier in the agent's history than any
1104/// subsequent streaming-content row that the same writer call
1105/// sequences after it.
1106pub async fn insert_request_messages_row(
1107    pool: &Pool,
1108    tier: Tier,
1109    response_id: &str,
1110    agent_instance_hierarchy: &str,
1111    timestamp: i64,
1112) -> Result<(), Error> {
1113    sqlx::query(
1114        "INSERT INTO objectiveai.messages \
1115            (response_id, \"table\", row_index, row_sub_index, \
1116             agent_instance_hierarchy, \"timestamp\") \
1117         VALUES ($1, $2, NULL, NULL, $3, $4)",
1118    )
1119    .bind(response_id)
1120    .bind(tier.request_message_table())
1121    .bind(agent_instance_hierarchy)
1122    .bind(timestamp)
1123    .execute(&**pool)
1124    .await?;
1125    Ok(())
1126}
1127
1128/// INSERT the response tier blob (first tick only). Response blobs
1129/// don't emit messages — they're the latest snapshot, not events.
1130/// Tier-symmetric: every tier's response table now has the same
1131/// `(response_id, body, created_at, inserted_at)` shape.
1132pub async fn insert_response_blob<C: Serialize>(
1133    pool: &Pool,
1134    tier: Tier,
1135    response_id: &str,
1136    chunk: &C,
1137    created_at: i64,
1138) -> Result<(), Error> {
1139    let body = serde_json::to_value(chunk)?;
1140    let sql = format!(
1141        "INSERT INTO {table} (response_id, body, created_at) VALUES ($1, $2, $3)",
1142        table = tier.response_table()
1143    );
1144    sqlx::query(&sql)
1145        .bind(response_id)
1146        .bind(sqlx::types::Json(body))
1147        .bind(created_at)
1148        .execute(&**pool)
1149        .await?;
1150    Ok(())
1151}
1152
1153/// UPDATE the response tier blob (subsequent ticks).
1154pub async fn update_response_blob<C: Serialize>(
1155    pool: &Pool,
1156    tier: Tier,
1157    response_id: &str,
1158    chunk: &C,
1159    created_at: i64,
1160) -> Result<(), Error> {
1161    let body = serde_json::to_value(chunk)?;
1162    let sql = format!(
1163        "UPDATE {table} SET body = $2, created_at = $3 WHERE response_id = $1",
1164        table = tier.response_table()
1165    );
1166    sqlx::query(&sql)
1167        .bind(response_id)
1168        .bind(sqlx::types::Json(body))
1169        .bind(created_at)
1170        .execute(&**pool)
1171        .await?;
1172    Ok(())
1173}