objectiveai_cli/db/logs/writer.rs
1//! `LogWriter<C>` — postgres log writer fronted by an mpsc sender.
2//!
3//! Architecturally:
4//!
5//! - The constructor (`write_agent_completion` etc.) spawns a tokio
6//! task that owns the [`LogWriterState`] and the
7//! `mpsc::UnboundedReceiver<C>`. It returns the [`LogWriter`]
8//! handle plus a [`tokio::sync::oneshot::Receiver<String>`] that
9//! fires the first time the writer learns the stream's primary
10//! `response_id`.
11//! - [`LogWriter::write`] is **synchronous** — it just hands the
12//! chunk to the listener task via `UnboundedSender::send`. The
13//! caller's chunk-yield hot path stays off the DB write critical
14//! path.
15//! - The listener task `.push()`-folds every chunk into one
16//! stream-wide accumulator and runs the persistence logic
17//! ([`LogWriterState::apply_chunk`]) against that cumulative
18//! aggregate — not a per-batch slice. Draining the queue per loop
19//! iteration only collapses how often the pass runs. Folding is
20//! correct because each tier's chunk is a cumulative roll-up of
21//! state — `push` folds the later chunk's deltas into the earlier
22//! one's accumulators (`AgentCompletionChunk::push` /
23//! `VectorCompletionChunk::push` / `FunctionExecutionChunk::push`) —
24//! so a row whose fields stream across several wire chunks (a tool
25//! call's id/name/arguments, streamed content text) is always
26//! persisted from its complete body, never a partial fragment.
27//! - [`LogWriter::finalize`] consumes the writer by value, drops the
28//! sender, and `.await`s the JoinHandle. By the time it returns,
29//! both invariants hold: the channel is empty (sender dropped →
30//! `recv()` returned `None` only after every queued chunk was
31//! consumed) and the task's future has fully completed (no
32//! in-flight row-bucket joins or blob writes).
33//!
34//! Persistence pass (run per drain batch against the cumulative
35//! accumulator):
36//!
37//! 1. **First pass**: capture the accumulator's `response_id`, INSERT
38//! the request blob (no `agent_instance_hierarchy` on the blob —
39//! that linkage lives in `objectiveai.messages`).
40//! 2. **Every pass**: walk `chunk_rows(acc)` over the cumulative
41//! aggregate, gate each yielded [`RowValue`] through the shadow
42//! (Skip path is pure-memory — unchanged rows cost nothing), bucket
43//! the survivors by `agent_instance_hierarchy`. For every agent the
44//! writer hasn't seen yet in this stream's lifetime, prepend a
45//! `objectiveai.messages` row that registers the request blob in
46//! that agent's history.
47//! 3. **Per-bucket execution**: rows within one agent's bucket fire
48//! sequentially (so the per-agent ORDER BY `"index"` matches the
49//! iterator's order). All buckets fire concurrently via
50//! `try_join_all`.
51//!
52//! The response blob is written separately, exactly once, by
53//! `listener_loop` after the last chunk — from the same cumulative
54//! accumulator, so blob and rows can never disagree.
55
56use std::collections::{HashMap, HashSet};
57use std::future::Future;
58use std::marker::PhantomData;
59use std::sync::Arc;
60use std::pin::Pin;
61
62use objectiveai_sdk::agent::completions::request::AgentCompletionCreateParams;
63use objectiveai_sdk::agent::completions::response::streaming::{
64 AgentCompletionChunk, AgentCompletionIds,
65};
66use objectiveai_sdk::functions::executions::request::FunctionExecutionCreateParams;
67use objectiveai_sdk::functions::executions::response::streaming::FunctionExecutionChunk;
68use objectiveai_sdk::vector::completions::request::VectorCompletionCreateParams;
69use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
70use serde::Serialize;
71use tokio::sync::{Mutex, mpsc, oneshot, watch};
72use tokio::task::JoinHandle;
73
74use crate::db::Pool;
75
76use super::row::{RowValue, WriterItem, WriterItems};
77use super::rows::{
78 agent_completion_chunk_rows, function_execution_chunk_rows,
79 vector_completion_chunk_rows,
80};
81use super::shadow::{Shadow, WriteOp};
82use super::write::{
83 Tier, insert_request_blob, insert_request_messages_row, insert_response_blob,
84 update_agent_token_usage, write_value,
85};
86
87pub trait WriterChunk {
88 fn primary_id(&self) -> &str;
89 /// The spawned agent's AIH for the agent tier — where the writer
90 /// derives request_message rows + the agent_ref from the REQUEST
91 /// (the response chunk no longer carries them). `None` for the
92 /// vector/function tiers, which surface these through the chunk
93 /// stream instead.
94 fn agent_tier_aih(&self) -> Option<&str> {
95 None
96 }
97}
98
99impl WriterChunk for AgentCompletionChunk {
100 fn primary_id(&self) -> &str {
101 self.id.as_str()
102 }
103 fn agent_tier_aih(&self) -> Option<&str> {
104 Some(self.agent_instance_hierarchy.as_str())
105 }
106}
107impl WriterChunk for VectorCompletionChunk {
108 fn primary_id(&self) -> &str {
109 self.id.as_str()
110 }
111}
112impl WriterChunk for FunctionExecutionChunk {
113 fn primary_id(&self) -> &str {
114 self.id.as_str()
115 }
116}
117
118/// Chunks that can surface `(AIH, definition source)` pairs for the
119/// `objectiveai.agent_refs` registry: every nested agent completion
120/// carrying `agent_inline` (each completion's FIRST chunk). Per the
121/// registry's rule, `agent_remote` present → the remote wins;
122/// otherwise the inline spec itself.
123pub trait ChunkAgentRefs {
124 fn collect_agent_refs(
125 &self,
126 out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
127 );
128}
129
130impl ChunkAgentRefs for AgentCompletionChunk {
131 fn collect_agent_refs(
132 &self,
133 _out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
134 ) {
135 // The inner agent chunk no longer carries `agent_inline` (it
136 // moved to the vector wrapper). Agent-tier refs are derived
137 // from the REQUEST by the writer, not from the response chunk;
138 // this impl is a no-op (used only when the chunk appears as a
139 // standalone agent completion or a function reasoning summary).
140 }
141}
142
143impl ChunkAgentRefs for VectorCompletionChunk {
144 fn collect_agent_refs(
145 &self,
146 out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
147 ) {
148 // `agent_inline` now lives on the per-agent vector wrapper, not
149 // the inner agent chunk. Pair it with the inner chunk's
150 // AIH / remote.
151 for completion in &self.completions {
152 if let Some(inline) = &completion.agent_inline {
153 let value = match &completion.inner.agent_remote {
154 Some(remote) => {
155 crate::db::agent_refs::AgentRefValue::remote(remote)
156 }
157 None => crate::db::agent_refs::AgentRefValue::inline(inline),
158 };
159 if let Some(value) = value {
160 out.push((
161 completion.inner.agent_instance_hierarchy.clone(),
162 value,
163 ));
164 }
165 }
166 }
167 }
168}
169
170impl ChunkAgentRefs for FunctionExecutionChunk {
171 fn collect_agent_refs(
172 &self,
173 out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
174 ) {
175 use objectiveai_sdk::functions::executions::response::streaming::TaskChunk;
176 for task in &self.tasks {
177 match task {
178 TaskChunk::FunctionExecution(wrapper) => {
179 wrapper.inner.collect_agent_refs(out);
180 }
181 TaskChunk::VectorCompletion(wrapper) => {
182 wrapper.inner.collect_agent_refs(out);
183 }
184 }
185 }
186 if let Some(reasoning) = &self.reasoning {
187 reasoning.inner.collect_agent_refs(out);
188 }
189 }
190}
191
192/// CLI-side wrapper exposing the SDK's intrinsic `push(&mut self,
193/// other: &Self)` method via a uniform trait. Each impl simply
194/// delegates to the chunk type's inherent method — the SDK already
195/// guarantees `push` is a correct accumulator for the tier's
196/// cumulative-state semantics.
197pub trait ChunkPush {
198 fn push(&mut self, other: &Self);
199}
200
201impl ChunkPush for AgentCompletionChunk {
202 fn push(&mut self, other: &Self) {
203 AgentCompletionChunk::push(self, other);
204 }
205}
206impl ChunkPush for VectorCompletionChunk {
207 fn push(&mut self, other: &Self) {
208 VectorCompletionChunk::push(self, other);
209 }
210}
211impl ChunkPush for FunctionExecutionChunk {
212 fn push(&mut self, other: &Self) {
213 FunctionExecutionChunk::push(self, other);
214 }
215}
216
217/// Background-task-fronted log writer.
218///
219/// Construction (via `write_agent_completion` etc.) spawns a tokio
220/// task that owns the per-stream [`LogWriterState`]. The handle here
221/// is a thin sender + JoinHandle pair.
222pub struct LogWriter<C> {
223 tx: mpsc::UnboundedSender<C>,
224 handle: JoinHandle<Result<(), crate::error::Error>>,
225 /// Toggled `false` → `true` by the listener task once it has
226 /// completed a single successful `apply_chunk`. Powers
227 /// [`LogWriter::written_once`] (sync peek) and
228 /// [`LogWriter::wait_written_once`] (async wait).
229 written_rx: watch::Receiver<bool>,
230 /// Mid-stream failure handoff slot shared with the listener —
231 /// see [`finalize_with_stream_error`](Self::finalize_with_stream_error).
232 stream_error: Arc<Mutex<Option<serde_json::Value>>>,
233 _chunk: PhantomData<fn() -> C>,
234}
235
236impl<C> LogWriter<C> {
237 /// Hand off one chunk to the listener task. Returns
238 /// `Err(Error::Instance(_))` only when the listener has already
239 /// exited — typically because an earlier DB write failed. The
240 /// caller should treat that error the same way it would treat an
241 /// upstream stream error: stop reading, surface upward.
242 pub fn write(&self, chunk: C) -> Result<(), crate::error::Error> {
243 self.tx
244 .send(chunk)
245 .map_err(|_| crate::error::Error::Instance(
246 "log writer task has exited (earlier write failed)".to_string(),
247 ))
248 }
249
250 /// Sync peek: has the listener completed at least one successful
251 /// `apply_chunk` batch? Flips `false → true` exactly once,
252 /// immediately after the first batch's write completes and
253 /// before the listener parks on the next `recv`.
254 pub fn written_once(&self) -> bool {
255 *self.written_rx.borrow()
256 }
257
258 /// Async wait that resolves once the listener has completed its
259 /// first successful `apply_chunk` batch. Returns immediately if
260 /// that already happened. Errors only if the listener task
261 /// exited before its first successful write (DB error on the
262 /// very first batch).
263 pub async fn wait_written_once(&self) -> Result<(), crate::error::Error> {
264 let mut rx = self.written_rx.clone();
265 rx.wait_for(|b| *b)
266 .await
267 .map(|_| ())
268 .map_err(|_| crate::error::Error::Instance(
269 "log writer task exited before completing its first write".to_string(),
270 ))
271 }
272
273 /// Consume the writer. Drops the sender (signaling EOF to the
274 /// listener) and awaits the task. Returns only once:
275 ///
276 /// - the channel is empty: `recv()` returns `None` only after the
277 /// listener has drained every queued chunk, AND
278 /// - no work is in flight: the task's future has fully completed,
279 /// so no row-bucket joins or blob writes remain pending.
280 ///
281 /// Surfaces the first DB error the task encountered, if any.
282 pub async fn finalize(self) -> Result<(), crate::error::Error> {
283 let LogWriter { tx, handle, .. } = self;
284 drop(tx);
285 match handle.await {
286 Ok(inner) => inner,
287 Err(e) => Err(crate::error::Error::Instance(
288 format!("log writer task: {e}"),
289 )),
290 }
291 }
292
293 /// [`finalize`](Self::finalize), carrying the caller's mid-stream
294 /// failure. When `error` is `Some`, the listener — AFTER draining
295 /// every queued chunk (so error rows land last, never out of
296 /// order) — logs it as an `error` row for every nested agent
297 /// completion that has NOT finished (no `usage` yet) and does not
298 /// carry its own in-band error. Completions that finished cleanly
299 /// get nothing: they started and ended with no errors.
300 pub async fn finalize_with_stream_error(
301 self,
302 error: Option<serde_json::Value>,
303 ) -> Result<(), crate::error::Error> {
304 if let Some(value) = error {
305 *self.stream_error.lock().await = Some(value);
306 }
307 self.finalize().await
308 }
309}
310
311/// All the per-stream state the listener task owns. Was previously
312/// inlined onto `LogWriter`; now lives entirely inside the spawned
313/// task so the handle stays send-and-clone-cheap.
314struct LogWriterState<C> {
315 pool: Pool,
316 tier: Tier,
317 request_body: serde_json::Value,
318 /// Agent tier only: the request's typed messages, unpacked into
319 /// request_message content rows once on the first chunk. `None`
320 /// for the vector/function tiers (their request_message rows ride
321 /// the chunk stream). Immutable — written once, not shadow-gated.
322 request_messages: Option<Vec<objectiveai_sdk::agent::completions::message::Message>>,
323 /// Agent tier only: the agent-ref derived from the REQUEST's
324 /// `agent` field, upserted once on the first chunk. Taken (moved)
325 /// then. `None` for the other tiers (their refs come from the
326 /// response via `ChunkAgentRefs`).
327 request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
328 /// AIH of the caller who issued the request that spawned this
329 /// writer (pulled from `ctx.config.agent_instance_hierarchy` at
330 /// `spawn_writer` time). Written into the request blob row at
331 /// `insert_request_blob` time. Constant for the writer's
332 /// lifetime — one request = one sender.
333 sender_agent_instance_hierarchy: String,
334 /// Walks a chunk into [`WriterItem`]s — content rows AND, at each
335 /// nested agent completion with usage, a per-AIH `total_tokens`
336 /// snapshot. One traversal covers both.
337 items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
338 /// Last `total_tokens` written per AIH — dedups redundant
339 /// overwrites across the writer's repeated passes over the
340 /// cumulative accumulator.
341 last_usage: HashMap<String, u64>,
342 primary_id: Option<String>,
343 /// Per-streaming-content-row shadow. Skip path is allocation-free.
344 shadow: Shadow,
345 /// Every `agent_instance_hierarchy` we've observed in this
346 /// stream's lifetime. The first time an agent appears in the row
347 /// iterator we insert a `objectiveai.messages` row registering the
348 /// request blob in that agent's history; subsequent ticks see
349 /// the agent already-marked and skip the registration.
350 seen_agents: HashSet<String>,
351 /// Live-conversation tee: every row the shadow admits
352 /// (`Insert`/`Update`) is also shipped, full-value, to the resident
353 /// daemon for `/agents/instances/{*aih}` fan-out. Best-effort and
354 /// non-blocking — see [`super::tee`]. `None` = no tee (e.g. hand
355 /// -built writers).
356 tee: Option<super::tee::ConversationTee>,
357 /// The tee's stateful row→typed-event mapper (head memory).
358 frame_mapper: super::tee::FrameMapper,
359 /// `(aih, response_id)` pairs whose in-band completion error has
360 /// already been persisted — the cumulative accumulator re-yields
361 /// the error item every tick once set. Also consulted by the
362 /// post-drain mid-stream failure sweep.
363 logged_errors: HashSet<(String, String)>,
364 /// Per-tier walker over the folded chunk's nested agent
365 /// completions — the mid-stream failure sweep's enumeration.
366 statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
367 /// The chunk's own TOP-LEVEL in-band error (a function execution
368 /// failing on the wire without a transport error). `None`-returning
369 /// for the agent tier — spawn's `note_error` owns that tier's
370 /// stream errors.
371 chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
372 /// Mid-stream failure handoff: the caller's stream error, set by
373 /// [`LogWriter::finalize_with_stream_error`] BEFORE the EOF signal,
374 /// consumed by the listener's post-drain sweep.
375 stream_error: Arc<Mutex<Option<serde_json::Value>>>,
376 _chunk: PhantomData<fn() -> C>,
377}
378
379impl<C> LogWriterState<C> {
380 fn new(
381 pool: Pool,
382 tier: Tier,
383 request_body: serde_json::Value,
384 sender_agent_instance_hierarchy: String,
385 items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
386 request_messages: Option<
387 Vec<objectiveai_sdk::agent::completions::message::Message>,
388 >,
389 request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
390 tee: Option<super::tee::ConversationTee>,
391 statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
392 chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
393 stream_error: Arc<Mutex<Option<serde_json::Value>>>,
394 ) -> Self {
395 Self {
396 pool,
397 tier,
398 request_body,
399 request_messages,
400 request_agent_ref,
401 sender_agent_instance_hierarchy,
402 items_fn,
403 last_usage: HashMap::new(),
404 primary_id: None,
405 shadow: Shadow::new(),
406 seen_agents: HashSet::new(),
407 tee,
408 frame_mapper: super::tee::FrameMapper::default(),
409 logged_errors: HashSet::new(),
410 statuses_fn,
411 chunk_error_fn,
412 stream_error,
413 _chunk: PhantomData,
414 }
415 }
416
417 /// Agent tier: write the request's messages as request_message
418 /// content rows, once, on the first chunk — before any response
419 /// row so they read first. Immutable, so a direct one-time INSERT
420 /// per row (no shadow). `response_id`/`aih` are the spawned agent
421 /// completion's own id + AIH.
422 async fn write_agent_request_messages(
423 &mut self,
424 response_id: &str,
425 aih: &str,
426 ) -> Result<(), crate::error::Error> {
427 let Some(messages) = &self.request_messages else {
428 return Ok(());
429 };
430 let ts = now_secs() as i64;
431 for row in super::rows::request_message_rows(response_id, aih, messages) {
432 // These rows bypass the shadow (immutable, written once),
433 // so they must tee here or live subscribers never see the
434 // spawned agent's opening messages.
435 if let Some(tee) = &self.tee {
436 if let Some(frame) = self.frame_mapper.map(&row, ts) {
437 tee.send(frame);
438 }
439 }
440 write_value(&self.pool, WriteOp::Insert, &row, ts).await?;
441 }
442 Ok(())
443 }
444
445 /// Persist the cumulative aggregate's streaming-content rows
446 /// (`listener_loop` hands in the stream-wide accumulator, not a
447 /// per-batch slice, so every row is seen with its complete body).
448 /// The response_id and request blob are established by
449 /// `listener_loop` before the first call; the response blob is
450 /// written by `listener_loop` after the last chunk. This method
451 /// only touches the per-row content tables (gated through the
452 /// shadow, so re-walking the unchanged majority is free) and the
453 /// per-agent `objectiveai.messages` bookkeeping.
454 async fn apply_chunk(&mut self, chunk: &C) -> Result<(), crate::error::Error>
455 where
456 C: Send + Sync,
457 {
458 let response_id = self
459 .primary_id
460 .clone()
461 .expect("primary_id set by listener_loop before apply_chunk");
462 let created_at_seed = now_secs() as i64;
463
464 // One traversal of the chunk. Content rows are gated via the
465 // shadow and bucketed by agent_instance_hierarchy (Vec inside
466 // the HashMap preserves iterator order so per-bucket sequential
467 // awaits match the walk's ordering). Usage items (per nested
468 // agent completion carrying a non-`None` usage) overwrite the
469 // per-AIH `total_tokens` snapshot inline — `last_usage` dedups
470 // so the repeated passes over the cumulative accumulator only
471 // write when the value changes.
472 let mut buckets: HashMap<&str, Vec<(WriteOp, RowValue<'_>)>> = HashMap::new();
473 for item in (self.items_fn)(chunk) {
474 match item {
475 WriterItem::Row(value) => {
476 let key = value.agent_instance_hierarchy();
477 match self.shadow.record(&value) {
478 WriteOp::Skip => {}
479 op => {
480 // Live-conversation tee: ship the admitted
481 // row (full current value) BEFORE its SQL
482 // runs — sequential walk order, never gated
483 // on DB latency. Best-effort, non-blocking.
484 if let Some(tee) = &self.tee {
485 if let Some(frame) =
486 self.frame_mapper.map(&value, created_at_seed)
487 {
488 tee.send(frame);
489 }
490 }
491 buckets.entry(key).or_default().push((op, value));
492 }
493 }
494 }
495 WriterItem::Usage { agent_instance_hierarchy, total_tokens } => {
496 if self.last_usage.get(agent_instance_hierarchy) != Some(&total_tokens) {
497 update_agent_token_usage(
498 &self.pool,
499 agent_instance_hierarchy,
500 total_tokens as i64,
501 )
502 .await?;
503 self.last_usage
504 .insert(agent_instance_hierarchy.to_string(), total_tokens);
505 }
506 }
507 WriterItem::Error { agent_instance_hierarchy, response_id: rid, error } => {
508 let logged_key =
509 (agent_instance_hierarchy.to_string(), rid.to_string());
510 if !self.logged_errors.contains(&logged_key) {
511 // Persist first, then tee — same order as the
512 // spawn-path `note_error`. Covers EVERY tier:
513 // nested agent completions inside vector /
514 // function executions flow through the same
515 // walker.
516 let value = serde_json::to_value(error)
517 .unwrap_or_else(|_| error.to_string().into());
518 super::errors::insert_error(
519 &self.pool,
520 agent_instance_hierarchy,
521 Some(rid),
522 &value,
523 created_at_seed,
524 )
525 .await?;
526 if let Some(tee) = &self.tee {
527 tee.send(super::tee::error_frame(
528 agent_instance_hierarchy.to_string(),
529 Some(rid.to_string()),
530 value,
531 created_at_seed,
532 ));
533 }
534 self.logged_errors.insert(logged_key);
535 }
536 }
537 }
538 }
539
540 // Build the per-agent bucket futures. Each future runs its
541 // rows sequentially (order matters within one agent's
542 // history); different agents run concurrently via
543 // `try_join_all`. The seen_agents mutation happens
544 // synchronously inside the map closure — by the time the
545 // futures actually run, every bucket already knows whether it
546 // owes a request-messages row.
547 let pool = &self.pool;
548 let tier = self.tier;
549 let resp_id = response_id.as_str();
550 let seen_agents = &mut self.seen_agents;
551 let bucket_futures: Vec<
552 Pin<Box<dyn Future<Output = Result<(), crate::error::Error>> + Send + '_>>,
553 > = buckets
554 .into_iter()
555 .map(|(hier, items)| {
556 let needs_request_row = !seen_agents.contains(hier);
557 if needs_request_row {
558 seen_agents.insert(hier.to_string());
559 }
560 Box::pin(async move {
561 if needs_request_row {
562 insert_request_messages_row(
563 pool,
564 tier,
565 resp_id,
566 hier,
567 created_at_seed,
568 )
569 .await?;
570 }
571 for (op, value) in &items {
572 write_value(pool, *op, value, created_at_seed).await?;
573 }
574 Ok::<(), crate::error::Error>(())
575 })
576 as Pin<
577 Box<
578 dyn Future<Output = Result<(), crate::error::Error>>
579 + Send
580 + '_,
581 >,
582 >
583 })
584 .collect();
585
586 futures::future::try_join_all(bucket_futures).await?;
587
588 Ok(())
589 }
590
591 /// Write the request blob exactly once, when `listener_loop` first
592 /// learns the response_id (before any content row references it).
593 /// The request blob carries no agent_instance_hierarchy — that
594 /// linkage lives in `objectiveai.messages` (written per-agent in
595 /// `apply_chunk`).
596 async fn write_request_blob(
597 &self,
598 response_id: &str,
599 ) -> Result<(), crate::error::Error> {
600 let created_at_seed = now_secs() as i64;
601 insert_request_blob(
602 &self.pool,
603 self.tier,
604 response_id,
605 &self.request_body,
606 &self.sender_agent_instance_hierarchy,
607 created_at_seed,
608 )
609 .await?;
610 Ok(())
611 }
612
613 /// Write the complete response blob exactly once, from the
614 /// cumulative aggregate of every chunk in the stream — built by
615 /// `listener_loop` and handed in after the last chunk (finalize).
616 /// A single INSERT: the blob is never a partial snapshot, so a
617 /// chunk's tool-calls can't be lost to a per-batch overwrite.
618 async fn write_response_blob(
619 &self,
620 chunk: &C,
621 ) -> Result<(), crate::error::Error>
622 where
623 C: Serialize,
624 {
625 let Some(response_id) = self.primary_id.as_deref() else {
626 return Ok(());
627 };
628 let created_at_seed = now_secs() as i64;
629 insert_response_blob(
630 &self.pool,
631 self.tier,
632 response_id,
633 chunk,
634 created_at_seed,
635 )
636 .await?;
637 Ok(())
638 }
639}
640
641/// Listener loop. One iteration:
642///
643/// 1. Block on `rx.recv()` for the first chunk of a batch.
644/// 2. Drain any other chunks queued behind it via `try_recv`,
645/// `.push()`-aggregating them into the first.
646/// 3. Apply the aggregated chunk to the state.
647/// 4. If this was the first successful batch, flip `written_tx` to
648/// `true` (powers `LogWriter::wait_written_once`).
649/// 5. If `primary_id` just became known, fire the ready oneshot.
650///
651/// On `recv() = None` (sender dropped via `finalize`), the loop
652/// exits cleanly. On any DB error from `apply_chunk`, the loop
653/// exits with `Err`; subsequent sender sends fail with `SendError`,
654/// which `LogWriter::write` maps to a stable `Error::Instance`.
655async fn listener_loop<C>(
656 mut rx: mpsc::UnboundedReceiver<C>,
657 mut state: LogWriterState<C>,
658 ready_tx: oneshot::Sender<String>,
659 written_tx: watch::Sender<bool>,
660) -> Result<(), crate::error::Error>
661where
662 C: WriterChunk
663 + AgentCompletionIds
664 + ChunkAgentRefs
665 + ChunkPush
666 + Clone
667 + Serialize
668 + Send
669 + Sync,
670{
671 let mut ready_tx = Some(ready_tx);
672 let mut written_fired = false;
673 // Cumulative aggregate of every chunk across the whole stream. Each
674 // iteration's `agg` is only a partial slice (the wire is per-message
675 // deltas); folding every batch in here builds the complete response
676 // that is written as the response blob exactly once, after the last
677 // chunk. Without this the blob would be overwritten with whatever
678 // partial batch arrived last, dropping earlier tool-calls.
679 let mut accumulated: Option<C> = None;
680 while let Some(first) = rx.recv().await {
681 // Fold `first` into the stream-wide aggregate, then drain any
682 // chunks queued behind it into the same aggregate. `accumulated`
683 // is the cumulative roll-up of every chunk seen so far — NOT a
684 // per-batch slice. Draining the queue only collapses how OFTEN
685 // the persistence pass runs; what it persists from is always
686 // the full accumulator.
687 // Definition sources ride the RAW chunks (agent_inline is
688 // first-chunk-only), so scan each incoming chunk before it
689 // dissolves into the aggregate.
690 let mut agent_refs: Vec<(String, crate::db::agent_refs::AgentRefValue)> =
691 Vec::new();
692 first.collect_agent_refs(&mut agent_refs);
693 if let Some(acc) = accumulated.as_mut() {
694 acc.push(&first);
695 } else {
696 accumulated = Some(first.clone());
697 }
698 while let Ok(next) = rx.try_recv() {
699 next.collect_agent_refs(&mut agent_refs);
700 if let Some(acc) = accumulated.as_mut() {
701 acc.push(&next);
702 }
703 }
704 let acc = accumulated
705 .as_ref()
706 .expect("accumulated is Some: set or pushed above");
707
708 // On the very first chunk: learn the response_id and write the
709 // request blob once, before any content row references it.
710 if state.primary_id.is_none() {
711 let response_id = acc.primary_id().to_string();
712 state.write_request_blob(&response_id).await?;
713 // Agent tier: unpack the REQUEST into request_message rows
714 // (written first) and register the agent_ref from the
715 // request's agent field — never the response chunk.
716 if let Some(aih) = acc.agent_tier_aih() {
717 state.write_agent_request_messages(&response_id, aih).await?;
718 if let Some(value) = state.request_agent_ref.take() {
719 crate::db::agent_refs::upsert(&state.pool, aih, value)
720 .await?;
721 }
722 }
723 state.primary_id = Some(response_id);
724 }
725
726 // Persist rows from the cumulative aggregate, never a per-batch
727 // slice. A tool call's id/name/arguments — and streamed content
728 // text — arrive as deltas spread across multiple wire chunks;
729 // under load those deltas land in different drain batches. Row
730 // generation (`rows.rs`) drops any tool call missing id/name/
731 // args, so a per-batch slice that lacked the id/name delta would
732 // omit the row entirely (and would overwrite content text with
733 // the latest fragment rather than the full run). Walking the
734 // full accumulator each pass emits every row from its COMPLETE
735 // body; the shadow makes the repeated walk cheap — unchanged
736 // rows Skip with zero writes, only genuinely-changed bodies hit
737 // the DB.
738 state.apply_chunk(acc).await?;
739
740 // Blind agent_refs upserts for every scanned definition
741 // source — last write wins by design.
742 for (hier, value) in agent_refs {
743 crate::db::agent_refs::upsert(&state.pool, &hier, value).await?;
744 }
745
746 // First successful apply: flip the watch true exactly once.
747 // Subsequent batches don't touch it (the value is already
748 // true; no point waking waiters again).
749 if !written_fired {
750 let _ = written_tx.send(true);
751 written_fired = true;
752 }
753 // Fire the oneshot the first time primary_id becomes known
754 // (set above on the first chunk).
755 if let Some(tx) = ready_tx.take() {
756 match state.primary_id.as_deref() {
757 Some(id) => {
758 let _ = tx.send(id.to_string());
759 }
760 None => {
761 ready_tx = Some(tx);
762 }
763 }
764 }
765 }
766 // EOF (sender dropped via finalize): every queued chunk is drained.
767 if let Some(acc) = accumulated {
768 // Mid-stream failure sweep: the caller's stream error (set via
769 // `finalize_with_stream_error` before EOF) — or the execution's
770 // own TOP-LEVEL in-band error — is logged for every nested
771 // agent completion that neither finished (no usage yet) nor
772 // carries its own in-band error (already persisted). Runs
773 // strictly post-drain, so error rows land AFTER every
774 // conversation row.
775 let sweep_error = state.stream_error.lock().await.take().or_else(|| {
776 (state.chunk_error_fn)(&acc).map(|e| {
777 serde_json::to_value(e).unwrap_or_else(|_| e.to_string().into())
778 })
779 });
780 if let Some(value) = sweep_error {
781 let ts = now_secs() as i64;
782 for status in (state.statuses_fn)(&acc) {
783 if status.finished || status.errored {
784 continue;
785 }
786 let key = (
787 status.agent_instance_hierarchy.to_string(),
788 status.response_id.to_string(),
789 );
790 if state.logged_errors.contains(&key) {
791 continue;
792 }
793 // Persist first, then tee — same order as everywhere.
794 super::errors::insert_error(
795 &state.pool,
796 status.agent_instance_hierarchy,
797 Some(status.response_id),
798 &value,
799 ts,
800 )
801 .await?;
802 if let Some(tee) = &state.tee {
803 tee.send(super::tee::error_frame(
804 key.0.clone(),
805 Some(key.1.clone()),
806 value.clone(),
807 ts,
808 ));
809 }
810 state.logged_errors.insert(key);
811 }
812 }
813 // Write the complete response blob exactly once from the
814 // cumulative aggregate. (Both skipped when no chunk ever
815 // arrived — `accumulated` still `None`.)
816 state.write_response_blob(&acc).await?;
817 }
818 Ok(())
819}
820
821fn now_secs() -> u64 {
822 std::time::SystemTime::now()
823 .duration_since(std::time::UNIX_EPOCH)
824 .map(|d| d.as_secs())
825 .unwrap_or(0)
826}
827
828fn spawn_writer<C>(
829 pool: Pool,
830 tier: Tier,
831 request_body: serde_json::Value,
832 sender_agent_instance_hierarchy: String,
833 items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
834 request_messages: Option<
835 Vec<objectiveai_sdk::agent::completions::message::Message>,
836 >,
837 request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
838 tee: Option<super::tee::ConversationTee>,
839 statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
840 chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
841) -> (LogWriter<C>, oneshot::Receiver<String>)
842where
843 C: WriterChunk
844 + AgentCompletionIds
845 + ChunkAgentRefs
846 + ChunkPush
847 + Clone
848 + Serialize
849 + Send
850 + Sync
851 + 'static,
852{
853 let (tx, rx) = mpsc::unbounded_channel();
854 let (ready_tx, ready_rx) = oneshot::channel();
855 let (written_tx, written_rx) = watch::channel(false);
856 let stream_error: Arc<Mutex<Option<serde_json::Value>>> =
857 Arc::new(Mutex::new(None));
858 let state = LogWriterState::new(
859 pool,
860 tier,
861 request_body,
862 sender_agent_instance_hierarchy,
863 items_fn,
864 request_messages,
865 request_agent_ref,
866 tee,
867 statuses_fn,
868 chunk_error_fn,
869 stream_error.clone(),
870 );
871 let handle = tokio::spawn(listener_loop(rx, state, ready_tx, written_tx));
872 (
873 LogWriter {
874 tx,
875 handle,
876 written_rx,
877 stream_error,
878 _chunk: PhantomData,
879 },
880 ready_rx,
881 )
882}
883
884pub fn write_agent_completion(
885 pool: &Pool,
886 params: &AgentCompletionCreateParams,
887 sender_agent_instance_hierarchy: String,
888 tee: Option<super::tee::ConversationTee>,
889) -> Result<
890 (LogWriter<AgentCompletionChunk>, oneshot::Receiver<String>),
891 crate::error::Error,
892> {
893 let body = serde_json::to_value(params)?;
894 // Agent tier derives request_message rows + the agent_ref from the
895 // REQUEST (not the response chunk). Capture both at construction.
896 let request_messages = Some(params.messages.clone());
897 let request_agent_ref = match ¶ms.agent {
898 objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional::AgentBase(base) => {
899 crate::db::agent_refs::AgentRefValue::inline(base)
900 }
901 objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional::Remote(remote) => {
902 crate::db::agent_refs::AgentRefValue::remote(remote)
903 }
904 };
905 Ok(spawn_writer(
906 pool.clone(),
907 Tier::Agent,
908 body,
909 sender_agent_instance_hierarchy,
910 agent_completion_chunk_rows,
911 request_messages,
912 request_agent_ref,
913 tee,
914 super::rows::agent_completion_statuses,
915 // Agent tier: spawn's `note_error` owns stream errors; the
916 // chunk's own in-band error is logged via `WriterItem::Error`.
917 |_| None,
918 ))
919}
920
921pub fn write_vector_completion(
922 pool: &Pool,
923 params: &VectorCompletionCreateParams,
924 sender_agent_instance_hierarchy: String,
925 tee: Option<super::tee::ConversationTee>,
926) -> Result<
927 (LogWriter<VectorCompletionChunk>, oneshot::Receiver<String>),
928 crate::error::Error,
929> {
930 let body = serde_json::to_value(params)?;
931 Ok(spawn_writer(
932 pool.clone(),
933 Tier::Vector,
934 body,
935 sender_agent_instance_hierarchy,
936 vector_completion_chunk_rows,
937 None,
938 None,
939 tee,
940 super::rows::vector_completion_statuses,
941 // VectorCompletionChunk carries no top-level error field.
942 |_| None,
943 ))
944}
945
946pub fn write_function_execution(
947 pool: &Pool,
948 params: &FunctionExecutionCreateParams,
949 sender_agent_instance_hierarchy: String,
950 tee: Option<super::tee::ConversationTee>,
951) -> Result<
952 (LogWriter<FunctionExecutionChunk>, oneshot::Receiver<String>),
953 crate::error::Error,
954> {
955 let body = serde_json::to_value(params)?;
956 Ok(spawn_writer(
957 pool.clone(),
958 Tier::Function,
959 body,
960 sender_agent_instance_hierarchy,
961 function_execution_chunk_rows,
962 None,
963 None,
964 tee,
965 super::rows::function_execution_statuses,
966 |chunk| chunk.error.as_ref(),
967 ))
968}