Skip to main content

oxicode_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// ── Stream delta types ────────────────────────────────────────────────────
98
99/// Typed incremental delta for [`AgentEvent::MessageUpdate`].
100///
101/// Replaces the former `Option<String>` which conflated text and thinking
102/// deltas. Consumers can now distinguish what kind of content changed.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub enum StreamDelta {
105    /// Regular text output from the assistant.
106    Text(String),
107    /// Thinking/reasoning content from a reasoning model.
108    Thinking(String),
109    /// Non-text structural change (e.g. a tool call was finalized).
110    /// Consumers should re-render from `message` rather than appending.
111    Sync,
112}
113
114impl StreamDelta {
115    /// Returns the text content if this is a `Text` or `Thinking` delta.
116    pub fn as_text(&self) -> Option<&str> {
117        match self {
118            StreamDelta::Text(s) | StreamDelta::Thinking(s) => Some(s),
119            StreamDelta::Sync => None,
120        }
121    }
122
123    /// Returns `true` if this delta carries text content (Text or Thinking).
124    pub fn has_text(&self) -> bool {
125        !matches!(self, StreamDelta::Sync)
126    }
127}
128
129/// Screenshot metadata attached to PageVisit context.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ScreenshotMeta {
132    /// PNG payload size in bytes.
133    pub bytes: usize,
134    /// Viewport width.
135    pub width: u32,
136    /// Capture duration in milliseconds.
137    pub duration_ms: u64,
138}
139
140/// Reason for visiting a page.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum VisitReason {
144    /// The agent specified the URL directly.
145    DirectNavigation,
146    /// Clicked a search result at the given position.
147    SearchResult {
148        /// 1-based position in search results.
149        position: usize,
150    },
151    /// Followed a link from another page.
152    LinkFollowed {
153        /// The URL the link was on.
154        from_url: String,
155    },
156}
157
158/// Events emitted during agent execution.
159///
160/// Events are tagged with `type` and serialized as camelCase for JSON consumers.
161/// This enum is `#[non_exhaustive]` — new variants may be added in future releases.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(tag = "type", rename_all = "camelCase")]
164#[non_exhaustive]
165pub enum AgentEvent {
166    // ── Lifecycle events ──────────────────────────────────────────────
167    /// Emitted when the agent begins processing a batch of prompts.
168    AgentStart {
169        /// The initial prompt messages sent to the agent.
170        prompts: Vec<oxicode_ai::Message>,
171        /// Optional session identifier for correlation.
172        session_id: Option<String>,
173    },
174
175    /// Emitted when the agent finishes all processing.
176    AgentEnd {
177        /// Final conversation messages.
178        messages: Vec<oxicode_ai::Message>,
179        /// Why the agent stopped (e.g. `"end_turn"`, `"tool_use"`).
180        stop_reason: Option<String>,
181        /// Optional session identifier for correlation.
182        session_id: Option<String>,
183    },
184
185    /// Emitted at the start of each agent loop turn.
186    TurnStart {
187        /// Zero-based turn index.
188        turn_number: u32,
189    },
190
191    /// Emitted when a turn completes, including the assistant reply and tool results.
192    TurnEnd {
193        /// Turn index that just completed.
194        turn_number: u32,
195        /// The assistant message produced this turn.
196        assistant_message: oxicode_ai::Message,
197        /// Tool results collected during this turn.
198        tool_results: Vec<oxicode_ai::ToolResultMessage>,
199    },
200
201    // ── Message events ────────────────────────────────────────────────
202    /// A new message has been created in the conversation.
203    MessageStart {
204        /// The message that started.
205        message: oxicode_ai::Message,
206    },
207
208    /// A message has been updated with new content.
209    MessageUpdate {
210        /// The message in its current state.
211        message: oxicode_ai::Message,
212        /// Incremental delta describing what changed.
213        delta: StreamDelta,
214    },
215
216    /// A message has been finalized.
217    MessageEnd {
218        /// The completed message.
219        message: oxicode_ai::Message,
220    },
221
222    // ── Todo session events ──────────────────────────────────────────
223    /// Emitted when the loop injects a stop-time incomplete-todo reminder
224    /// (the hidden user turn) so the TUI can commit a visible banner of
225    /// *why* the agent kept going.
226    TodoReminder {
227        /// The open (pending/in_progress) todo items being nudged.
228        open: Vec<crate::tools::todo::TodoItem>,
229        /// Which reminder attempt this is (1-based).
230        attempt: u32,
231        /// The configured max reminder count.
232        max: u32,
233    },
234
235    // ── Tool execution events ────────────────────────────────────────
236    /// A tool is about to be executed.
237    ToolExecutionStart {
238        /// Unique identifier for this tool call.
239        tool_call_id: String,
240        /// Name of the tool being invoked.
241        tool_name: String,
242        /// JSON arguments passed to the tool.
243        args: serde_json::Value,
244        /// Intent trace — a concise description of what this tool call does.
245        /// `None` for tools without intent tracing.
246        #[serde(default, skip_serializing_if = "Option::is_none")]
247        intent: Option<String>,
248        /// Semantic context inferred from tool name and arguments.
249        /// `None` for tools without a known context mapping.
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        context: Option<ToolCallContext>,
252    },
253
254    /// Partial progress from a running tool execution.
255    ToolExecutionUpdate {
256        /// Identifier of the tool call producing the update.
257        tool_call_id: String,
258        /// Name of the tool.
259        tool_name: String,
260        /// Partial result text so far.
261        partial_result: String,
262        /// Browser tab id that produced this progress (if the tool is
263        /// tab-aware). `None` for tools that don't have a tab concept,
264        /// or for older tool implementations that don't propagate tab ids.
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        tab_id: Option<uuid::Uuid>,
267        /// Semantic context inferred from tool name and arguments.
268        /// Carries structured information about what this update means.
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        context: Option<ToolCallContext>,
271    },
272
273    /// A tool execution has finished.
274    ToolExecutionEnd {
275        /// Identifier of the completed tool call.
276        tool_call_id: String,
277        /// Name of the tool.
278        tool_name: String,
279        /// Intent trace — a concise description of what this tool call did.
280        /// `None` for tools without intent tracing.
281        #[serde(default, skip_serializing_if = "Option::is_none")]
282        intent: Option<String>,
283        /// The tool result payload.
284        result: oxicode_ai::ToolResult,
285        /// Whether the tool execution resulted in an error.
286        is_error: bool,
287    },
288
289    // ── Streaming tool-call events ───────────────────────────────────
290    /// Partial tool-call arguments streamed by the LLM while it is still
291    /// constructing a tool call. Emitted between the provider's
292    /// `ToolCallStart` and `ToolCallEnd`, before [`AgentEvent::ToolExecutionStart`].
293    ///
294    /// Each `args_delta` is a raw JSON fragment (not valid JSON on its own) —
295    /// downstream consumers accumulate per `tool_call_id`.
296    ToolCallDelta {
297        /// Tool call identifier (matches the id later carried by
298        /// `ToolExecutionStart`).
299        tool_call_id: String,
300        /// Raw JSON argument fragment from the LLM stream.
301        args_delta: String,
302    },
303
304    // ── Legacy events (kept for backward compatibility) ──────────
305    /// Legacy: agent started processing a prompt.
306    #[serde(rename = "start")]
307    Start {
308        /// The user prompt that triggered the run.
309        prompt: String,
310    },
311
312    /// Agent is waiting for the first response token.
313    Thinking,
314
315    /// Incremental thinking / reasoning text from the model.
316    ThinkingDelta {
317        /// The reasoning text delta.
318        text: String,
319    },
320
321    /// The model finished a reasoning/thinking span and is about to produce
322    /// the answer (or begin another content block). Signal-only (no payload).
323    ///
324    /// Emitted when the provider reports `ThinkingEnd`. For models that
325    /// interleave reasoning and text (Claude 4, o-series) this may fire more
326    /// than once per turn — once per thinking span.
327    ThinkingEnd,
328
329    /// A chunk of generated text from the model.
330    TextChunk {
331        /// The text delta to append.
332        text: String,
333    },
334
335    /// The model requested a tool call.
336    ToolCall {
337        /// The tool call descriptor from the provider.
338        tool_call: oxicode_ai::ToolCall,
339    },
340
341    /// A tool execution has started.
342    ToolStart {
343        /// Identifier of the tool call.
344        tool_call_id: String,
345        /// Name of the tool being invoked.
346        tool_name: String,
347        /// JSON arguments for the tool call.
348        #[serde(default)]
349        arguments: serde_json::Value,
350    },
351
352    /// Progress update from a running tool.
353    ToolProgress {
354        /// Identifier of the tool call.
355        tool_call_id: String,
356        /// Human-readable progress message.
357        message: String,
358    },
359
360    /// A tool execution has completed.
361    ToolComplete {
362        /// The tool result payload.
363        result: oxicode_ai::ToolResult,
364    },
365
366    /// A tool execution failed.
367    ToolError {
368        /// Identifier of the failed tool call.
369        tool_call_id: String,
370        /// Error description.
371        error: String,
372    },
373
374    /// The agent produced a final response.
375    Complete {
376        /// Full response text.
377        content: String,
378        /// Stop reason string (e.g. `"EndTurn"`).
379        stop_reason: String,
380    },
381
382    /// An error occurred during agent execution.
383    Error {
384        /// Human-readable error message.
385        message: String,
386        /// Optional session identifier.
387        session_id: Option<String>,
388    },
389
390    /// Agent loop iteration counter update.
391    Iteration {
392        /// Current iteration number.
393        number: usize,
394    },
395
396    /// Token usage report for a completed turn.
397    Usage {
398        /// Number of prompt / input tokens consumed.
399        input_tokens: usize,
400        /// Number of completion / output tokens produced.
401        output_tokens: usize,
402    },
403
404    /// Context compaction lifecycle event.
405    Compaction {
406        /// The underlying compaction event detail.
407        event: CompactionEvent,
408    },
409
410    /// The agent is retrying after a transient error.
411    Retry {
412        /// Current retry attempt (1-based).
413        attempt: usize,
414        /// Maximum number of retries allowed.
415        max_retries: usize,
416        /// Seconds until the next attempt.
417        retry_after_secs: u64,
418        /// Why the previous attempt failed.
419        reason: String,
420        /// Optional session identifier.
421        session_id: Option<String>,
422    },
423
424    /// A TTSR rule violation was detected during streaming.
425    /// The stream was aborted and a system reminder will be injected.
426    TtsrInterrupt {
427        /// Name of the violated rule.
428        rule_name: String,
429        /// Session identifier for logging.
430        session_id: Option<String>,
431    },
432    /// The agent run was cancelled by the caller.
433    Cancelled,
434
435    /// A partial response delivered mid-stream (useful for UI rendering).
436    PartialResponse {
437        /// Accumulated response content so far.
438        content: String,
439    },
440
441    // ── Auto-retry events ─────────────────────────────────────────
442    /// An automatic retry attempt is starting.
443    AutoRetryStart {
444        /// Current retry attempt (1-based).
445        attempt: usize,
446        /// Total retry attempts that will be made.
447        max_attempts: usize,
448        /// Milliseconds before this attempt is sent.
449        delay_ms: u64,
450        /// The error that triggered the retry.
451        error_message: String,
452    },
453
454    /// An automatic retry attempt has concluded.
455    AutoRetryEnd {
456        /// Whether the retry succeeded.
457        success: bool,
458        /// Which attempt this was (1-based).
459        attempt: usize,
460        /// Final error if the retry failed, `None` on success.
461        final_error: Option<String>,
462    },
463
464    // ── Loop-specific steering events ─────────────────────────────
465    /// A system-level steering message injected into the conversation.
466    SteeringMessage {
467        /// The steering message to add to the context.
468        message: oxicode_ai::Message,
469    },
470
471    /// A follow-up message appended to continue the conversation.
472    FollowUpMessage {
473        /// The follow-up message.
474        message: oxicode_ai::Message,
475    },
476
477    // ── Approval events ────────────────────────────────────────────
478    /// A tool call requires human approval.
479    ApprovalRequired {
480        /// Tool call identifier.
481        tool_call_id: String,
482        /// Name of the tool requiring approval.
483        tool_name: String,
484        /// Arguments passed to the tool.
485        args: serde_json::Value,
486        /// Why approval is needed.
487        reason: String,
488        /// Session identifier for correlation.
489        #[serde(default, skip_serializing_if = "Option::is_none")]
490        session_id: Option<String>,
491    },
492    /// Result of an approval request.
493    ApprovalResult {
494        /// Tool call identifier this result corresponds to.
495        tool_call_id: String,
496        /// Whether the tool call was approved.
497        approved: bool,
498        /// Optional reason from the approver.
499        #[serde(default, skip_serializing_if = "Option::is_none")]
500        reason: Option<String>,
501    },
502
503    // ── Soft requirement events ─────────────────────────────────────
504    /// A soft-required tool was not called on the first turn.
505    /// The loop injects a reminder steering message.
506    SoftRequirementReminder {
507        /// Tool that should have been called.
508        tool_name: String,
509        /// Reason why the tool is needed.
510        reason: String,
511        /// Session identifier for correlation.
512        #[serde(default, skip_serializing_if = "Option::is_none")]
513        session_id: Option<String>,
514    },
515    /// A soft-required tool was not called after multiple turns.
516    /// Escalation — stronger action may be needed.
517    SoftRequirementEscalation {
518        /// Tool that should have been called.
519        tool_name: String,
520        /// Reason why the tool is needed.
521        reason: String,
522        /// Session identifier for correlation.
523        #[serde(default, skip_serializing_if = "Option::is_none")]
524        session_id: Option<String>,
525    },
526
527    // ── Harmony leak event ──────────────────────────────────────────
528    /// GPT-5 Harmony protocol leak detected in streaming output.
529    /// The stream was aborted to prevent the leaked content from
530    /// being persisted or acted upon.
531    HarmonyLeakDetected {
532        /// A preview of the leaked content (truncated, privacy-safe).
533        preview: String,
534        /// Session identifier for correlation.
535        #[serde(default, skip_serializing_if = "Option::is_none")]
536        session_id: Option<String>,
537    },
538}
539
540impl AgentEvent {
541    /// Returns `true` if this event represents the end of the agent lifecycle.
542    pub fn is_terminal(&self) -> bool {
543        matches!(self, AgentEvent::AgentEnd { .. })
544    }
545
546    /// Returns the snake_case variant name of this event (useful for logging / serialization).
547    pub fn type_name(&self) -> &'static str {
548        match self {
549            AgentEvent::AgentStart { .. } => "agent_start",
550            AgentEvent::AgentEnd { .. } => "agent_end",
551            AgentEvent::TurnStart { .. } => "turn_start",
552            AgentEvent::TurnEnd { .. } => "turn_end",
553            AgentEvent::MessageStart { .. } => "message_start",
554            AgentEvent::MessageUpdate { .. } => "message_update",
555            AgentEvent::MessageEnd { .. } => "message_end",
556            AgentEvent::ToolExecutionStart { .. } => "tool_execution_start",
557            AgentEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
558            AgentEvent::ToolExecutionEnd { .. } => "tool_execution_end",
559            AgentEvent::ToolCallDelta { .. } => "tool_call_delta",
560            AgentEvent::Start { .. } => "start",
561            AgentEvent::Thinking => "thinking",
562            AgentEvent::ThinkingDelta { .. } => "thinking_delta",
563            AgentEvent::ThinkingEnd => "thinking_end",
564            AgentEvent::TextChunk { .. } => "text_chunk",
565            AgentEvent::ToolCall { .. } => "tool_call",
566            AgentEvent::ToolStart { .. } => "tool_start",
567            AgentEvent::ToolProgress { .. } => "tool_progress",
568            AgentEvent::ToolComplete { .. } => "tool_complete",
569            AgentEvent::ToolError { .. } => "tool_error",
570            AgentEvent::Complete { .. } => "complete",
571            AgentEvent::Error { .. } => "error",
572            AgentEvent::Iteration { .. } => "iteration",
573            AgentEvent::Usage { .. } => "usage",
574            AgentEvent::Compaction { .. } => "compaction",
575            AgentEvent::Retry { .. } => "retry",
576            AgentEvent::TtsrInterrupt { .. } => "ttsr_interrupt",
577            AgentEvent::Cancelled => "cancelled",
578            AgentEvent::PartialResponse { .. } => "partial_response",
579            AgentEvent::AutoRetryStart { .. } => "auto_retry_start",
580            AgentEvent::AutoRetryEnd { .. } => "auto_retry_end",
581            AgentEvent::SteeringMessage { .. } => "steering_message",
582            AgentEvent::TodoReminder { .. } => "todo_reminder",
583            AgentEvent::FollowUpMessage { .. } => "follow_up_message",
584            AgentEvent::ApprovalRequired { .. } => "approval_required",
585            AgentEvent::ApprovalResult { .. } => "approval_result",
586            AgentEvent::SoftRequirementReminder { .. } => "soft_requirement_reminder",
587            AgentEvent::SoftRequirementEscalation { .. } => "soft_requirement_escalation",
588            AgentEvent::HarmonyLeakDetected { .. } => "harmony_leak_detected",
589        }
590    }
591}