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