Skip to main content

oxi_agent/
events.rs

1/// Agent event system
2/// Defines all events emitted during an agent run, including lifecycle,
3/// streaming, tool execution, compaction, retry, and steering events.
4use crate::compaction::CompactionEvent;
5use serde::{Deserialize, Serialize};
6
7// ── Tool context types ────────────────────────────────────────────────────
8
9/// Semantic context for a tool execution event.
10///
11/// Carries structured information about *what* a tool call means,
12/// derived from the tool name and arguments by the agent loop.
13/// UI consumers that understand a context variant can render it
14/// richly; older consumers simply ignore the field.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum ToolCallContext {
19    // ── Web exploration ──────────────────────────────────────
20    /// A search engine query.
21    WebSearch {
22        /// The search query string.
23        query: String,
24        /// Search engine used (e.g. "duckduckgo").
25        #[serde(skip_serializing_if = "Option::is_none")]
26        engine: Option<String>,
27    },
28
29    /// Visiting a web page.
30    PageVisit {
31        /// URL being visited.
32        url: String,
33        /// Why this page is being visited.
34        #[serde(skip_serializing_if = "Option::is_none")]
35        reason: Option<VisitReason>,
36        // ── Result fields (enriched by BrowseProgress::DocumentReady) ──
37        /// Page `<title>` after load.
38        #[serde(skip_serializing_if = "Option::is_none")]
39        page_title: Option<String>,
40        /// HTTP status code.
41        #[serde(skip_serializing_if = "Option::is_none")]
42        page_status: Option<u16>,
43        /// HTML body size in bytes.
44        #[serde(skip_serializing_if = "Option::is_none")]
45        page_bytes: Option<u64>,
46        /// Wall-clock page load duration in milliseconds.
47        #[serde(skip_serializing_if = "Option::is_none")]
48        page_duration_ms: Option<u64>,
49        // ── Error / enrichment fields ──
50        /// Navigation error message (from BrowseProgress::NavigationFailed).
51        #[serde(skip_serializing_if = "Option::is_none")]
52        navigation_error: Option<String>,
53        /// Screenshot metadata (from BrowseProgress::ScreenshotCaptured).
54        #[serde(skip_serializing_if = "Option::is_none")]
55        screenshot: Option<ScreenshotMeta>,
56    },
57
58    /// Extracting data from a web page.
59    DataExtraction {
60        /// Description of what is being extracted (e.g. CSS selector).
61        target: String,
62        /// URL of the page being extracted from.
63        #[serde(skip_serializing_if = "Option::is_none")]
64        url: Option<String>,
65        // ── Result fields (enriched by BrowseProgress::DocumentReady) ──
66        /// Number of items extracted.
67        #[serde(skip_serializing_if = "Option::is_none")]
68        result_count: Option<usize>,
69        /// HTTP status code of the page.
70        #[serde(skip_serializing_if = "Option::is_none")]
71        page_status: Option<u16>,
72        /// Page load duration in milliseconds.
73        #[serde(skip_serializing_if = "Option::is_none")]
74        page_duration_ms: Option<u64>,
75    },
76
77    /// An action within a persistent browser session.
78    SessionAction {
79        /// The session action being performed (e.g. "goto", "click").
80        action: String,
81        /// URL if the action involves navigation.
82        #[serde(skip_serializing_if = "Option::is_none")]
83        url: Option<String>,
84    },
85
86    /// A step within a browse script.
87    ScriptStep {
88        /// Current step index (1-based).
89        current: usize,
90        /// Total number of steps.
91        total: usize,
92        /// Human-readable step description.
93        step: String,
94    },
95}
96
97/// Screenshot metadata attached to PageVisit context.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ScreenshotMeta {
100    /// PNG payload size in bytes.
101    pub bytes: usize,
102    /// Viewport width.
103    pub width: u32,
104    /// Capture duration in milliseconds.
105    pub duration_ms: u64,
106}
107
108/// Reason for visiting a page.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum VisitReason {
112    /// The agent specified the URL directly.
113    DirectNavigation,
114    /// Clicked a search result at the given position.
115    SearchResult {
116        /// 1-based position in search results.
117        position: usize,
118    },
119    /// Followed a link from another page.
120    LinkFollowed {
121        /// The URL the link was on.
122        from_url: String,
123    },
124}
125
126/// Events emitted during agent execution.
127///
128/// Events are tagged with `type` and serialized as camelCase for JSON consumers.
129/// This enum is `#[non_exhaustive]` — new variants may be added in future releases.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(tag = "type", rename_all = "camelCase")]
132#[non_exhaustive]
133pub enum AgentEvent {
134    // ── Lifecycle events ──────────────────────────────────────────────
135    /// Emitted when the agent begins processing a batch of prompts.
136    AgentStart {
137        /// The initial prompt messages sent to the agent.
138        prompts: Vec<oxi_ai::Message>,
139        /// Optional session identifier for correlation.
140        session_id: Option<String>,
141    },
142
143    /// Emitted when the agent finishes all processing.
144    AgentEnd {
145        /// Final conversation messages.
146        messages: Vec<oxi_ai::Message>,
147        /// Why the agent stopped (e.g. `"end_turn"`, `"tool_use"`).
148        stop_reason: Option<String>,
149        /// Optional session identifier for correlation.
150        session_id: Option<String>,
151    },
152
153    /// Emitted at the start of each agent loop turn.
154    TurnStart {
155        /// Zero-based turn index.
156        turn_number: u32,
157    },
158
159    /// Emitted when a turn completes, including the assistant reply and tool results.
160    TurnEnd {
161        /// Turn index that just completed.
162        turn_number: u32,
163        /// The assistant message produced this turn.
164        assistant_message: oxi_ai::Message,
165        /// Tool results collected during this turn.
166        tool_results: Vec<oxi_ai::ToolResultMessage>,
167    },
168
169    // ── Message events ────────────────────────────────────────────────
170    /// A new message has been created in the conversation.
171    MessageStart {
172        /// The message that started.
173        message: oxi_ai::Message,
174    },
175
176    /// A message has been updated with new content.
177    MessageUpdate {
178        /// The message in its current state.
179        message: oxi_ai::Message,
180        /// Incremental text delta since the last update, if available.
181        delta: Option<String>,
182    },
183
184    /// A message has been finalized.
185    MessageEnd {
186        /// The completed message.
187        message: oxi_ai::Message,
188    },
189
190    // ── Tool execution events ────────────────────────────────────────
191    /// A tool is about to be executed.
192    ToolExecutionStart {
193        /// Unique identifier for this tool call.
194        tool_call_id: String,
195        /// Name of the tool being invoked.
196        tool_name: String,
197        /// JSON arguments passed to the tool.
198        args: serde_json::Value,
199        /// Intent trace — a concise description of what this tool call does.
200        /// `None` for tools without intent tracing.
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        intent: Option<String>,
203        /// Semantic context inferred from tool name and arguments.
204        /// `None` for tools without a known context mapping.
205        #[serde(default, skip_serializing_if = "Option::is_none")]
206        context: Option<ToolCallContext>,
207    },
208
209    /// Partial progress from a running tool execution.
210    ToolExecutionUpdate {
211        /// Identifier of the tool call producing the update.
212        tool_call_id: String,
213        /// Name of the tool.
214        tool_name: String,
215        /// Partial result text so far.
216        partial_result: String,
217        /// Browser tab id that produced this progress (if the tool is
218        /// tab-aware). `None` for tools that don't have a tab concept,
219        /// or for older tool implementations that don't propagate tab ids.
220        #[serde(default, skip_serializing_if = "Option::is_none")]
221        tab_id: Option<uuid::Uuid>,
222        /// Semantic context inferred from tool name and arguments.
223        /// Carries structured information about what this update means.
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        context: Option<ToolCallContext>,
226    },
227
228    /// A tool execution has finished.
229    ToolExecutionEnd {
230        /// Identifier of the completed tool call.
231        tool_call_id: String,
232        /// Name of the tool.
233        tool_name: String,
234        /// Intent trace — a concise description of what this tool call did.
235        /// `None` for tools without intent tracing.
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        intent: Option<String>,
238        /// The tool result payload.
239        result: oxi_ai::ToolResult,
240        /// Whether the tool execution resulted in an error.
241        is_error: bool,
242    },
243
244    // ── Streaming tool-call events ───────────────────────────────────
245    /// Partial tool-call arguments streamed by the LLM while it is still
246    /// constructing a tool call. Emitted between the provider's
247    /// `ToolCallStart` and `ToolCallEnd`, before [`AgentEvent::ToolExecutionStart`].
248    ///
249    /// Each `args_delta` is a raw JSON fragment (not valid JSON on its own) —
250    /// downstream consumers accumulate per `tool_call_id`.
251    ToolCallDelta {
252        /// Tool call identifier (matches the id later carried by
253        /// `ToolExecutionStart`).
254        tool_call_id: String,
255        /// Raw JSON argument fragment from the LLM stream.
256        args_delta: String,
257    },
258
259    // ── Legacy events (kept for backward compatibility) ──────────
260    /// Legacy: agent started processing a prompt.
261    #[serde(rename = "start")]
262    Start {
263        /// The user prompt that triggered the run.
264        prompt: String,
265    },
266
267    /// Agent is waiting for the first response token.
268    Thinking,
269
270    /// Incremental thinking / reasoning text from the model.
271    ThinkingDelta {
272        /// The reasoning text delta.
273        text: String,
274    },
275
276    /// The model finished a reasoning/thinking span and is about to produce
277    /// the answer (or begin another content block). Signal-only (no payload).
278    ///
279    /// Emitted when the provider reports `ThinkingEnd`. For models that
280    /// interleave reasoning and text (Claude 4, o-series) this may fire more
281    /// than once per turn — once per thinking span.
282    ThinkingEnd,
283
284    /// A chunk of generated text from the model.
285    TextChunk {
286        /// The text delta to append.
287        text: String,
288    },
289
290    /// The model requested a tool call.
291    ToolCall {
292        /// The tool call descriptor from the provider.
293        tool_call: oxi_ai::ToolCall,
294    },
295
296    /// A tool execution has started.
297    ToolStart {
298        /// Identifier of the tool call.
299        tool_call_id: String,
300        /// Name of the tool being invoked.
301        tool_name: String,
302        /// JSON arguments for the tool call.
303        #[serde(default)]
304        arguments: serde_json::Value,
305    },
306
307    /// Progress update from a running tool.
308    ToolProgress {
309        /// Identifier of the tool call.
310        tool_call_id: String,
311        /// Human-readable progress message.
312        message: String,
313    },
314
315    /// A tool execution has completed.
316    ToolComplete {
317        /// The tool result payload.
318        result: oxi_ai::ToolResult,
319    },
320
321    /// A tool execution failed.
322    ToolError {
323        /// Identifier of the failed tool call.
324        tool_call_id: String,
325        /// Error description.
326        error: String,
327    },
328
329    /// The agent produced a final response.
330    Complete {
331        /// Full response text.
332        content: String,
333        /// Stop reason string (e.g. `"EndTurn"`).
334        stop_reason: String,
335    },
336
337    /// An error occurred during agent execution.
338    Error {
339        /// Human-readable error message.
340        message: String,
341        /// Optional session identifier.
342        session_id: Option<String>,
343    },
344
345    /// Agent loop iteration counter update.
346    Iteration {
347        /// Current iteration number.
348        number: usize,
349    },
350
351    /// Token usage report for a completed turn.
352    Usage {
353        /// Number of prompt / input tokens consumed.
354        input_tokens: usize,
355        /// Number of completion / output tokens produced.
356        output_tokens: usize,
357    },
358
359    /// Context compaction lifecycle event.
360    Compaction {
361        /// The underlying compaction event detail.
362        event: CompactionEvent,
363    },
364
365    /// The agent is retrying after a transient error.
366    Retry {
367        /// Current retry attempt (1-based).
368        attempt: usize,
369        /// Maximum number of retries allowed.
370        max_retries: usize,
371        /// Seconds until the next attempt.
372        retry_after_secs: u64,
373        /// Why the previous attempt failed.
374        reason: String,
375        /// Optional session identifier.
376        session_id: Option<String>,
377    },
378
379    /// A TTSR rule violation was detected during streaming.
380    /// The stream was aborted and a system reminder will be injected.
381    TtsrInterrupt {
382        /// Name of the violated rule.
383        rule_name: String,
384        /// Session identifier for logging.
385        session_id: Option<String>,
386    },
387    /// The agent run was cancelled by the caller.
388    Cancelled,
389
390    /// A partial response delivered mid-stream (useful for UI rendering).
391    PartialResponse {
392        /// Accumulated response content so far.
393        content: String,
394    },
395
396    // ── Auto-retry events ─────────────────────────────────────────
397    /// An automatic retry attempt is starting.
398    AutoRetryStart {
399        /// Current retry attempt (1-based).
400        attempt: usize,
401        /// Total retry attempts that will be made.
402        max_attempts: usize,
403        /// Milliseconds before this attempt is sent.
404        delay_ms: u64,
405        /// The error that triggered the retry.
406        error_message: String,
407    },
408
409    /// An automatic retry attempt has concluded.
410    AutoRetryEnd {
411        /// Whether the retry succeeded.
412        success: bool,
413        /// Which attempt this was (1-based).
414        attempt: usize,
415        /// Final error if the retry failed, `None` on success.
416        final_error: Option<String>,
417    },
418
419    // ── Loop-specific steering events ─────────────────────────────
420    /// A system-level steering message injected into the conversation.
421    SteeringMessage {
422        /// The steering message to add to the context.
423        message: oxi_ai::Message,
424    },
425
426    /// A follow-up message appended to continue the conversation.
427    FollowUpMessage {
428        /// The follow-up message.
429        message: oxi_ai::Message,
430    },
431
432    // ── Approval events ────────────────────────────────────────────
433    /// A tool call requires human approval.
434    ApprovalRequired {
435        /// Tool call identifier.
436        tool_call_id: String,
437        /// Name of the tool requiring approval.
438        tool_name: String,
439        /// Arguments passed to the tool.
440        args: serde_json::Value,
441        /// Why approval is needed.
442        reason: String,
443        /// Session identifier for correlation.
444        #[serde(default, skip_serializing_if = "Option::is_none")]
445        session_id: Option<String>,
446    },
447    /// Result of an approval request.
448    ApprovalResult {
449        /// Tool call identifier this result corresponds to.
450        tool_call_id: String,
451        /// Whether the tool call was approved.
452        approved: bool,
453        /// Optional reason from the approver.
454        #[serde(default, skip_serializing_if = "Option::is_none")]
455        reason: Option<String>,
456    },
457
458    // ── Soft requirement events ─────────────────────────────────────
459    /// A soft-required tool was not called on the first turn.
460    /// The loop injects a reminder steering message.
461    SoftRequirementReminder {
462        /// Tool that should have been called.
463        tool_name: String,
464        /// Reason why the tool is needed.
465        reason: String,
466        /// Session identifier for correlation.
467        #[serde(default, skip_serializing_if = "Option::is_none")]
468        session_id: Option<String>,
469    },
470    /// A soft-required tool was not called after multiple turns.
471    /// Escalation — stronger action may be needed.
472    SoftRequirementEscalation {
473        /// Tool that should have been called.
474        tool_name: String,
475        /// Reason why the tool is needed.
476        reason: String,
477        /// Session identifier for correlation.
478        #[serde(default, skip_serializing_if = "Option::is_none")]
479        session_id: Option<String>,
480    },
481
482    // ── Harmony leak event ──────────────────────────────────────────
483    /// GPT-5 Harmony protocol leak detected in streaming output.
484    /// The stream was aborted to prevent the leaked content from
485    /// being persisted or acted upon.
486    HarmonyLeakDetected {
487        /// A preview of the leaked content (truncated, privacy-safe).
488        preview: String,
489        /// Session identifier for correlation.
490        #[serde(default, skip_serializing_if = "Option::is_none")]
491        session_id: Option<String>,
492    },
493}
494
495impl AgentEvent {
496    /// Returns `true` if this event represents the end of the agent lifecycle.
497    pub fn is_terminal(&self) -> bool {
498        matches!(self, AgentEvent::AgentEnd { .. })
499    }
500
501    /// Returns the snake_case variant name of this event (useful for logging / serialization).
502    pub fn type_name(&self) -> &'static str {
503        match self {
504            AgentEvent::AgentStart { .. } => "agent_start",
505            AgentEvent::AgentEnd { .. } => "agent_end",
506            AgentEvent::TurnStart { .. } => "turn_start",
507            AgentEvent::TurnEnd { .. } => "turn_end",
508            AgentEvent::MessageStart { .. } => "message_start",
509            AgentEvent::MessageUpdate { .. } => "message_update",
510            AgentEvent::MessageEnd { .. } => "message_end",
511            AgentEvent::ToolExecutionStart { .. } => "tool_execution_start",
512            AgentEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
513            AgentEvent::ToolExecutionEnd { .. } => "tool_execution_end",
514            AgentEvent::ToolCallDelta { .. } => "tool_call_delta",
515            AgentEvent::Start { .. } => "start",
516            AgentEvent::Thinking => "thinking",
517            AgentEvent::ThinkingDelta { .. } => "thinking_delta",
518            AgentEvent::ThinkingEnd => "thinking_end",
519            AgentEvent::TextChunk { .. } => "text_chunk",
520            AgentEvent::ToolCall { .. } => "tool_call",
521            AgentEvent::ToolStart { .. } => "tool_start",
522            AgentEvent::ToolProgress { .. } => "tool_progress",
523            AgentEvent::ToolComplete { .. } => "tool_complete",
524            AgentEvent::ToolError { .. } => "tool_error",
525            AgentEvent::Complete { .. } => "complete",
526            AgentEvent::Error { .. } => "error",
527            AgentEvent::Iteration { .. } => "iteration",
528            AgentEvent::Usage { .. } => "usage",
529            AgentEvent::Compaction { .. } => "compaction",
530            AgentEvent::Retry { .. } => "retry",
531            AgentEvent::TtsrInterrupt { .. } => "ttsr_interrupt",
532            AgentEvent::Cancelled => "cancelled",
533            AgentEvent::PartialResponse { .. } => "partial_response",
534            AgentEvent::AutoRetryStart { .. } => "auto_retry_start",
535            AgentEvent::AutoRetryEnd { .. } => "auto_retry_end",
536            AgentEvent::SteeringMessage { .. } => "steering_message",
537            AgentEvent::FollowUpMessage { .. } => "follow_up_message",
538            AgentEvent::ApprovalRequired { .. } => "approval_required",
539            AgentEvent::ApprovalResult { .. } => "approval_result",
540            AgentEvent::SoftRequirementReminder { .. } => "soft_requirement_reminder",
541            AgentEvent::SoftRequirementEscalation { .. } => "soft_requirement_escalation",
542            AgentEvent::HarmonyLeakDetected { .. } => "harmony_leak_detected",
543        }
544    }
545}