1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::core::{MessageRole, ToolCall, ToolResponse};
5use crate::execution::ContextBudget;
6use crate::hooks::InlineHookRequest;
7
8#[derive(Debug, Serialize, Deserialize, Clone, Default)]
10pub struct RunUsage {
11 pub total_tokens: u32,
13 pub input_tokens: u32,
14 pub output_tokens: u32,
15 #[serde(default)]
17 pub cached_tokens: u32,
18 pub estimated_tokens: u32,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub model: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub cost_usd: Option<f64>,
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone)]
29#[serde(rename_all = "snake_case")]
30pub struct AgentEvent {
31 pub timestamp: chrono::DateTime<chrono::Utc>,
32 pub thread_id: String,
33 pub run_id: String,
34 pub event: AgentEventType,
35 pub task_id: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub parent_task_id: Option<String>,
41 pub agent_id: String,
42 #[serde(default)]
44 pub user_id: Option<String>,
45 #[serde(default)]
47 pub identifier_id: Option<String>,
48 #[serde(default)]
50 pub workspace_id: Option<String>,
51 #[serde(default)]
53 pub channel_id: Option<String>,
54}
55
56impl AgentEvent {
57 pub fn from_task_event(task_event: &crate::TaskEvent, thread_id: &str) -> Self {
59 Self {
60 event: task_event.event.clone(),
61 agent_id: String::new(),
62 timestamp: chrono::DateTime::from_timestamp_millis(task_event.created_at)
63 .unwrap_or_default(),
64 thread_id: thread_id.to_string(),
65 run_id: String::new(),
66 task_id: String::new(),
67 parent_task_id: None,
68 user_id: None,
69 identifier_id: None,
70 workspace_id: None,
71 channel_id: None,
72 }
73 }
74}
75
76#[derive(Debug, Serialize, Deserialize, Clone)]
85pub struct AgentEventEnvelope {
86 #[serde(flatten)]
89 pub event: AgentEventType,
90 pub agent_id: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub parent_task_id: Option<String>,
99}
100
101impl AgentEventEnvelope {
102 pub fn from_event(event: &AgentEvent) -> Self {
104 Self {
105 event: event.event.clone(),
106 agent_id: event.agent_id.clone(),
107 parent_task_id: event.parent_task_id.clone(),
108 }
109 }
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone)]
113#[serde(rename_all = "snake_case", tag = "type")]
114#[allow(clippy::large_enum_variant)]
115pub enum AgentEventType {
116 DiagnosticLog {
118 message: String,
119 },
120
121 RunStarted {},
123 RunFinished {
124 success: bool,
125 total_steps: usize,
126 failed_steps: usize,
127 usage: Option<RunUsage>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 context_budget: Option<ContextBudget>,
131 },
132 RunError {
133 message: String,
134 code: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 usage: Option<RunUsage>,
138 },
139 PlanStarted {
140 initial_plan: bool,
141 },
142 PlanFinished {
143 total_steps: usize,
144 },
145 PlanPruned {
146 removed_steps: Vec<String>,
147 },
148 StepStarted {
150 step_id: String,
151 step_index: usize,
152 },
153 StepCompleted {
154 step_id: String,
155 success: bool,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 context_budget: Option<ContextBudget>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 usage: Option<RunUsage>,
161 },
162
163 ReflectStarted {},
165 ReflectFinished {
166 should_retry: bool,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 reason: Option<String>,
169 },
170
171 ToolExecutionStart {
173 step_id: String,
174 tool_call_id: String,
175 tool_call_name: String,
176 input: Value,
177 },
178 ToolExecutionEnd {
179 step_id: String,
180 tool_call_id: String,
181 tool_call_name: String,
182 success: bool,
183 },
184
185 TextMessageStart {
187 message_id: String,
188 step_id: String,
189 role: MessageRole,
190 is_final: Option<bool>,
191 },
192 TextMessageContent {
193 message_id: String,
194 step_id: String,
195 delta: String,
196 stripped_content: Option<Vec<(usize, String)>>,
197 },
198 TextMessageEnd {
199 message_id: String,
200 step_id: String,
201 },
202
203 ToolCalls {
205 step_id: String,
206 parent_message_id: Option<String>,
207 tool_calls: Vec<ToolCall>,
208 },
209 ToolResults {
210 step_id: String,
211 parent_message_id: Option<String>,
212 results: Vec<ToolResponse>,
213 },
214
215 AgentHandover {
217 from_agent: String,
218 to_agent: String,
219 reason: Option<String>,
220 },
221
222 LiveView {
226 view_id: String,
228 url: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 title: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 display_mode: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 width: Option<u32>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 height: Option<u32>,
242 },
243
244 BrowserSessionStarted {
245 session_id: String,
246 viewer_url: Option<String>,
247 stream_url: Option<String>,
248 },
249
250 InlineHookRequested {
251 request: InlineHookRequest,
252 },
253
254 TodosUpdated {
256 formatted_todos: String,
257 action: String,
258 todo_count: usize,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 changes: Vec<crate::todos::TodoChange>,
267 },
268
269 ContextCompaction {
271 tier: CompactionTier,
272 tokens_before: usize,
273 tokens_after: usize,
274 entries_affected: usize,
275 context_limit: usize,
276 usage_ratio: f64,
277 summary: Option<String>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 reinjected_skills: Vec<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 context_budget: Option<ContextBudget>,
283 #[serde(default = "default_compaction_source")]
286 source: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 duration_ms: Option<u64>,
290 },
291
292 CompactionRequested {
295 source: String,
297 },
298
299 ContextBudgetUpdate {
301 budget: ContextBudget,
302 is_warning: bool,
303 is_critical: bool,
304 },
305
306 ChannelReply {
310 reply: crate::channel_commands::ChannelReply,
311 },
312}
313
314fn default_compaction_source() -> String {
315 "auto".to_string()
316}
317
318#[derive(
320 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema,
321)]
322#[serde(rename_all = "snake_case")]
323pub enum CompactionTier {
324 Trim,
326 Summarize,
328 Reset,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
337pub struct CompactTaskResponse {
338 pub compacted: bool,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub reason: Option<String>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub tier: Option<CompactionTier>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub tokens_before: Option<usize>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub tokens_after: Option<usize>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub entries_affected: Option<usize>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub usage_ratio: Option<f64>,
354}
355
356impl CompactTaskResponse {
357 pub fn nothing_to_compact(reason: impl Into<String>) -> Self {
359 Self {
360 compacted: false,
361 reason: Some(reason.into()),
362 tier: None,
363 tokens_before: None,
364 tokens_after: None,
365 entries_affected: None,
366 usage_ratio: None,
367 }
368 }
369
370 pub fn reduction_percent(&self) -> f64 {
373 match (self.tokens_before, self.tokens_after) {
374 (Some(before), Some(after)) if before > 0 => {
375 (1.0 - after as f64 / before as f64) * 100.0
376 }
377 _ => 0.0,
378 }
379 }
380}
381
382#[cfg(test)]
383mod channel_reply_event_tests {
384 use super::*;
385 use crate::channel_commands::{ChannelButton, ChannelReply};
386
387 #[test]
388 fn channel_reply_event_round_trips() {
389 let ev = AgentEventType::ChannelReply {
390 reply: ChannelReply {
391 text: "Tap to continue:".into(),
392 buttons: vec![vec![ChannelButton::WebApp {
393 label: "Continue".into(),
394 url: "https://a.app/lesson/1".into(),
395 }]],
396 },
397 };
398 let v = serde_json::to_value(&ev).unwrap();
399 let back: AgentEventType = serde_json::from_value(v).unwrap();
400 assert!(matches!(back, AgentEventType::ChannelReply { .. }));
401 }
402
403 #[test]
404 fn channel_reply_envelope_round_trips() {
405 let ev = AgentEventType::ChannelReply {
406 reply: ChannelReply {
407 text: "Tap to continue:".into(),
408 buttons: vec![vec![ChannelButton::Callback {
409 label: "Continue".into(),
410 callback_data: "wf:open:x".into(),
411 }]],
412 },
413 };
414 let agent_event = AgentEvent::new(ev);
415 let envelope = AgentEventEnvelope::from_event(&agent_event);
416 let v = serde_json::to_value(&envelope).unwrap();
417 let back: AgentEventEnvelope = serde_json::from_value(v).expect("envelope deserialize");
418 assert!(matches!(back.event, AgentEventType::ChannelReply { .. }));
419 }
420}
421
422impl AgentEvent {
423 pub fn new(event: AgentEventType) -> Self {
424 Self {
425 timestamp: chrono::Utc::now(),
426 thread_id: uuid::Uuid::new_v4().to_string(),
427 run_id: uuid::Uuid::new_v4().to_string(),
428 event,
429 task_id: uuid::Uuid::new_v4().to_string(),
430 parent_task_id: None,
431 agent_id: "default".to_string(),
432 user_id: None,
433 identifier_id: None,
434 workspace_id: None,
435 channel_id: None,
436 }
437 }
438
439 pub fn with_context(
440 event: AgentEventType,
441 thread_id: String,
442 run_id: String,
443 task_id: String,
444 agent_id: String,
445 ) -> Self {
446 Self {
447 timestamp: chrono::Utc::now(),
448 thread_id,
449 run_id,
450 task_id,
451 parent_task_id: None,
452 event,
453 agent_id,
454 user_id: None,
455 identifier_id: None,
456 workspace_id: None,
457 channel_id: None,
458 }
459 }
460}