Skip to main content

objectiveai_cli/db/logs/
rows.rs

1//! Free functions that walk a chunk and yield every streaming-content
2//! [`RowValue`] it implies. One entry point per top-level chunk type:
3//!
4//! - [`agent_completion_chunk_rows`]
5//! - [`vector_completion_chunk_rows`]
6//! - [`function_execution_chunk_rows`]
7//!
8//! The function-execution walker is recursive — it forwards into the
9//! vector walker (and back into itself for nested function tasks),
10//! which in turn forwards into the agent walker. No collection
11//! happens: each yielded row borrows from the input chunk and the
12//! writer drains the iterator one element at a time.
13//!
14//! Every yielded `RowValue` carries the enclosing agent-completion
15//! chunk's `response_id` AND `agent_instance_hierarchy`, so the
16//! writer can populate `objectiveai.messages` / `objectiveai.messages_queue`
17//! without a side-channel.
18
19use objectiveai_sdk::agent::completions::message::{RichContent, RichContentPart};
20use objectiveai_sdk::agent::completions::response::ToolResponse;
21use objectiveai_sdk::agent::completions::response::streaming::{
22    AgentCompletionChunk, AssistantResponseChunk, MessageChunk,
23};
24use objectiveai_sdk::functions::executions::response::streaming::{
25    FunctionExecutionChunk, TaskChunk,
26};
27use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
28
29use super::row::{RowValue, RowsIter};
30
31/// Entry: walk an agent-completion chunk's `messages` and yield every
32/// streaming-content row keyed by the chunk's own `id` and
33/// `agent_instance_hierarchy`.
34pub fn agent_completion_chunk_rows<'a>(
35    chunk: &'a AgentCompletionChunk,
36) -> RowsIter<'a> {
37    let response_id = chunk.id.as_str();
38    let agent_hierarchy = chunk.agent_instance_hierarchy.as_str();
39    Box::new(
40        chunk
41            .messages
42            .iter()
43            .flat_map(move |msg| message_chunk_rows(response_id, agent_hierarchy, msg)),
44    )
45}
46
47/// Entry: walk every embedded per-agent completion in a vector chunk
48/// and forward to [`agent_completion_chunk_rows`].
49pub fn vector_completion_chunk_rows<'a>(
50    chunk: &'a VectorCompletionChunk,
51) -> RowsIter<'a> {
52    Box::new(
53        chunk
54            .completions
55            .iter()
56            .flat_map(|c| agent_completion_chunk_rows(&c.inner)),
57    )
58}
59
60/// Entry: walk every task in a function chunk and forward to the
61/// matching tier walker. Reasoning summary's inner agent completion
62/// also flows through. Recursive: function tasks chain back into this
63/// function.
64pub fn function_execution_chunk_rows<'a>(
65    chunk: &'a FunctionExecutionChunk,
66) -> RowsIter<'a> {
67    let task_iter = chunk
68        .tasks
69        .iter()
70        .flat_map(|task| task_chunk_rows(task));
71    let reasoning_iter = chunk
72        .reasoning
73        .iter()
74        .flat_map(|r| agent_completion_chunk_rows(&r.inner));
75    Box::new(task_iter.chain(reasoning_iter))
76}
77
78// ---- internal helpers -------------------------------------------------
79
80fn task_chunk_rows<'a>(task: &'a TaskChunk) -> RowsIter<'a> {
81    match task {
82        TaskChunk::FunctionExecution(wrapper) => {
83            function_execution_chunk_rows(&wrapper.inner)
84        }
85        TaskChunk::VectorCompletion(wrapper) => {
86            vector_completion_chunk_rows(&wrapper.inner)
87        }
88    }
89}
90
91fn message_chunk_rows<'a>(
92    response_id: &'a str,
93    agent_instance_hierarchy: &'a str,
94    msg: &'a MessageChunk,
95) -> RowsIter<'a> {
96    match msg {
97        MessageChunk::Assistant(a) => {
98            assistant_response_chunk_rows(response_id, agent_instance_hierarchy, a)
99        }
100        MessageChunk::Tool(t) => {
101            tool_response_rows(response_id, agent_instance_hierarchy, t)
102        }
103    }
104}
105
106fn assistant_response_chunk_rows<'a>(
107    response_id: &'a str,
108    agent_instance_hierarchy: &'a str,
109    chunk: &'a AssistantResponseChunk,
110) -> RowsIter<'a> {
111    let index = chunk.index;
112
113    // Prepend: `MessageQueueContent` rows for every consumed
114    // `message_queue_contents.id` the API stamped. Yielded ahead
115    // of the message body so `objectiveai.messages` chronicles
116    // consumption before the body the agent produced from it.
117    let message_queue_iter = chunk
118        .request_message_ids
119        .as_deref()
120        .unwrap_or(&[])
121        .iter()
122        .copied()
123        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
124            response_id,
125            agent_instance_hierarchy,
126            message_queue_content_id,
127        });
128
129    // Emission order: reasoning → tool_calls → content → refusal.
130    // Refusal goes last on purpose — when a model refuses it's
131    // typically the terminal signal of the turn, so readers see all
132    // the actual work (reasoning / tool calls / content parts) before
133    // the refusal stamp.
134    let reasoning_iter = chunk.reasoning.iter().map(move |text| {
135        RowValue::AssistantResponseReasoning {
136            response_id,
137            agent_instance_hierarchy,
138            index,
139            text: text.as_str(),
140        }
141    });
142    let tool_calls_iter = chunk
143        .tool_calls
144        .iter()
145        .flatten()
146        .enumerate()
147        .filter_map(move |(tc_idx, tc)| {
148            let id = tc.id.as_deref()?;
149            let name = tc.function.as_ref().and_then(|f| f.name.as_deref())?;
150            let args = tc.function.as_ref().and_then(|f| f.arguments.as_deref())?;
151            Some(RowValue::AssistantResponseToolCalls {
152                response_id,
153                agent_instance_hierarchy,
154                index,
155                tool_call_index: tc_idx as u64,
156                tool_call_id: id,
157                function_name: name,
158                arguments: args,
159            })
160        });
161    let content_iter = chunk
162        .content
163        .iter()
164        .flat_map(move |c| assistant_content_rows(response_id, agent_instance_hierarchy, index, c));
165    let refusal_iter = chunk.refusal.iter().map(move |text| {
166        RowValue::AssistantResponseRefusal {
167            response_id,
168            agent_instance_hierarchy,
169            index,
170            text: text.as_str(),
171        }
172    });
173
174    Box::new(
175        message_queue_iter
176            .chain(reasoning_iter)
177            .chain(tool_calls_iter)
178            .chain(content_iter)
179            .chain(refusal_iter),
180    )
181}
182
183fn tool_response_rows<'a>(
184    response_id: &'a str,
185    agent_instance_hierarchy: &'a str,
186    response: &'a ToolResponse,
187) -> RowsIter<'a> {
188    let index = response.index;
189    // Same MessageQueueContent prepend as the assistant path —
190    // surfaced for wire-shape symmetry. Currently the API never
191    // populates `ToolResponse.request_message_ids`, so this iter
192    // is empty in practice.
193    let message_queue_iter = response
194        .request_message_ids
195        .as_deref()
196        .unwrap_or(&[])
197        .iter()
198        .copied()
199        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
200            response_id,
201            agent_instance_hierarchy,
202            message_queue_content_id,
203        });
204    let head = std::iter::once(RowValue::ToolResponse {
205        response_id,
206        agent_instance_hierarchy,
207        index,
208        tool_call_id: response.inner.tool_call_id.as_str(),
209    });
210    Box::new(
211        message_queue_iter.chain(head).chain(tool_content_rows(
212            response_id,
213            agent_instance_hierarchy,
214            index,
215            &response.inner.content,
216        )),
217    )
218}
219
220fn assistant_content_rows<'a>(
221    response_id: &'a str,
222    agent_instance_hierarchy: &'a str,
223    index: u64,
224    content: &'a RichContent,
225) -> RowsIter<'a> {
226    match content {
227        RichContent::Text(text) => Box::new(std::iter::once(RowValue::AssistantResponseContentText {
228            response_id,
229            agent_instance_hierarchy,
230            index,
231            part_index: 0,
232            text: text.as_str(),
233        })),
234        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
235            assistant_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
236        })),
237    }
238}
239
240fn assistant_content_part<'a>(
241    response_id: &'a str,
242    agent_instance_hierarchy: &'a str,
243    index: u64,
244    part_index: u64,
245    part: &'a RichContentPart,
246) -> RowValue<'a> {
247    match part {
248        RichContentPart::Text { text } => RowValue::AssistantResponseContentText {
249            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
250        },
251        RichContentPart::ImageUrl { image_url } => RowValue::AssistantResponseContentImage {
252            response_id, agent_instance_hierarchy, index, part_index, image_url,
253        },
254        RichContentPart::InputAudio { input_audio } => RowValue::AssistantResponseContentAudio {
255            response_id, agent_instance_hierarchy, index, part_index, input_audio,
256        },
257        RichContentPart::InputVideo { video_url }
258        | RichContentPart::VideoUrl { video_url } => RowValue::AssistantResponseContentVideo {
259            response_id, agent_instance_hierarchy, index, part_index, video_url,
260        },
261        RichContentPart::File { file } => RowValue::AssistantResponseContentFile {
262            response_id, agent_instance_hierarchy, index, part_index, file,
263        },
264    }
265}
266
267fn tool_content_rows<'a>(
268    response_id: &'a str,
269    agent_instance_hierarchy: &'a str,
270    index: u64,
271    content: &'a RichContent,
272) -> RowsIter<'a> {
273    match content {
274        RichContent::Text(text) => Box::new(std::iter::once(RowValue::ToolResponseContentText {
275            response_id,
276            agent_instance_hierarchy,
277            index,
278            part_index: 0,
279            text: text.as_str(),
280        })),
281        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
282            tool_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
283        })),
284    }
285}
286
287fn tool_content_part<'a>(
288    response_id: &'a str,
289    agent_instance_hierarchy: &'a str,
290    index: u64,
291    part_index: u64,
292    part: &'a RichContentPart,
293) -> RowValue<'a> {
294    match part {
295        RichContentPart::Text { text } => RowValue::ToolResponseContentText {
296            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
297        },
298        RichContentPart::ImageUrl { image_url } => RowValue::ToolResponseContentImage {
299            response_id, agent_instance_hierarchy, index, part_index, image_url,
300        },
301        RichContentPart::InputAudio { input_audio } => RowValue::ToolResponseContentAudio {
302            response_id, agent_instance_hierarchy, index, part_index, input_audio,
303        },
304        RichContentPart::InputVideo { video_url }
305        | RichContentPart::VideoUrl { video_url } => RowValue::ToolResponseContentVideo {
306            response_id, agent_instance_hierarchy, index, part_index, video_url,
307        },
308        RichContentPart::File { file } => RowValue::ToolResponseContentFile {
309            response_id, agent_instance_hierarchy, index, part_index, file,
310        },
311    }
312}