Skip to main content

objectiveai_cli/db/logs/
read_all.rs

1//! `agents logs read all` / `agents logs read pending` backend:
2//! SELECT `objectiveai.messages` rows for a target AIH (or every child
3//! AIH of a parent), JOIN through to the row's source table to
4//! pull `sender_agent_instance_hierarchy` (+ `timestamp_queued`
5//! for `message_queue_*` kinds), coalesce consecutive rows into
6//! `ResponseItem` blocks, and yield them in index order.
7//!
8//! Sender + timestamp_queued live on the row's source table — the
9//! three `logs.<tier>_completion_requests` tables for request +
10//! assistant_response_* + tool_response* rows (all reachable by
11//! `response_id`), and `message_queue` via
12//! `message_queue_contents` for the five `message_queue_*` row
13//! kinds. We LEFT JOIN all four sources unconditionally and let
14//! the `CASE` over `m."table"` pick the right column. No
15//! denormalized shadow copies on `objectiveai.messages`.
16//!
17//! Block-coalesce rule: a new block starts when ANY of `(class,
18//! agent_instance_hierarchy, response_id)` changes — PLUS, for
19//! `ClientNotification` rows, when the `sender_agent_instance_hierarchy`
20//! changes. Assistant/Tool blocks ignore sender because their
21//! producer IS the agent (no separate sender exists). The three
22//! request-blob classes are always single-row blocks.
23//!
24//! `read pending` is read-and-advance, expressed as a single
25//! CTE-chained SQL statement: the SELECT returns the pending rows,
26//! and a paired UPDATE bumps each affected
27//! `objectiveai.messages_queue.read_index` to `GREATEST(current,
28//! max_returned)` — never downgraded.
29
30use objectiveai_sdk::cli::command::agents::logs::read::all::{
31    AssistantResponsePart, ClientNotificationPart, ClientNotificationPartType, ResponseItem,
32    ToolResponsePart, ToolResponsePartType,
33};
34use sqlx::Row as _;
35
36use super::super::{Error, Pool};
37use super::row::MessageTable;
38
39/// One materialized `objectiveai.messages` row plus the joined-in sender
40/// (and queue parent + enqueued_at for `message_queue_*` rows).
41struct MsgRow {
42    /// `objectiveai.messages."index"` — pass to `agents logs read id`
43    /// for the full typed payload.
44    id: i64,
45    response_id: String,
46    table_kind: MessageTable,
47    agent_instance_hierarchy: String,
48    timestamp_delivered: i64,
49    /// Sender AIH. Populated for request blob rows (from
50    /// `logs.<tier>_completion_requests.sender_*`) and for
51    /// `message_queue_*` rows (from `message_queue.sender_*`).
52    /// NULL for assistant/tool response rows — those have no
53    /// distinct sender, the agent IS the producer.
54    sender_agent_instance_hierarchy: Option<String>,
55    /// `message_queue.id` of the consumed parent queue row.
56    /// Some only for `message_queue_*` rows. Part of the
57    /// `ClientNotification` block boundary tuple so each block
58    /// = exactly one parent queue row.
59    message_queue_id: Option<i64>,
60    /// `message_queue.enqueued_at` of the consumed parent queue
61    /// row. Some only for `message_queue_*` rows; lives at
62    /// block level on the emitted `ClientNotification`.
63    timestamp_queued: Option<i64>,
64    /// `message_queue.key` of the consumed parent queue row —
65    /// the idempotency token passed to
66    /// `agents message --enqueue-with-key`. Some only for
67    /// `message_queue_*` rows, and only when the parent row had
68    /// a key set; lives at block level on the emitted
69    /// `ClientNotification`.
70    message_queue_key: Option<String>,
71    /// `objectiveai.assistant_response_tool_calls.function_name` —
72    /// `Some` only for tool-call rows; NULL → `None` for every other
73    /// table. Surfaced on [`AssistantResponsePart::ToolCall`] so
74    /// callers can dedupe tool calls by name without a round-trip
75    /// through `agents logs read id`.
76    function_name: Option<String>,
77    /// `tool_call_id` for the row — `COALESCE` of the
78    /// `assistant_response_tool_calls` join (assistant tool-call
79    /// rows) and the `tool_response` join (tool-response content
80    /// rows); these are mutually exclusive per row. `None` for every
81    /// other table. Used both to inline it on
82    /// `AssistantResponsePart::ToolCall` and to split `ToolResponse`
83    /// blocks per tool call.
84    tool_call_id: Option<String>,
85    /// `objectiveai.messages.row_sub_index` — the tool call's wire
86    /// index for `assistant_response_tool_calls` rows (and the
87    /// part_index for content rows). Surfaced as `tool_call_index` on
88    /// `AssistantResponsePart::ToolCall`.
89    row_sub_index: Option<i64>,
90}
91
92/// Coarse block-class for a `objectiveai.message_table` value. Block
93/// boundaries are drawn whenever this changes between consecutive
94/// rows (or AIH / response_id / sender for ClientNotification).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum BlockClass {
97    AgentCompletionRequest,
98    VectorCompletionRequest,
99    FunctionExecutionRequest,
100    ClientNotification,
101    AssistantResponse,
102    ToolResponse,
103}
104
105fn block_class(t: MessageTable) -> BlockClass {
106    match t {
107        MessageTable::AgentCompletionRequest => BlockClass::AgentCompletionRequest,
108        MessageTable::VectorCompletionRequest => BlockClass::VectorCompletionRequest,
109        MessageTable::FunctionExecutionRequest => BlockClass::FunctionExecutionRequest,
110        MessageTable::MessageQueueText
111        | MessageTable::MessageQueueImage
112        | MessageTable::MessageQueueAudio
113        | MessageTable::MessageQueueVideo
114        | MessageTable::MessageQueueFile => BlockClass::ClientNotification,
115        MessageTable::ToolResponseContentText
116        | MessageTable::ToolResponseContentImage
117        | MessageTable::ToolResponseContentAudio
118        | MessageTable::ToolResponseContentVideo
119        | MessageTable::ToolResponseContentFile => BlockClass::ToolResponse,
120        MessageTable::AssistantResponseRefusal
121        | MessageTable::AssistantResponseReasoning
122        | MessageTable::AssistantResponseToolCalls
123        | MessageTable::AssistantResponseContentText
124        | MessageTable::AssistantResponseContentImage
125        | MessageTable::AssistantResponseContentAudio
126        | MessageTable::AssistantResponseContentVideo
127        | MessageTable::AssistantResponseContentFile => BlockClass::AssistantResponse,
128    }
129}
130
131fn client_notification_kind(t: MessageTable) -> Option<ClientNotificationPartType> {
132    match t {
133        MessageTable::MessageQueueText => Some(ClientNotificationPartType::Text),
134        MessageTable::MessageQueueImage => Some(ClientNotificationPartType::Image),
135        MessageTable::MessageQueueAudio => Some(ClientNotificationPartType::Audio),
136        MessageTable::MessageQueueVideo => Some(ClientNotificationPartType::Video),
137        MessageTable::MessageQueueFile => Some(ClientNotificationPartType::File),
138        _ => None,
139    }
140}
141
142/// Build the [`AssistantResponsePart`] for one `assistant_response_*`
143/// row. The `ToolCall` variant inlines the call's metadata
144/// (`function_name` / `tool_call_id` / `tool_call_index`); every other
145/// variant carries just `id` + `timestamp_delivered`.
146fn assistant_response_part(row: &MsgRow) -> Option<AssistantResponsePart> {
147    let id = row.id;
148    let timestamp_delivered = row.timestamp_delivered;
149    Some(match row.table_kind {
150        MessageTable::AssistantResponseToolCalls => AssistantResponsePart::ToolCall {
151            id,
152            timestamp_delivered,
153            function_name: row.function_name.clone().unwrap_or_default(),
154            tool_call_id: row.tool_call_id.clone().unwrap_or_default(),
155            tool_call_index: row.row_sub_index.unwrap_or_default(),
156        },
157        MessageTable::AssistantResponseRefusal => {
158            AssistantResponsePart::Refusal { id, timestamp_delivered }
159        }
160        MessageTable::AssistantResponseReasoning => {
161            AssistantResponsePart::Reasoning { id, timestamp_delivered }
162        }
163        MessageTable::AssistantResponseContentText => {
164            AssistantResponsePart::Text { id, timestamp_delivered }
165        }
166        MessageTable::AssistantResponseContentImage => {
167            AssistantResponsePart::Image { id, timestamp_delivered }
168        }
169        MessageTable::AssistantResponseContentAudio => {
170            AssistantResponsePart::Audio { id, timestamp_delivered }
171        }
172        MessageTable::AssistantResponseContentVideo => {
173            AssistantResponsePart::Video { id, timestamp_delivered }
174        }
175        MessageTable::AssistantResponseContentFile => {
176            AssistantResponsePart::File { id, timestamp_delivered }
177        }
178        _ => return None,
179    })
180}
181
182fn tool_response_kind(t: MessageTable) -> Option<ToolResponsePartType> {
183    match t {
184        MessageTable::ToolResponseContentText => Some(ToolResponsePartType::Text),
185        MessageTable::ToolResponseContentImage => Some(ToolResponsePartType::Image),
186        MessageTable::ToolResponseContentAudio => Some(ToolResponsePartType::Audio),
187        MessageTable::ToolResponseContentVideo => Some(ToolResponsePartType::Video),
188        MessageTable::ToolResponseContentFile => Some(ToolResponsePartType::File),
189        _ => None,
190    }
191}
192
193/// Shared SELECT clause for `read_all` / `read_pending`. JOINs
194/// the four sender source tables LEFT-style; CASE-picks the
195/// right sender column based on `m."table"`. `timestamp_queued`
196/// comes from the queue JOIN (Some only for `message_queue_*`
197/// kinds).
198const SELECT_SHAPE: &str = "SELECT \
199    m.\"index\" AS id, \
200    m.response_id, \
201    m.\"table\" AS table_kind, \
202    m.agent_instance_hierarchy, \
203    m.\"timestamp\" AS timestamp_delivered, \
204    CASE m.\"table\" \
205        WHEN 'message_queue_text'  THEN mq.sender_agent_instance_hierarchy \
206        WHEN 'message_queue_image' THEN mq.sender_agent_instance_hierarchy \
207        WHEN 'message_queue_audio' THEN mq.sender_agent_instance_hierarchy \
208        WHEN 'message_queue_video' THEN mq.sender_agent_instance_hierarchy \
209        WHEN 'message_queue_file'  THEN mq.sender_agent_instance_hierarchy \
210        WHEN 'agent_completion_request'    THEN acr.sender_agent_instance_hierarchy \
211        WHEN 'vector_completion_request'   THEN vcr.sender_agent_instance_hierarchy \
212        WHEN 'function_execution_request'  THEN fer.sender_agent_instance_hierarchy \
213        ELSE NULL \
214    END AS sender_agent_instance_hierarchy, \
215    mq.id AS message_queue_id, \
216    mq.enqueued_at AS timestamp_queued, \
217    mq.key AS message_queue_key, \
218    atc.function_name AS function_name, \
219    COALESCE(atc.tool_call_id, tr.tool_call_id) AS tool_call_id, \
220    m.row_sub_index AS row_sub_index";
221
222const FROM_JOINS: &str = "FROM objectiveai.messages m \
223    LEFT JOIN objectiveai.message_queue_contents mqc \
224        ON m.row_index = mqc.id \
225        AND m.\"table\" IN ( \
226            'message_queue_text', 'message_queue_image', 'message_queue_audio', \
227            'message_queue_video', 'message_queue_file' \
228        ) \
229    LEFT JOIN objectiveai.message_queue mq ON mqc.message_queue_id = mq.id \
230    LEFT JOIN objectiveai.agent_completion_requests acr \
231        ON m.response_id = acr.response_id \
232        AND m.\"table\" = 'agent_completion_request' \
233    LEFT JOIN objectiveai.vector_completion_requests vcr \
234        ON m.response_id = vcr.response_id \
235        AND m.\"table\" = 'vector_completion_request' \
236    LEFT JOIN objectiveai.function_execution_requests fer \
237        ON m.response_id = fer.response_id \
238        AND m.\"table\" = 'function_execution_request' \
239    LEFT JOIN objectiveai.assistant_response_tool_calls atc \
240        ON m.response_id = atc.response_id \
241        AND m.row_index = atc.\"index\" \
242        AND m.row_sub_index = atc.tool_call_index \
243        AND m.\"table\" = 'assistant_response_tool_calls' \
244    LEFT JOIN objectiveai.tool_response tr \
245        ON m.response_id = tr.response_id \
246        AND m.row_index = tr.\"index\" \
247        AND m.\"table\" IN ( \
248            'tool_response_content_text', 'tool_response_content_image', \
249            'tool_response_content_audio', 'tool_response_content_video', \
250            'tool_response_content_file' \
251        )";
252
253fn row_into_msg(r: &sqlx::postgres::PgRow) -> Result<MsgRow, Error> {
254    Ok(MsgRow {
255        id: r.try_get("id")?,
256        response_id: r.try_get("response_id")?,
257        table_kind: r.try_get("table_kind")?,
258        agent_instance_hierarchy: r.try_get("agent_instance_hierarchy")?,
259        timestamp_delivered: r.try_get("timestamp_delivered")?,
260        sender_agent_instance_hierarchy: r.try_get("sender_agent_instance_hierarchy")?,
261        message_queue_id: r.try_get("message_queue_id")?,
262        timestamp_queued: r.try_get("timestamp_queued")?,
263        message_queue_key: r.try_get("message_queue_key")?,
264        function_name: r.try_get("function_name")?,
265        tool_call_id: r.try_get("tool_call_id")?,
266        row_sub_index: r.try_get("row_sub_index")?,
267    })
268}
269
270/// Walk `rows` (already sorted by `id` ASC) and coalesce into
271/// `ResponseItem`s. Pure / deterministic.
272fn coalesce_into_blocks(rows: Vec<MsgRow>) -> Vec<ResponseItem> {
273    let mut out: Vec<ResponseItem> = Vec::new();
274    let mut cur_class: Option<BlockClass> = None;
275    let mut cur_aih: String = String::new();
276    let mut cur_rid: String = String::new();
277    /// `Some` only for an open `ClientNotification` block; assistant /
278    /// tool blocks never set this. Boundary check pulls it in.
279    let mut cur_sender: Option<String> = None;
280    /// `Some` only for an open `ClientNotification` block — the
281    /// consumed `message_queue.id`. Forces 1:1 block-to-parent
282    /// correspondence so block-level `timestamp_queued` is
283    /// well-defined.
284    let mut cur_mq_id: Option<i64> = None;
285    /// `Some` only for an open `ClientNotification` block —
286    /// `message_queue.enqueued_at`.
287    let mut cur_timestamp_queued: Option<i64> = None;
288    /// `Some` only for an open `ClientNotification` block AND
289    /// only when the parent queue row had `--key` set —
290    /// `message_queue.key`.
291    let mut cur_key: Option<String> = None;
292    // `Some` only for an open `ToolResponse` block — the `tool_call_id`
293    // this block answers. Part of the ToolResponse boundary tuple so
294    // each block = exactly one tool call's response.
295    let mut cur_tool_call_id: Option<String> = None;
296    let mut cur_notification_parts: Vec<ClientNotificationPart> = Vec::new();
297    let mut cur_assistant_parts: Vec<AssistantResponsePart> = Vec::new();
298    let mut cur_tool_parts: Vec<ToolResponsePart> = Vec::new();
299
300    let flush = |class: Option<BlockClass>,
301                 aih: &mut String,
302                 rid: &mut String,
303                 sender: &mut Option<String>,
304                 mq_id: &mut Option<i64>,
305                 timestamp_queued: &mut Option<i64>,
306                 key: &mut Option<String>,
307                 tool_call_id: &mut Option<String>,
308                 notification_parts: &mut Vec<ClientNotificationPart>,
309                 assistant_parts: &mut Vec<AssistantResponsePart>,
310                 tool_parts: &mut Vec<ToolResponsePart>,
311                 out: &mut Vec<ResponseItem>| {
312        match class {
313            Some(BlockClass::ClientNotification) if !notification_parts.is_empty() => {
314                out.push(ResponseItem::ClientNotification {
315                    agent_instance_hierarchy: std::mem::take(aih),
316                    sender_agent_instance_hierarchy: sender.take().unwrap_or_default(),
317                    response_id: std::mem::take(rid),
318                    timestamp_queued: timestamp_queued.take().unwrap_or_default(),
319                    key: key.take(),
320                    parts: std::mem::take(notification_parts),
321                });
322                *mq_id = None;
323            }
324            Some(BlockClass::AssistantResponse) if !assistant_parts.is_empty() => {
325                out.push(ResponseItem::AssistantResponse {
326                    agent_instance_hierarchy: std::mem::take(aih),
327                    response_id: std::mem::take(rid),
328                    parts: std::mem::take(assistant_parts),
329                });
330            }
331            Some(BlockClass::ToolResponse) if !tool_parts.is_empty() => {
332                out.push(ResponseItem::ToolResponse {
333                    agent_instance_hierarchy: std::mem::take(aih),
334                    response_id: std::mem::take(rid),
335                    tool_call_id: tool_call_id.take().unwrap_or_default(),
336                    parts: std::mem::take(tool_parts),
337                });
338            }
339            _ => {
340                aih.clear();
341                rid.clear();
342                *sender = None;
343                *mq_id = None;
344                *timestamp_queued = None;
345                *key = None;
346                *tool_call_id = None;
347                notification_parts.clear();
348                assistant_parts.clear();
349                tool_parts.clear();
350            }
351        }
352    };
353
354    for row in rows {
355        let class = block_class(row.table_kind);
356
357        // Single-row request classes — emit immediately, reset.
358        match class {
359            BlockClass::AgentCompletionRequest => {
360                flush(
361                    cur_class, &mut cur_aih, &mut cur_rid, &mut cur_sender,
362                    &mut cur_mq_id, &mut cur_timestamp_queued, &mut cur_key,
363                    &mut cur_tool_call_id,
364                    &mut cur_notification_parts, &mut cur_assistant_parts,
365                    &mut cur_tool_parts, &mut out,
366                );
367                out.push(ResponseItem::AgentCompletionRequest {
368                    id: row.id,
369                    agent_instance_hierarchy: row.agent_instance_hierarchy,
370                    sender_agent_instance_hierarchy: row
371                        .sender_agent_instance_hierarchy
372                        .unwrap_or_default(),
373                    timestamp_delivered: row.timestamp_delivered,
374                    response_id: row.response_id,
375                });
376                cur_class = None;
377                continue;
378            }
379            BlockClass::VectorCompletionRequest => {
380                flush(
381                    cur_class, &mut cur_aih, &mut cur_rid, &mut cur_sender,
382                    &mut cur_mq_id, &mut cur_timestamp_queued, &mut cur_key,
383                    &mut cur_tool_call_id,
384                    &mut cur_notification_parts, &mut cur_assistant_parts,
385                    &mut cur_tool_parts, &mut out,
386                );
387                out.push(ResponseItem::VectorCompletionRequest {
388                    id: row.id,
389                    agent_instance_hierarchy: row.agent_instance_hierarchy,
390                    sender_agent_instance_hierarchy: row
391                        .sender_agent_instance_hierarchy
392                        .unwrap_or_default(),
393                    timestamp_delivered: row.timestamp_delivered,
394                    response_id: row.response_id,
395                });
396                cur_class = None;
397                continue;
398            }
399            BlockClass::FunctionExecutionRequest => {
400                flush(
401                    cur_class, &mut cur_aih, &mut cur_rid, &mut cur_sender,
402                    &mut cur_mq_id, &mut cur_timestamp_queued, &mut cur_key,
403                    &mut cur_tool_call_id,
404                    &mut cur_notification_parts, &mut cur_assistant_parts,
405                    &mut cur_tool_parts, &mut out,
406                );
407                out.push(ResponseItem::FunctionExecutionRequest {
408                    id: row.id,
409                    agent_instance_hierarchy: row.agent_instance_hierarchy,
410                    sender_agent_instance_hierarchy: row
411                        .sender_agent_instance_hierarchy
412                        .unwrap_or_default(),
413                    timestamp_delivered: row.timestamp_delivered,
414                    response_id: row.response_id,
415                });
416                cur_class = None;
417                continue;
418            }
419            _ => {}
420        }
421
422        // Multi-row class. For ClientNotification, sender_aih
423        // AND message_queue_id are part of the boundary tuple —
424        // each block = one consumed parent queue row, well-defined
425        // block-level `timestamp_queued`. Assistant/Tool blocks
426        // ignore sender + mq_id (both are None for them anyway).
427        let boundary = cur_class != Some(class)
428            || cur_aih != row.agent_instance_hierarchy
429            || cur_rid != row.response_id
430            || (class == BlockClass::ClientNotification
431                && (cur_sender.as_deref() != row.sender_agent_instance_hierarchy.as_deref()
432                    || cur_mq_id != row.message_queue_id))
433            || (class == BlockClass::ToolResponse
434                && cur_tool_call_id.as_deref() != row.tool_call_id.as_deref());
435        if boundary {
436            flush(
437                cur_class, &mut cur_aih, &mut cur_rid, &mut cur_sender,
438                &mut cur_mq_id, &mut cur_timestamp_queued, &mut cur_key,
439                &mut cur_tool_call_id,
440                &mut cur_notification_parts, &mut cur_assistant_parts,
441                &mut cur_tool_parts, &mut out,
442            );
443            cur_class = Some(class);
444            cur_aih = row.agent_instance_hierarchy.clone();
445            cur_rid = row.response_id.clone();
446            if class == BlockClass::ClientNotification {
447                cur_sender = row.sender_agent_instance_hierarchy.clone();
448                cur_mq_id = row.message_queue_id;
449                cur_timestamp_queued = row.timestamp_queued;
450                cur_key = row.message_queue_key.clone();
451                cur_tool_call_id = None;
452            } else if class == BlockClass::ToolResponse {
453                cur_sender = None;
454                cur_mq_id = None;
455                cur_timestamp_queued = None;
456                cur_key = None;
457                cur_tool_call_id = row.tool_call_id.clone();
458            } else {
459                cur_sender = None;
460                cur_mq_id = None;
461                cur_timestamp_queued = None;
462                cur_key = None;
463                cur_tool_call_id = None;
464            }
465        }
466
467        match class {
468            BlockClass::ClientNotification => {
469                let r#type = client_notification_kind(row.table_kind)
470                    .expect("class invariant: ClientNotification maps to message_queue_*");
471                cur_notification_parts.push(ClientNotificationPart {
472                    id: row.id,
473                    timestamp_delivered: row.timestamp_delivered,
474                    r#type,
475                });
476            }
477            BlockClass::AssistantResponse => {
478                let part = assistant_response_part(&row)
479                    .expect("class invariant: AssistantResponse maps to assistant_response_*");
480                cur_assistant_parts.push(part);
481            }
482            BlockClass::ToolResponse => {
483                let r#type = tool_response_kind(row.table_kind)
484                    .expect("class invariant: ToolResponse maps to tool_response*");
485                cur_tool_parts.push(ToolResponsePart {
486                    id: row.id,
487                    timestamp_delivered: row.timestamp_delivered,
488                    r#type,
489                });
490            }
491            _ => unreachable!("request classes handled above"),
492        }
493    }
494
495    flush(
496        cur_class, &mut cur_aih, &mut cur_rid, &mut cur_sender,
497        &mut cur_mq_id, &mut cur_timestamp_queued, &mut cur_key,
498        &mut cur_tool_call_id,
499        &mut cur_notification_parts, &mut cur_assistant_parts,
500        &mut cur_tool_parts, &mut out,
501    );
502
503    out
504}
505
506/// Materialize every `objectiveai.messages` row for `agent_instance_hierarchy`
507/// (filtered by `after_id` / `limit`), coalesced into `ResponseItem`
508/// blocks.
509pub async fn read_all_for_hierarchy(
510    pool: &Pool,
511    agent_instance_hierarchy: &str,
512    after_id: Option<i64>,
513    limit: Option<i64>,
514) -> Result<Vec<ResponseItem>, Error> {
515    let sql = format!(
516        "{select} {from} \
517         WHERE m.agent_instance_hierarchy = $1 \
518           AND m.\"index\" > COALESCE($2, 0) \
519         ORDER BY m.\"index\" ASC \
520         LIMIT $3",
521        select = SELECT_SHAPE,
522        from = FROM_JOINS,
523    );
524    let rows = sqlx::query(&sql)
525        .bind(agent_instance_hierarchy)
526        .bind(after_id)
527        .bind(limit)
528        .fetch_all(&**pool)
529        .await?;
530
531    let msg_rows: Vec<MsgRow> = rows.iter().map(row_into_msg).collect::<Result<_, _>>()?;
532    Ok(coalesce_into_blocks(msg_rows))
533}
534
535/// Materialize every unread `objectiveai.messages` row for the children
536/// spawned by `parent_agent_instance_hierarchy` (per
537/// `objectiveai.messages_queue` watermarks), coalesced into `ResponseItem`
538/// blocks. Bumps each affected child's `read_index` to
539/// `GREATEST(current, max_returned)` atomically in the same SQL
540/// statement.
541pub async fn read_pending_for_parent(
542    pool: &Pool,
543    parent_agent_instance_hierarchy: &str,
544    after_id: Option<i64>,
545    limit: Option<i64>,
546) -> Result<Vec<ResponseItem>, Error> {
547    // CTE-chained read-and-bump:
548    //   * `selected` — the rows to return; same JOIN topology as
549    //     `read_all_for_hierarchy` plus a JOIN to
550    //     `objectiveai.messages_queue` for the watermark filter.
551    //   * `maxes` — per-spawned max returned `id`.
552    //   * `bump` — UPDATE that lifts each child's `read_index` to
553    //     `GREATEST(current, max_id)`. Always runs (Postgres
554    //     materializes data-modifying CTEs even when the outer
555    //     SELECT doesn't reference them); when `selected` is
556    //     empty, `maxes` is empty and `bump` no-ops.
557    //   * Final SELECT pulls from `selected`.
558    let sql = format!(
559        "WITH selected AS ( \
560             {select} \
561             {from} \
562             JOIN objectiveai.messages_queue q \
563               ON q.spawned_agent_instance_hierarchy = m.agent_instance_hierarchy \
564             WHERE q.parent_agent_instance_hierarchy = $1 \
565               AND m.\"index\" > GREATEST(q.read_index, COALESCE($2, 0)) \
566             ORDER BY m.\"index\" ASC \
567             LIMIT $3 \
568         ), \
569         maxes AS ( \
570             SELECT agent_instance_hierarchy AS spawned, MAX(id) AS max_id \
571               FROM selected \
572              GROUP BY agent_instance_hierarchy \
573         ), \
574         bump AS ( \
575             UPDATE objectiveai.messages_queue qq \
576                SET read_index = GREATEST(qq.read_index, mx.max_id) \
577               FROM maxes mx \
578              WHERE qq.parent_agent_instance_hierarchy = $1 \
579                AND qq.spawned_agent_instance_hierarchy = mx.spawned \
580             RETURNING 1 \
581         ) \
582         SELECT s.id, s.response_id, s.table_kind, \
583                s.agent_instance_hierarchy, s.timestamp_delivered, \
584                s.sender_agent_instance_hierarchy, \
585                s.message_queue_id, s.timestamp_queued, \
586                s.message_queue_key, s.function_name, \
587                s.tool_call_id, s.row_sub_index \
588           FROM selected s \
589          ORDER BY s.id ASC",
590        select = SELECT_SHAPE,
591        from = FROM_JOINS,
592    );
593    let rows = sqlx::query(&sql)
594        .bind(parent_agent_instance_hierarchy)
595        .bind(after_id)
596        .bind(limit)
597        .fetch_all(&**pool)
598        .await?;
599
600    let msg_rows: Vec<MsgRow> = rows.iter().map(row_into_msg).collect::<Result<_, _>>()?;
601    Ok(coalesce_into_blocks(msg_rows))
602}
603
604/// Side-effect-free existence check used by
605/// `agents logs read subscribe`'s wait loop. Returns `true` iff
606/// `objectiveai.messages_queue` has at least one unread row past the
607/// watermark for any child of `parent_agent_instance_hierarchy`
608/// whose `m."table"` falls in `kinds`. When `kinds` is `None` or
609/// empty, the kind filter is dropped (existence check across all
610/// kinds — equivalent to "is there anything pending at all?").
611///
612/// Does NOT touch `read_index`. The subscriber re-checks via
613/// this on every wake-up and only calls
614/// `read_pending_for_parent` (which DOES bump) once it confirms
615/// a matching row exists. When a match is confirmed, the
616/// subsequent `read_pending_for_parent` call returns EVERY
617/// pending row regardless of kind — the kinds filter is for
618/// "wake me up" gating only, not for the returned slice.
619pub async fn any_pending_matching_kinds(
620    pool: &Pool,
621    parent_agent_instance_hierarchy: &str,
622    after_id: Option<i64>,
623    kinds: Option<&[MessageTable]>,
624) -> Result<bool, Error> {
625    let kinds_clause = match kinds {
626        Some(ks) if !ks.is_empty() => {
627            let list = ks
628                .iter()
629                .map(|k| format!("'{}'", k.schema_name()))
630                .collect::<Vec<_>>()
631                .join(", ");
632            format!("AND m.\"table\" IN ({list})")
633        }
634        _ => String::new(),
635    };
636    let sql = format!(
637        "SELECT EXISTS( \
638             SELECT 1 FROM objectiveai.messages m \
639             JOIN objectiveai.messages_queue q \
640               ON q.spawned_agent_instance_hierarchy = m.agent_instance_hierarchy \
641             WHERE q.parent_agent_instance_hierarchy = $1 \
642               AND m.\"index\" > GREATEST(q.read_index, COALESCE($2, 0)) \
643               {kinds_clause} \
644         )"
645    );
646    let exists: bool = sqlx::query_scalar(&sql)
647        .bind(parent_agent_instance_hierarchy)
648        .bind(after_id)
649        .fetch_one(&**pool)
650        .await?;
651    Ok(exists)
652}