Skip to main content

objectiveai_sdk/cli/command/agents/logs/list/
mod.rs

1//! `agents logs read all` — fetch every log row for a target AIH,
2//! coalesced into [`ResponseItem`] blocks.
3//!
4//! Each yielded `ResponseItem` is either a single-row request blob
5//! (`AgentCompletionRequest` / `VectorCompletionRequest` /
6//! `FunctionExecutionRequest`) or a multi-row block
7//! (`ClientNotification` / `AssistantResponse` / `ToolResponse`)
8//! formed by joining consecutive `logs.messages` rows that share
9//! `(block_class, agent_instance_hierarchy, response_id)` —
10//! `ToolResponse` additionally splits per `tool_call_id`.
11//!
12//! `response_id` is carried on every variant — for the three
13//! request-blob variants it's the chunk's own id, for the three
14//! block variants it's the immediately-enclosing
15//! agent-completion's id (even when the underlying rows were
16//! emitted inside a function execution or vector completion
17//! chunk).
18
19use std::str::FromStr;
20
21use crate::cli::command::CommandRequest;
22use crate::cli::command::path_ref::tokenize;
23
24/// One queue-read target. Either direct `(parent, instance)` (parent
25/// defaults to the cli's own `Config.agent_instance_hierarchy` when
26/// omitted), a tag name the cli resolves at handler time, or `me`
27/// (the caller's own `Config.agent_instance_hierarchy`, with no child
28/// leaf appended). Shared with `agents read pending` via re-export.
29///
30/// Docker-style `key=value,key=value` wire form on the CLI:
31///   `--target instance=L`           (direct; parent defaults to ctx)
32///   `--target instance=L,parent=P`  (direct; explicit parent)
33///   `--target tag=T`                (tag; cli resolves via the tags tier)
34///   `--target me`                   (the caller's own AIH; bare valueless key)
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
36#[serde(tag = "by", rename_all = "snake_case")]
37#[schemars(rename = "cli.command.agents.logs.list.Target")]
38pub enum Target {
39    #[schemars(title = "Direct")]
40    Direct {
41        /// Optional lineage prefix. `None` ⇒ cli substitutes
42        /// `Config.agent_instance_hierarchy`.
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        #[schemars(extend("omitempty" = true))]
45        parent_agent_instance_hierarchy: Option<String>,
46        /// Leaf id of the target agent.
47        agent_instance: String,
48    },
49    #[schemars(title = "Tag")]
50    Tag { agent_tag: String },
51    /// The caller's own `Config.agent_instance_hierarchy`, verbatim.
52    /// CLI wire form is the bare valueless key `me` (docker
53    /// `readonly`-style), mutually exclusive with the other keys.
54    #[schemars(title = "Me")]
55    Me,
56}
57
58impl FromStr for Target {
59    type Err = String;
60    /// Parse a `--target` arg. Accepted forms: the bare valueless key
61    /// `me`; `instance` + optional `parent`; or `tag` alone. `tag` is
62    /// mutually exclusive with the other two keys.
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        // Bare valueless key (docker `readonly`-style). Handled before
65        // `tokenize`, which requires every token to be `key=value`.
66        if s.trim() == "me" {
67            return Ok(Target::Me);
68        }
69        let mut tag: Option<String> = None;
70        let mut parent: Option<String> = None;
71        let mut instance: Option<String> = None;
72        for (k, v) in tokenize(s)? {
73            match k {
74                "tag" => tag = Some(v.to_string()),
75                "instance" => instance = Some(v.to_string()),
76                "parent" => parent = Some(v.to_string()),
77                other => return Err(format!("unknown key: {other}")),
78            }
79        }
80        match (tag, instance, parent) {
81            (Some(t), None, None) => Ok(Target::Tag { agent_tag: t }),
82            (Some(_), _, _) => Err(
83                "tag is mutually exclusive with instance and parent".to_string(),
84            ),
85            (None, Some(i), p) => Ok(Target::Direct {
86                parent_agent_instance_hierarchy: p,
87                agent_instance: i,
88            }),
89            (None, None, _) => Err("instance or tag is required".to_string()),
90        }
91    }
92}
93
94impl Target {
95    /// Inverse of [`FromStr::from_str`]: emit the docker-style
96    /// `key=value,key=value` wire form for round-tripping.
97    pub fn into_arg_string(&self) -> String {
98        match self {
99            Target::Me => "me".to_string(),
100            Target::Tag { agent_tag } => format!("tag={agent_tag}"),
101            Target::Direct {
102                parent_agent_instance_hierarchy: None,
103                agent_instance,
104            } => format!("instance={agent_instance}"),
105            Target::Direct {
106                parent_agent_instance_hierarchy: Some(p),
107                agent_instance,
108            } => format!("instance={agent_instance},parent={p}"),
109        }
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
114#[schemars(rename = "cli.command.agents.logs.list.Request")]
115pub struct Request {
116    pub path_type: Path,
117    /// `true` = list only pending (unfinalized) rows; `false` = list
118    /// every logged row. Selected on the CLI by `--pending` / `--all`.
119    pub pending: bool,
120    pub targets: Vec<Target>,
121    /// Skip rows with `logs.messages."index" <= after_id`. Use the
122    /// highest `id` from a previous page to paginate forward.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    #[schemars(extend("omitempty" = true))]
125    pub after_id: Option<i64>,
126    /// Cap on rows scanned per target. `None` = unlimited.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    #[schemars(extend("omitempty" = true))]
129    pub limit: Option<i64>,
130    #[serde(flatten)]
131    pub base: crate::cli::command::RequestBase,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
135#[schemars(rename = "cli.command.agents.logs.list.Path")]
136pub enum Path {
137    #[serde(rename = "agents/logs/list")]
138    AgentsLogsList,
139}
140
141impl CommandRequest for Request {
142    fn request_base(&self) -> &crate::cli::command::RequestBase {
143        &self.base
144    }
145
146    fn request_base_mut(&mut self) -> Option<&mut crate::cli::command::RequestBase> {
147        Some(&mut self.base)
148    }
149}
150
151/// Type tag for one `ClientNotification` part — the table-kind of
152/// the underlying `message_queue_*` content row.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
154#[serde(rename_all = "snake_case")]
155#[schemars(rename = "cli.command.agents.logs.list.ClientNotificationPartType")]
156pub enum ClientNotificationPartType {
157    Text,
158    Image,
159    Audio,
160    Video,
161    File,
162}
163
164/// One row inside a `ClientNotification` block — a consumed
165/// `message_queue_contents` entry. `queued_at` is on the
166/// enclosing block (it lives on `message_queue.enqueued_at`, not
167/// per-content); only the per-row consumption timestamp is here.
168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
169#[schemars(rename = "cli.command.agents.logs.list.ClientNotificationPart")]
170pub struct ClientNotificationPart {
171    /// `logs.messages."index"` for this row. Pass to
172    /// `agents logs read id <n>` to fetch the consumed
173    /// `message_queue_contents` body.
174    pub id: i64,
175    /// `logs.messages."timestamp"` — when the receiver consumed
176    /// this content row and the LogWriter committed the
177    /// consumption event.
178    pub delivered_at: String,
179    pub r#type: ClientNotificationPartType,
180}
181
182/// One row inside an `AssistantResponse` block, tagged by the
183/// table-kind of the underlying `assistant_response_*` row.
184///
185/// The `ToolCall` variant inlines the call's metadata
186/// (`function_name` / `tool_call_id` / `tool_call_index`) so callers
187/// can dedupe and correlate without a per-row round-trip; its `id`
188/// addresses the same `assistant_response_tool_calls` row, which
189/// `agents logs read id <id>` returns as the call's `arguments`
190/// (text). Every other variant carries only `id` (the
191/// `logs.messages."index"` to pass to `agents logs read id`) and the
192/// delivery timestamp.
193#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
194#[serde(tag = "type", rename_all = "snake_case")]
195#[schemars(rename = "cli.command.agents.logs.list.AssistantResponsePart")]
196pub enum AssistantResponsePart {
197    #[schemars(title = "ToolCall")]
198    ToolCall {
199        /// `logs.messages."index"` for the tool-call row. Pass to
200        /// `agents logs read id <n>` to read the call's `arguments`
201        /// as text.
202        id: i64,
203        delivered_at: String,
204        /// `objectiveai.assistant_response_tool_calls.function_name`.
205        function_name: String,
206        /// The wire tool-call id this row carries.
207        tool_call_id: String,
208        /// The tool call's wire index within the assistant message's
209        /// `tool_calls[]`.
210        tool_call_index: i64,
211    },
212    #[schemars(title = "Refusal")]
213    Refusal { id: i64, delivered_at: String },
214    #[schemars(title = "Reasoning")]
215    Reasoning { id: i64, delivered_at: String },
216    #[schemars(title = "Text")]
217    Text { id: i64, delivered_at: String },
218    #[schemars(title = "Image")]
219    Image { id: i64, delivered_at: String },
220    #[schemars(title = "Audio")]
221    Audio { id: i64, delivered_at: String },
222    #[schemars(title = "Video")]
223    Video { id: i64, delivered_at: String },
224    #[schemars(title = "File")]
225    File { id: i64, delivered_at: String },
226}
227
228/// Type tag for one `ToolResponse` part — the table-kind of the
229/// underlying `tool_response_content_*` row. The tool-call linkage
230/// (`tool_call_id`) lives on the enclosing `ResponseItem::ToolResponse`
231/// block, not on the parts.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
233#[serde(rename_all = "snake_case")]
234#[schemars(rename = "cli.command.agents.logs.list.ToolResponsePartType")]
235pub enum ToolResponsePartType {
236    Text,
237    Image,
238    Audio,
239    Video,
240    File,
241}
242
243/// One row inside a `ToolResponse` block.
244#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
245#[schemars(rename = "cli.command.agents.logs.list.ToolResponsePart")]
246pub struct ToolResponsePart {
247    /// `logs.messages."index"` for this row. Pass to
248    /// `agents logs read id <n>` for the typed body.
249    pub id: i64,
250    pub delivered_at: String,
251    pub r#type: ToolResponsePartType,
252}
253
254/// One yielded item. Three single-row request blobs +
255/// three multi-row blocks. Every variant carries `response_id`.
256/// `sender_agent_instance_hierarchy` appears only on the four
257/// variants that have a sender ≠ producer: the three request
258/// variants (caller AIH) and `ClientNotification` (enqueuer
259/// AIH). `AssistantResponse` and `ToolResponse` are emitted BY
260/// the agent itself — their `agent_instance_hierarchy` IS the
261/// producer, so no separate sender field exists.
262///
263/// Block-coalescing boundary tuple: `(class,
264/// agent_instance_hierarchy, response_id)` for `AssistantResponse`;
265/// `(class, agent_instance_hierarchy, response_id, tool_call_id)`
266/// for `ToolResponse` (one block per tool call); `(class,
267/// agent_instance_hierarchy, response_id, sender, message_queue_id)`
268/// for `ClientNotification` blocks.
269/// One `ClientNotification` block = one consumed
270/// `message_queue` parent row, so `queued_at` and
271/// `sender_agent_instance_hierarchy` are well-defined
272/// block-level.
273#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
274#[serde(tag = "type", rename_all = "snake_case")]
275#[schemars(rename = "cli.command.agents.logs.list.ResponseItem")]
276pub enum ResponseItem {
277    #[schemars(title = "AgentCompletionRequest")]
278    AgentCompletionRequest {
279        id: i64,
280        agent_instance_hierarchy: String,
281        /// AIH of the caller who issued the request — from
282        /// `logs.agent_completion_requests.sender_*`.
283        sender_agent_instance_hierarchy: String,
284        delivered_at: String,
285        response_id: String,
286    },
287    #[schemars(title = "VectorCompletionRequest")]
288    VectorCompletionRequest {
289        id: i64,
290        agent_instance_hierarchy: String,
291        sender_agent_instance_hierarchy: String,
292        delivered_at: String,
293        response_id: String,
294    },
295    #[schemars(title = "FunctionExecutionRequest")]
296    FunctionExecutionRequest {
297        id: i64,
298        agent_instance_hierarchy: String,
299        sender_agent_instance_hierarchy: String,
300        delivered_at: String,
301        response_id: String,
302    },
303    #[schemars(title = "ClientNotification")]
304    ClientNotification {
305        agent_instance_hierarchy: String,
306        /// AIH of the enqueuer — from `message_queue.sender_*`
307        /// joined through `message_queue_contents.id`.
308        sender_agent_instance_hierarchy: String,
309        response_id: String,
310        /// `message_queue.enqueued_at` of the consumed parent
311        /// queue row. One block = one parent queue row, so this
312        /// is well-defined block-level (each part's individual
313        /// `delivered_at` still records its own
314        /// consumption moment).
315        queued_at: String,
316        /// Idempotency token, if the row was enqueued with
317        /// `--key` via `agents message --enqueue-with-key`.
318        /// Surfacing it lets readers attribute a notification
319        /// to a specific enqueue beyond just the sender AIH.
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        #[schemars(extend("omitempty" = true))]
322        key: Option<String>,
323        parts: Vec<ClientNotificationPart>,
324    },
325    /// Agent emissions — the agent IS the producer of these
326    /// rows, so there's no separate sender. The
327    /// `agent_instance_hierarchy` field IS the sender.
328    #[schemars(title = "AssistantResponse")]
329    AssistantResponse {
330        agent_instance_hierarchy: String,
331        response_id: String,
332        parts: Vec<AssistantResponsePart>,
333    },
334    /// One tool call's response. Blocks are grouped per
335    /// `tool_call_id` (in addition to `agent_instance_hierarchy` +
336    /// `response_id`), so two responses in the same turn yield two
337    /// blocks.
338    #[schemars(title = "ToolResponse")]
339    ToolResponse {
340        agent_instance_hierarchy: String,
341        response_id: String,
342        /// The wire tool-call id this response answers.
343        tool_call_id: String,
344        parts: Vec<ToolResponsePart>,
345    },
346}
347
348#[derive(clap::Args)]
349#[command(group(
350    clap::ArgGroup::new("logs_list_mode")
351        .required(true)
352        .multiple(false)
353        .args(["all", "pending"])
354))]
355pub struct Args {
356    /// List every logged row. Mutually exclusive with `--pending`;
357    /// exactly one of the two is required.
358    #[arg(long)]
359    pub all: bool,
360    /// List only pending (unfinalized) rows. Mutually exclusive with
361    /// `--all`; exactly one of the two is required.
362    #[arg(long)]
363    pub pending: bool,
364    /// One or more `--target instance=L[,parent=P]` entries. `parent`
365    /// defaults to the cli's own `Config.agent_instance_hierarchy`
366    /// when omitted on an individual target. Also accepts
367    /// `--target tag=T` and `--target me` (the caller's own AIH).
368    #[arg(long = "target", required = true)]
369    pub targets: Vec<String>,
370    /// Skip rows with `logs.messages."index" <= after_id` per target.
371    #[arg(long)]
372    pub after_id: Option<i64>,
373    /// Cap on rows scanned per target.
374    #[arg(long)]
375    pub limit: Option<i64>,
376    #[command(flatten)]
377    pub base: crate::cli::command::RequestBaseArgs,
378}
379
380#[derive(clap::Args)]
381#[command(args_conflicts_with_subcommands = true)]
382pub struct Command {
383    #[command(flatten)]
384    pub args: Args,
385    #[command(subcommand)]
386    pub schema: Option<Schema>,
387}
388
389#[derive(clap::Subcommand)]
390pub enum Schema {
391    /// Emit the JSON Schema for this leaf's `Request` type and exit.
392    RequestSchema(request_schema::Args),
393    /// Emit the JSON Schema for this leaf's `Response` type and exit.
394    ResponseSchema(response_schema::Args),
395}
396
397impl TryFrom<Args> for Request {
398    type Error = crate::cli::command::FromArgsError;
399    fn try_from(args: Args) -> Result<Self, Self::Error> {
400        let targets = args
401            .targets
402            .iter()
403            .map(|s| {
404                s.parse::<Target>().map_err(|msg| {
405                    crate::cli::command::FromArgsError::path_parse("target", msg)
406                })
407            })
408            .collect::<Result<Vec<_>, _>>()?;
409        Ok(Self {
410            path_type: Path::AgentsLogsList,
411            // The `logs_list_mode` group guarantees exactly one of
412            // `--all` / `--pending`, so `pending` is the full mode.
413            pending: args.pending,
414            targets,
415            after_id: args.after_id,
416            limit: args.limit,
417            base: args.base.into(),
418        })
419    }
420}
421
422#[cfg(feature = "cli-executor")]
423pub async fn execute<E: crate::cli::command::CommandExecutor>(
424    executor: &E,
425    mut request: Request,
426
427        agent_arguments: Option<&crate::cli::command::AgentArguments>,
428    ) -> Result<E::Stream<ResponseItem>, E::Error> {
429    request.base.clear_transform();
430    executor.execute(request, agent_arguments).await
431}
432
433#[cfg(feature = "cli-executor")]
434pub async fn execute_transform<E: crate::cli::command::CommandExecutor>(
435    executor: &E,
436    mut request: Request,
437    transform: crate::cli::command::Transform,
438
439        agent_arguments: Option<&crate::cli::command::AgentArguments>,
440    ) -> Result<E::Stream<serde_json::Value>, E::Error> {
441    request.base.set_transform(transform);
442    executor.execute(request, agent_arguments).await
443}
444
445#[cfg(feature = "mcp")]
446impl crate::cli::command::CommandResponse for ResponseItem {
447    fn into_mcp(self) -> crate::cli::command::McpResponseItem {
448        crate::cli::command::McpResponseItem::JSONL(serde_json::to_value(self).unwrap())
449    }
450}
451
452pub mod request_schema;
453
454
455pub mod response_schema;
456
457#[cfg(test)]
458mod tests {
459    use super::Target;
460
461    #[test]
462    fn me_target_parses_and_round_trips() {
463        assert_eq!("me".parse::<Target>(), Ok(Target::Me));
464        // Surrounding whitespace is trimmed, same as the other forms.
465        assert_eq!("  me  ".parse::<Target>(), Ok(Target::Me));
466        assert_eq!(Target::Me.into_arg_string(), "me");
467    }
468
469    #[test]
470    fn me_is_mutually_exclusive_with_other_keys() {
471        // `me` combined with any other key falls through to the
472        // `key=value` tokenizer and is rejected.
473        assert!("me,instance=x".parse::<Target>().is_err());
474    }
475
476    #[test]
477    fn json_wire_shape_is_by_me() {
478        assert_eq!(
479            serde_json::to_value(Target::Me).unwrap(),
480            serde_json::json!({ "by": "me" }),
481        );
482    }
483}