Skip to main content

objectiveai_cli/db/logs/
rows.rs

1//! Free functions that walk a chunk and yield every [`WriterItem`] it
2//! implies — the streaming-content [`RowValue`]s plus, at each nested
3//! agent completion carrying usage, a [`WriterItem::Usage`] token-count
4//! snapshot. One entry point per top-level chunk type:
5//!
6//! - [`agent_completion_chunk_rows`]
7//! - [`vector_completion_chunk_rows`]
8//! - [`function_execution_chunk_rows`]
9//!
10//! The function-execution walker is recursive — it forwards into the
11//! vector walker (and back into itself for nested function tasks),
12//! which in turn forwards into the agent walker. No collection
13//! happens: each yielded item borrows from the input chunk and the
14//! writer drains the iterator one element at a time — a single
15//! traversal covers both rows and usage.
16//!
17//! Every yielded `RowValue` carries the enclosing agent-completion
18//! chunk's `response_id` AND `agent_instance_hierarchy`, so the
19//! writer can populate `objectiveai.messages` / `objectiveai.messages_queue`
20//! without a side-channel.
21
22use objectiveai_sdk::agent::completions::message::{RichContent, RichContentPart};
23use objectiveai_sdk::agent::completions::response::ToolResponse;
24use objectiveai_sdk::agent::completions::response::streaming::{
25    AgentCompletionChunk, AssistantResponseChunk, MessageChunk,
26};
27use objectiveai_sdk::functions::executions::response::streaming::{
28    FunctionExecutionChunk, TaskChunk,
29};
30use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
31
32use super::row::{RowValue, RowsIter, WriterItem, WriterItems};
33
34/// Entry: walk an agent-completion chunk. Emits a [`WriterItem::Usage`]
35/// first when the chunk carries a non-`None` usage (its `total_tokens`
36/// snapshot), then every streaming-content row keyed by the chunk's own
37/// `id` and `agent_instance_hierarchy`.
38pub fn agent_completion_chunk_rows<'a>(
39    chunk: &'a AgentCompletionChunk,
40) -> WriterItems<'a> {
41    let response_id = chunk.id.as_str();
42    let agent_hierarchy = chunk.agent_instance_hierarchy.as_str();
43    let usage = chunk.usage.as_ref().map(move |u| WriterItem::Usage {
44        agent_instance_hierarchy: agent_hierarchy,
45        total_tokens: u.total_tokens,
46    });
47    let rows = chunk
48        .messages
49        .iter()
50        .flat_map(move |msg| message_chunk_rows(response_id, agent_hierarchy, msg))
51        .map(WriterItem::Row);
52    Box::new(usage.into_iter().chain(rows))
53}
54
55/// Entry: walk every embedded per-agent completion in a vector chunk
56/// and forward to [`agent_completion_chunk_rows`] (usage included).
57pub fn vector_completion_chunk_rows<'a>(
58    chunk: &'a VectorCompletionChunk,
59) -> WriterItems<'a> {
60    Box::new(
61        chunk
62            .completions
63            .iter()
64            .flat_map(|c| agent_completion_chunk_rows(&c.inner)),
65    )
66}
67
68/// Entry: walk every task in a function chunk and forward to the
69/// matching tier walker. Reasoning summary's inner agent completion
70/// also flows through. Recursive: function tasks chain back into this
71/// function. Usage items interleave with rows via the leaf walker.
72pub fn function_execution_chunk_rows<'a>(
73    chunk: &'a FunctionExecutionChunk,
74) -> WriterItems<'a> {
75    let task_iter = chunk
76        .tasks
77        .iter()
78        .flat_map(|task| task_chunk_rows(task));
79    let reasoning_iter = chunk
80        .reasoning
81        .iter()
82        .flat_map(|r| agent_completion_chunk_rows(&r.inner));
83    Box::new(task_iter.chain(reasoning_iter))
84}
85
86// ---- internal helpers -------------------------------------------------
87
88fn task_chunk_rows<'a>(task: &'a TaskChunk) -> WriterItems<'a> {
89    match task {
90        TaskChunk::FunctionExecution(wrapper) => {
91            function_execution_chunk_rows(&wrapper.inner)
92        }
93        TaskChunk::VectorCompletion(wrapper) => {
94            vector_completion_chunk_rows(&wrapper.inner)
95        }
96    }
97}
98
99fn message_chunk_rows<'a>(
100    response_id: &'a str,
101    agent_instance_hierarchy: &'a str,
102    msg: &'a MessageChunk,
103) -> RowsIter<'a> {
104    match msg {
105        MessageChunk::Assistant(a) => {
106            assistant_response_chunk_rows(response_id, agent_instance_hierarchy, a)
107        }
108        MessageChunk::Tool(t) => {
109            tool_response_rows(response_id, agent_instance_hierarchy, t)
110        }
111    }
112}
113
114fn assistant_response_chunk_rows<'a>(
115    response_id: &'a str,
116    agent_instance_hierarchy: &'a str,
117    chunk: &'a AssistantResponseChunk,
118) -> RowsIter<'a> {
119    let index = chunk.index;
120
121    // Prepend: `MessageQueueContent` rows for every consumed
122    // `message_queue_contents.id` the API stamped. Yielded ahead
123    // of the message body so `objectiveai.messages` chronicles
124    // consumption before the body the agent produced from it.
125    let message_queue_iter = chunk
126        .request_message_ids
127        .as_deref()
128        .unwrap_or(&[])
129        .iter()
130        .copied()
131        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
132            response_id,
133            agent_instance_hierarchy,
134            message_queue_content_id,
135        });
136
137    // Emission order: reasoning → tool_calls → content → refusal.
138    // Refusal goes last on purpose — when a model refuses it's
139    // typically the terminal signal of the turn, so readers see all
140    // the actual work (reasoning / tool calls / content parts) before
141    // the refusal stamp.
142    let reasoning_iter = chunk.reasoning.iter().map(move |text| {
143        RowValue::AssistantResponseReasoning {
144            response_id,
145            agent_instance_hierarchy,
146            index,
147            text: text.as_str(),
148        }
149    });
150    let tool_calls_iter = chunk
151        .tool_calls
152        .iter()
153        .flatten()
154        .filter_map(move |tc| {
155            let id = tc.id.as_deref()?;
156            let name = tc.function.as_ref().and_then(|f| f.name.as_deref())?;
157            let args = tc.function.as_ref().and_then(|f| f.arguments.as_deref())?;
158            Some(RowValue::AssistantResponseToolCalls {
159                response_id,
160                agent_instance_hierarchy,
161                index,
162                // The tool call's own wire `index`, NOT its position in
163                // the merged Vec. `push` correlates streamed tool-call
164                // deltas by `index`, so a call whose wire index is e.g.
165                // 2 can sit at Vec position 0 (or move as earlier-index
166                // deltas arrive). The row PK is
167                // (response_id, index, tool_call_index) — keying it on
168                // Vec position would mislabel calls and let the shadow
169                // key drift across chunks; the wire index is stable.
170                tool_call_index: tc.index,
171                tool_call_id: id,
172                function_name: name,
173                arguments: args,
174            })
175        });
176    let content_iter = chunk
177        .content
178        .iter()
179        .flat_map(move |c| assistant_content_rows(response_id, agent_instance_hierarchy, index, c));
180    let refusal_iter = chunk.refusal.iter().map(move |text| {
181        RowValue::AssistantResponseRefusal {
182            response_id,
183            agent_instance_hierarchy,
184            index,
185            text: text.as_str(),
186        }
187    });
188
189    Box::new(
190        message_queue_iter
191            .chain(reasoning_iter)
192            .chain(tool_calls_iter)
193            .chain(content_iter)
194            .chain(refusal_iter),
195    )
196}
197
198fn tool_response_rows<'a>(
199    response_id: &'a str,
200    agent_instance_hierarchy: &'a str,
201    response: &'a ToolResponse,
202) -> RowsIter<'a> {
203    let index = response.index;
204    // Same MessageQueueContent prepend as the assistant path —
205    // surfaced for wire-shape symmetry. Currently the API never
206    // populates `ToolResponse.request_message_ids`, so this iter
207    // is empty in practice.
208    let message_queue_iter = response
209        .request_message_ids
210        .as_deref()
211        .unwrap_or(&[])
212        .iter()
213        .copied()
214        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
215            response_id,
216            agent_instance_hierarchy,
217            message_queue_content_id,
218        });
219    let head = std::iter::once(RowValue::ToolResponse {
220        response_id,
221        agent_instance_hierarchy,
222        index,
223        tool_call_id: response.inner.tool_call_id.as_str(),
224    });
225    Box::new(
226        message_queue_iter.chain(head).chain(tool_content_rows(
227            response_id,
228            agent_instance_hierarchy,
229            index,
230            &response.inner.content,
231        )),
232    )
233}
234
235fn assistant_content_rows<'a>(
236    response_id: &'a str,
237    agent_instance_hierarchy: &'a str,
238    index: u64,
239    content: &'a RichContent,
240) -> RowsIter<'a> {
241    match content {
242        RichContent::Text(text) => Box::new(std::iter::once(RowValue::AssistantResponseContentText {
243            response_id,
244            agent_instance_hierarchy,
245            index,
246            part_index: 0,
247            text: text.as_str(),
248        })),
249        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
250            assistant_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
251        })),
252    }
253}
254
255fn assistant_content_part<'a>(
256    response_id: &'a str,
257    agent_instance_hierarchy: &'a str,
258    index: u64,
259    part_index: u64,
260    part: &'a RichContentPart,
261) -> RowValue<'a> {
262    match part {
263        RichContentPart::Text { text } => RowValue::AssistantResponseContentText {
264            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
265        },
266        RichContentPart::ImageUrl { image_url } => RowValue::AssistantResponseContentImage {
267            response_id, agent_instance_hierarchy, index, part_index, image_url,
268        },
269        RichContentPart::InputAudio { input_audio } => RowValue::AssistantResponseContentAudio {
270            response_id, agent_instance_hierarchy, index, part_index, input_audio,
271        },
272        RichContentPart::InputVideo { video_url }
273        | RichContentPart::VideoUrl { video_url } => RowValue::AssistantResponseContentVideo {
274            response_id, agent_instance_hierarchy, index, part_index, video_url,
275        },
276        RichContentPart::File { file } => RowValue::AssistantResponseContentFile {
277            response_id, agent_instance_hierarchy, index, part_index, file,
278        },
279    }
280}
281
282fn tool_content_rows<'a>(
283    response_id: &'a str,
284    agent_instance_hierarchy: &'a str,
285    index: u64,
286    content: &'a RichContent,
287) -> RowsIter<'a> {
288    match content {
289        RichContent::Text(text) => Box::new(std::iter::once(RowValue::ToolResponseContentText {
290            response_id,
291            agent_instance_hierarchy,
292            index,
293            part_index: 0,
294            text: text.as_str(),
295        })),
296        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
297            tool_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
298        })),
299    }
300}
301
302fn tool_content_part<'a>(
303    response_id: &'a str,
304    agent_instance_hierarchy: &'a str,
305    index: u64,
306    part_index: u64,
307    part: &'a RichContentPart,
308) -> RowValue<'a> {
309    match part {
310        RichContentPart::Text { text } => RowValue::ToolResponseContentText {
311            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
312        },
313        RichContentPart::ImageUrl { image_url } => RowValue::ToolResponseContentImage {
314            response_id, agent_instance_hierarchy, index, part_index, image_url,
315        },
316        RichContentPart::InputAudio { input_audio } => RowValue::ToolResponseContentAudio {
317            response_id, agent_instance_hierarchy, index, part_index, input_audio,
318        },
319        RichContentPart::InputVideo { video_url }
320        | RichContentPart::VideoUrl { video_url } => RowValue::ToolResponseContentVideo {
321            response_id, agent_instance_hierarchy, index, part_index, video_url,
322        },
323        RichContentPart::File { file } => RowValue::ToolResponseContentFile {
324            response_id, agent_instance_hierarchy, index, part_index, file,
325        },
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use objectiveai_sdk::agent::completions::response::Usage;
333
334    fn agent_chunk(aih: &str, total: Option<u64>) -> AgentCompletionChunk {
335        AgentCompletionChunk {
336            agent_instance_hierarchy: aih.to_string(),
337            usage: total.map(|t| Usage { total_tokens: t, ..Default::default() }),
338            ..Default::default()
339        }
340    }
341
342    /// Collect just the `Usage` items from a walk.
343    fn usages<'a>(items: WriterItems<'a>) -> Vec<(&'a str, u64)> {
344        items
345            .filter_map(|item| match item {
346                WriterItem::Usage { agent_instance_hierarchy, total_tokens } => {
347                    Some((agent_instance_hierarchy, total_tokens))
348                }
349                WriterItem::Row(_) => None,
350            })
351            .collect()
352    }
353
354    #[test]
355    fn agent_walk_emits_usage_only_when_present() {
356        assert_eq!(
357            usages(agent_completion_chunk_rows(&agent_chunk("a/b", Some(42)))),
358            vec![("a/b", 42)]
359        );
360        assert!(usages(agent_completion_chunk_rows(&agent_chunk("a/b", None))).is_empty());
361    }
362
363    #[test]
364    fn vector_walk_surfaces_nested_usage_and_skips_none() {
365        use objectiveai_sdk::vector::completions::response::streaming::AgentCompletionChunk as VecAgent;
366        let chunk = VectorCompletionChunk {
367            completions: vec![
368                VecAgent { index: 0, inner: agent_chunk("a/x", Some(10)), ..Default::default() },
369                VecAgent { index: 1, inner: agent_chunk("a/y", None), ..Default::default() },
370                VecAgent { index: 2, inner: agent_chunk("a/z", Some(7)), ..Default::default() },
371            ],
372            ..Default::default()
373        };
374        assert_eq!(
375            usages(vector_completion_chunk_rows(&chunk)),
376            vec![("a/x", 10), ("a/z", 7)]
377        );
378    }
379}