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}