1use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::sync::Arc;
12
13use crate::stream::{AssistantStreamChunk, ToolSchema};
14use crate::tool::ToolResult;
15use crate::types::{
16 AgentMessage, AssistantBlock, RunIdentity, ToolResultBlock, UserBlock, UserContent,
17};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum AgentEvent {
30 AgentStart,
32
33 RunIdentified { identity: RunIdentity },
44
45 AgentEnd { messages: Vec<AgentMessage> },
49
50 TurnStart,
53
54 TurnEnd {
57 message: AgentMessage,
58 tool_results: Vec<AgentMessage>,
59 },
60
61 MessageStart { message: AgentMessage },
65
66 MessageUpdate {
68 partial: AgentMessage,
69 chunk: AssistantStreamChunk,
70 },
71
72 MessageEnd { message: AgentMessage },
74
75 ToolExecutionStart {
79 tool_call_id: String,
80 tool_name: String,
81 args: Value,
82 },
83
84 ToolExecutionUpdate {
87 tool_call_id: String,
88 tool_name: String,
89 partial: ToolResult,
90 },
91
92 ToolExecutionEnd {
94 tool_call_id: String,
95 tool_name: String,
96 result: ToolResult,
97 is_error: bool,
98 },
99
100 OutputTokensEscalation {
105 attempt: u32,
107 prev_cap: u32,
109 new_cap: u32,
111 },
112
113 ContextTransformApplied {
120 iteration: usize,
123 plugin: &'static str,
125 before: Vec<AgentMessage>,
127 after: Vec<AgentMessage>,
129 },
130
131 ToolGateApplied {
137 iteration: usize,
139 plugin: &'static str,
141 allow: Option<Vec<String>>,
145 },
146
147 ToolGateConflictResolved {
151 iteration: usize,
153 plugins: Vec<String>,
155 chosen_plugin: Option<String>,
157 allow: Vec<String>,
159 reason: String,
161 },
162
163 ProviderRequestPrepared {
170 iteration: usize,
172 model_id: Option<String>,
176 system_prompt: String,
179 messages: Vec<AgentMessage>,
181 tools: Vec<ToolSchema>,
183 temperature: Option<f32>,
186 max_output_tokens: Option<u32>,
188 },
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct ProviderRequestSummary {
199 pub iteration: usize,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub model_id: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub temperature: Option<f32>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub max_output_tokens: Option<u32>,
206 pub system_prompt_bytes: usize,
207 pub system_prompt_chars: usize,
208 pub message_count: usize,
209 pub message_counts: ProviderMessageCounts,
210 pub content_counts: ProviderContentCounts,
211 pub tool_count: usize,
212 pub tool_names: Vec<String>,
213 pub tool_schema_bytes: usize,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub last_message_role: Option<String>,
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
219pub struct ProviderMessageCounts {
220 pub system: usize,
221 pub user: usize,
222 pub assistant: usize,
223 pub tool_result: usize,
224 pub custom: usize,
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
228pub struct ProviderContentCounts {
229 pub system_message_bytes: usize,
230 pub user_text_blocks: usize,
231 pub user_text_bytes: usize,
232 pub user_image_blocks: usize,
233 pub user_image_with_media_type: usize,
234 pub assistant_text_blocks: usize,
235 pub assistant_text_bytes: usize,
236 pub assistant_thinking_blocks: usize,
237 pub assistant_thinking_bytes: usize,
238 pub assistant_reasoning_blocks: usize,
239 pub assistant_reasoning_bytes: usize,
240 pub assistant_reasoning_detail_blocks: usize,
241 pub assistant_reasoning_detail_bytes: usize,
242 pub assistant_tool_call_blocks: usize,
243 pub assistant_error_messages: usize,
244 pub tool_result_text_blocks: usize,
245 pub tool_result_text_bytes: usize,
246 pub tool_result_image_blocks: usize,
247 pub tool_result_error_messages: usize,
248 pub custom_payload_bytes: usize,
249}
250
251impl ProviderRequestSummary {
252 #[allow(clippy::too_many_arguments)]
255 pub fn from_parts(
256 iteration: usize,
257 model_id: Option<&str>,
258 temperature: Option<f32>,
259 max_output_tokens: Option<u32>,
260 system_prompt: &str,
261 messages: &[AgentMessage],
262 tools: &[ToolSchema],
263 ) -> Self {
264 let mut message_counts = ProviderMessageCounts::default();
265 let mut content_counts = ProviderContentCounts::default();
266
267 for message in messages {
268 match message {
269 AgentMessage::System { content, .. } => {
270 message_counts.system += 1;
271 content_counts.system_message_bytes += content.len();
272 }
273 AgentMessage::User { content, .. } => {
274 message_counts.user += 1;
275 count_user_content(content, &mut content_counts);
276 }
277 AgentMessage::Assistant {
278 content,
279 error_message,
280 ..
281 } => {
282 message_counts.assistant += 1;
283 if error_message.is_some() {
284 content_counts.assistant_error_messages += 1;
285 }
286 count_assistant_content(content, &mut content_counts);
287 }
288 AgentMessage::ToolResult {
289 content, is_error, ..
290 } => {
291 message_counts.tool_result += 1;
292 if *is_error {
293 content_counts.tool_result_error_messages += 1;
294 }
295 count_tool_result_content(content, &mut content_counts);
296 }
297 AgentMessage::Custom { payload, .. } => {
298 message_counts.custom += 1;
299 content_counts.custom_payload_bytes += json_size(payload);
300 }
301 }
302 }
303
304 let tool_names = tools
305 .iter()
306 .map(|tool| tool.name.clone())
307 .collect::<Vec<_>>();
308 let tool_schema_bytes = tools.iter().map(tool_schema_size).sum();
309
310 Self {
311 iteration,
312 model_id: model_id
313 .map(str::trim)
314 .filter(|id| !id.is_empty())
315 .map(str::to_string),
316 temperature,
317 max_output_tokens,
318 system_prompt_bytes: system_prompt.len(),
319 system_prompt_chars: system_prompt.chars().count(),
320 message_count: messages.len(),
321 message_counts,
322 content_counts,
323 tool_count: tools.len(),
324 tool_names,
325 tool_schema_bytes,
326 last_message_role: messages.last().map(message_role).map(str::to_string),
327 }
328 }
329}
330
331fn count_user_content(content: &UserContent, counts: &mut ProviderContentCounts) {
332 match content {
333 UserContent::Text(text) => {
334 counts.user_text_blocks += 1;
335 counts.user_text_bytes += text.len();
336 }
337 UserContent::Blocks(blocks) => {
338 for block in blocks {
339 match block {
340 UserBlock::Text(text) => {
341 counts.user_text_blocks += 1;
342 counts.user_text_bytes += text.text.len();
343 }
344 UserBlock::Image(image) => {
345 counts.user_image_blocks += 1;
346 if image.media_type.is_some() {
347 counts.user_image_with_media_type += 1;
348 }
349 }
350 }
351 }
352 }
353 }
354}
355
356fn count_assistant_content(
357 content: &crate::types::AssistantContent,
358 counts: &mut ProviderContentCounts,
359) {
360 for block in &content.blocks {
361 match block {
362 AssistantBlock::Text(text) => {
363 counts.assistant_text_blocks += 1;
364 counts.assistant_text_bytes += text.text.len();
365 }
366 AssistantBlock::Thinking(text) => {
367 counts.assistant_thinking_blocks += 1;
368 counts.assistant_thinking_bytes += text.text.len();
369 }
370 AssistantBlock::Reasoning(text) => {
371 counts.assistant_reasoning_blocks += 1;
372 counts.assistant_reasoning_bytes += text.text.len();
373 }
374 AssistantBlock::ReasoningDetails(details) => {
375 counts.assistant_reasoning_detail_blocks += 1;
376 counts.assistant_reasoning_detail_bytes += json_size(&details.details);
377 }
378 AssistantBlock::ToolCall(_) => {
379 counts.assistant_tool_call_blocks += 1;
380 }
381 }
382 }
383}
384
385fn count_tool_result_content(
386 content: &crate::types::ToolResultContent,
387 counts: &mut ProviderContentCounts,
388) {
389 for block in &content.blocks {
390 match block {
391 ToolResultBlock::Text(text) => {
392 counts.tool_result_text_blocks += 1;
393 counts.tool_result_text_bytes += text.text.len();
394 }
395 ToolResultBlock::Image(_) => {
396 counts.tool_result_image_blocks += 1;
397 }
398 }
399 }
400}
401
402fn message_role(message: &AgentMessage) -> &'static str {
403 match message {
404 AgentMessage::System { .. } => "system",
405 AgentMessage::User { .. } => "user",
406 AgentMessage::Assistant { .. } => "assistant",
407 AgentMessage::ToolResult { .. } => "tool_result",
408 AgentMessage::Custom { .. } => "custom",
409 }
410}
411
412fn tool_schema_size(tool: &ToolSchema) -> usize {
413 tool.name.len()
414 + tool.description.len()
415 + serde_json::to_vec(&tool.parameters)
416 .map(|bytes| bytes.len())
417 .unwrap_or(0)
418}
419
420fn json_size(value: &impl Serialize) -> usize {
421 serde_json::to_vec(value)
422 .map(|bytes| bytes.len())
423 .unwrap_or(0)
424}
425
426#[async_trait]
433pub trait EventSink: Send + Sync {
434 async fn emit(&self, event: AgentEvent);
435}
436
437pub struct NoopSink;
440
441#[async_trait]
442impl EventSink for NoopSink {
443 async fn emit(&self, _event: AgentEvent) {}
444}
445
446pub struct ChannelSink {
452 tx: tokio::sync::mpsc::UnboundedSender<AgentEvent>,
453}
454
455impl ChannelSink {
456 pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<AgentEvent>) {
457 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
458 (Self { tx }, rx)
459 }
460}
461
462#[async_trait]
463impl EventSink for ChannelSink {
464 async fn emit(&self, event: AgentEvent) {
465 if self.tx.send(event).is_err() {
466 }
468 }
469}
470
471pub struct FanOutSink {
475 sinks: Vec<Arc<dyn EventSink>>,
476}
477
478impl FanOutSink {
479 pub fn new(sinks: Vec<Arc<dyn EventSink>>) -> Self {
480 Self { sinks }
481 }
482}
483
484#[async_trait]
485impl EventSink for FanOutSink {
486 async fn emit(&self, event: AgentEvent) {
487 for sink in &self.sinks {
488 sink.emit(event.clone()).await;
489 }
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[tokio::test]
498 async fn channel_sink_forwards_events() {
499 let (sink, mut rx) = ChannelSink::new();
500 sink.emit(AgentEvent::AgentStart).await;
501 sink.emit(AgentEvent::TurnStart).await;
502 drop(sink);
503
504 let mut received = Vec::new();
505 while let Some(e) = rx.recv().await {
506 received.push(e);
507 }
508 assert_eq!(received.len(), 2);
509 assert!(matches!(received[0], AgentEvent::AgentStart));
510 assert!(matches!(received[1], AgentEvent::TurnStart));
511 }
512
513 #[tokio::test]
514 async fn fan_out_sink_replicates() {
515 let (a, mut a_rx) = ChannelSink::new();
516 let (b, mut b_rx) = ChannelSink::new();
517 let fanout = FanOutSink::new(vec![Arc::new(a), Arc::new(b)]);
518 fanout.emit(AgentEvent::AgentStart).await;
519 drop(fanout);
520
521 assert!(matches!(a_rx.recv().await, Some(AgentEvent::AgentStart)));
522 assert!(matches!(b_rx.recv().await, Some(AgentEvent::AgentStart)));
523 }
524
525 #[test]
526 fn provider_request_summary_counts_shape_without_text() {
527 let messages = vec![
528 AgentMessage::User {
529 content: UserContent::Blocks(vec![
530 UserBlock::Text(crate::types::TextContent {
531 text: "secret user request".into(),
532 }),
533 UserBlock::Image(crate::types::ImageContent {
534 source: "data:image/png;base64,secret".into(),
535 media_type: Some("image/png".into()),
536 alt: Some("screenshot".into()),
537 }),
538 ]),
539 timestamp: None,
540 },
541 AgentMessage::Assistant {
542 content: crate::types::AssistantContent {
543 blocks: vec![
544 AssistantBlock::Thinking(crate::types::TextContent {
545 text: "private scratch".into(),
546 }),
547 AssistantBlock::ToolCall(crate::tool::ToolCall {
548 id: "call-1".into(),
549 name: "web_search".into(),
550 arguments: serde_json::json!({"q": "secret"}),
551 }),
552 ],
553 },
554 stop_reason: crate::types::StopReason::ToolUse,
555 error_message: None,
556 timestamp: None,
557 usage: None,
558 },
559 AgentMessage::ToolResult {
560 tool_call_id: "call-1".into(),
561 tool_name: "web_search".into(),
562 content: crate::types::ToolResultContent::text("secret result"),
563 is_error: false,
564 narration: None,
565 details: None,
566 timestamp: None,
567 },
568 ];
569 let tools = vec![ToolSchema {
570 name: "web_search".into(),
571 description: "Search the web".into(),
572 parameters: serde_json::json!({"type": "object", "properties": {"q": {"type": "string"}}}),
573 }];
574
575 let summary = ProviderRequestSummary::from_parts(
576 2,
577 Some("google/gemini-3.1-flash-lite-preview"),
578 Some(0.2),
579 Some(4096),
580 "system prompt secret",
581 &messages,
582 &tools,
583 );
584
585 assert_eq!(summary.iteration, 2);
586 assert_eq!(
587 summary.model_id.as_deref(),
588 Some("google/gemini-3.1-flash-lite-preview")
589 );
590 assert_eq!(summary.message_counts.user, 1);
591 assert_eq!(summary.message_counts.assistant, 1);
592 assert_eq!(summary.message_counts.tool_result, 1);
593 assert_eq!(summary.content_counts.user_text_blocks, 1);
594 assert_eq!(
595 summary.content_counts.user_text_bytes,
596 "secret user request".len()
597 );
598 assert_eq!(summary.content_counts.user_image_blocks, 1);
599 assert_eq!(summary.content_counts.assistant_thinking_blocks, 1);
600 assert_eq!(summary.content_counts.assistant_tool_call_blocks, 1);
601 assert_eq!(summary.content_counts.tool_result_text_blocks, 1);
602 assert_eq!(summary.tool_names, vec!["web_search"]);
603 assert_eq!(summary.last_message_role.as_deref(), Some("tool_result"));
604
605 let serialized = serde_json::to_string(&summary).unwrap();
606 assert!(!serialized.contains("secret user request"));
607 assert!(!serialized.contains("private scratch"));
608 assert!(!serialized.contains("secret result"));
609 assert!(!serialized.contains("data:image"));
610 }
611}