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}