Skip to main content

clark_agent/
event.rs

1//! Typed events emitted by the loop.
2//!
3//! Single sink, single enum. Streaming consumers pattern-match on the
4//! event kind. Events are observation-only — they cannot change loop
5//! state. Plugins that need to mutate state use the dedicated capability
6//! traits in [`crate::plugin`].
7
8use 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/// All events the loop emits.
20///
21/// Lifecycle events (`AgentStart`, `AgentEnd`, `TurnStart`, `TurnEnd`)
22/// bracket the run. Message events (`MessageStart`, `MessageUpdate`,
23/// `MessageEnd`) bracket each individual message. Tool events
24/// (`ToolExecutionStart`, `ToolExecutionUpdate`, `ToolExecutionEnd`) describe
25/// calls that reach real execution. Preflight failures emit only
26/// `ToolExecutionEnd`, carrying their typed error result.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum AgentEvent {
30    /// First event in a run. Emitted once.
31    AgentStart,
32
33    /// Run identity, emitted immediately after [`AgentEvent::AgentStart`]
34    /// when the context carries a [`RunIdentity`]. Trajectory sinks key
35    /// every subsequent event of the same run on `identity.run_id`;
36    /// child runs surface their `parent_run_id` so the spawn tree
37    /// rebuilds without external bookkeeping.
38    ///
39    /// Existing observers that don't care about identity ignore this
40    /// variant (every match arm in the tree already has a wildcard
41    /// fallback). Plugins and sinks that want identity pattern-match
42    /// directly.
43    RunIdentified { identity: RunIdentity },
44
45    /// Last event in a run. Carries the messages produced *during this run*
46    /// (not the full transcript). Listeners that want the full transcript
47    /// should fold prior messages into a state of their own.
48    AgentEnd { messages: Vec<AgentMessage> },
49
50    /// Bracket: a new turn begins. A turn is one assistant response plus
51    /// any tool calls/results it spawned.
52    TurnStart,
53
54    /// Bracket: a turn ends. Carries the assistant message and the tool
55    /// results for that turn (empty if the model didn't call any tools).
56    TurnEnd {
57        message: AgentMessage,
58        tool_results: Vec<AgentMessage>,
59    },
60
61    /// A message has been added to the transcript (user, assistant, or
62    /// tool result). For assistant messages, this fires before streaming
63    /// begins; subsequent `MessageUpdate` events carry deltas.
64    MessageStart { message: AgentMessage },
65
66    /// Streaming delta for the in-progress assistant message.
67    MessageUpdate {
68        partial: AgentMessage,
69        chunk: AssistantStreamChunk,
70    },
71
72    /// The message has been fully assembled (final content, stop reason).
73    MessageEnd { message: AgentMessage },
74
75    /// A tool execution has begun. Emitted after registry lookup, argument
76    /// validation, and all `BeforeToolCall` gates allow the call, immediately
77    /// before the tool implementation is invoked.
78    ToolExecutionStart {
79        tool_call_id: String,
80        tool_name: String,
81        args: Value,
82    },
83
84    /// Partial progress from a long-running tool. The tool calls
85    /// `update.send(...)` to surface intermediate state without ending.
86    ToolExecutionUpdate {
87        tool_call_id: String,
88        tool_name: String,
89        partial: ToolResult,
90    },
91
92    /// A tool execution has finished.
93    ToolExecutionEnd {
94        tool_call_id: String,
95        tool_name: String,
96        result: ToolResult,
97        is_error: bool,
98    },
99
100    /// The loop discarded a truncated assistant turn and re-streamed
101    /// with a higher `max_output_tokens` cap. Emitted once per
102    /// retry attempt; multiple events for the same turn signal the
103    /// recovery walked the configured ladder. See
104    /// [`crate::config::MaxTokensRecovery`].
105    OutputTokensEscalation {
106        /// 1-indexed retry counter within the current turn.
107        attempt: u8,
108        /// Cap that produced the truncated turn we're discarding.
109        prev_cap: u32,
110        /// Cap we're re-streaming with.
111        new_cap: u32,
112    },
113
114    /// A `ContextTransform` plugin ran on this turn's transcript.
115    /// Emitted once per active transform per turn, in registration
116    /// order. Carries the full before/after message slices so observers
117    /// can reconstruct exactly which messages each transform removed,
118    /// added, or rewrote — the canonical answer to "which compaction
119    /// stripped that tool result we expected the model to still see?".
120    ContextTransformApplied {
121        /// Zero-indexed turn within the current run. Same semantics as
122        /// [`crate::plugin::TransformContext::iteration`].
123        iteration: usize,
124        /// `Plugin::name` of the transform that just ran.
125        plugin: &'static str,
126        /// Transcript handed to the transform.
127        before: Vec<AgentMessage>,
128        /// Transcript the transform returned.
129        after: Vec<AgentMessage>,
130    },
131
132    /// A `ToolGate` plugin contributed to this turn's allowlist.
133    /// Emitted once per gate per turn. Multiple gates compose by
134    /// intersection downstream; this event records the gate's own
135    /// decision before composition so observers can attribute the
136    /// final allowlist to specific plugins.
137    ToolGateApplied {
138        /// Zero-indexed turn within the current run.
139        iteration: usize,
140        /// `Plugin::name` of the gate.
141        plugin: &'static str,
142        /// `None` when the gate declined to constrain;
143        /// `Some(names)` when it returned an allowlist (sorted for
144        /// stable diffing).
145        allow: Option<Vec<String>>,
146    },
147
148    /// Multiple `ToolGate` plugins narrowed the same turn to disjoint
149    /// non-empty allowlists. The loop repaired the composition to avoid
150    /// advertising an empty tool catalog to the model.
151    ToolGateConflictResolved {
152        /// Zero-indexed turn within the current run.
153        iteration: usize,
154        /// Gate names that returned a non-empty allowlist.
155        plugins: Vec<String>,
156        /// Gate whose allowlist won the deterministic repair policy.
157        chosen_plugin: Option<String>,
158        /// Final repaired allowlist, sorted for stable diffing.
159        allow: Vec<String>,
160        /// Human-readable policy reason for trajectory/debug inspection.
161        reason: String,
162    },
163
164    /// Snapshot of the request the loop is about to send to the
165    /// provider on this turn, taken after every `ContextTransform`
166    /// has run and every `ToolGate` has filtered. This is the typed
167    /// view of "what the model sees" — wire-format conversion
168    /// (provider-specific shapes) happens downstream inside the
169    /// `StreamFn`. Emitted once per turn, just before the stream call.
170    ProviderRequestPrepared {
171        /// Zero-indexed turn within the current run.
172        iteration: usize,
173        /// Model identifier the host associated with this loop, when
174        /// known. Provider transports still own their wire conversion,
175        /// so this is observability metadata only.
176        model_id: Option<String>,
177        /// System prompt for this turn. May include ephemeral system
178        /// reminders injected by `ContextTransform` plugins.
179        system_prompt: String,
180        /// Full message history the loop is about to send.
181        messages: Vec<AgentMessage>,
182        /// Tool schemas advertised this turn, post-`ToolGate` filtering.
183        tools: Vec<ToolSchema>,
184        /// Sampling temperature forwarded to the provider stream, when
185        /// configured.
186        temperature: Option<f32>,
187        /// Resolved per-turn output cap.
188        max_output_tokens: Option<u32>,
189    },
190}
191
192/// Redacted, durable metadata for one provider request.
193///
194/// This deliberately excludes free-form prompt, message, image URL,
195/// tool-description, and schema content. It keeps the dimensions needed
196/// to debug "what shape did we send?" without leaking user text or
197/// hidden/private reasoning.
198#[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    // Mirrors the provider-request fields directly; grouping would only hide
254    // the shape this summary is meant to expose.
255    #[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/// Sink the loop publishes events to.
428///
429/// Implementations buffer, log, forward, persist, etc. The loop awaits
430/// `emit` so backpressure flows naturally. Failures inside the sink must
431/// not propagate out of the loop — observers that fail are logged and
432/// skipped.
433#[async_trait]
434pub trait EventSink: Send + Sync {
435    async fn emit(&self, event: AgentEvent);
436}
437
438/// Trivial discard sink, useful when the caller only cares about the
439/// final result of `run`.
440pub struct NoopSink;
441
442#[async_trait]
443impl EventSink for NoopSink {
444    async fn emit(&self, _event: AgentEvent) {}
445}
446
447/// Sink that forwards events into a `tokio::sync::mpsc::UnboundedSender`.
448///
449/// The loop is the producer; the consumer drains the channel and renders /
450/// persists / forwards each event. Drops events silently when the receiver
451/// is gone.
452pub 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            // Receiver shutdown is an accepted best-effort sink outcome.
468        }
469    }
470}
471
472/// Composite sink that fans events out to multiple downstream sinks in
473/// declaration order. Useful for mixing a logger, a UI forwarder, and a
474/// persistence layer.
475pub 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}