Skip to main content

harn_vm/agent_events/
agent.rs

1use serde::{Deserialize, Serialize};
2
3use crate::composition::{CompositionChildCall, CompositionChildResult, CompositionRunEnvelope};
4use crate::llm::receipts::ToolCallReceipt;
5use crate::orchestration::{HandoffArtifact, MutationSessionRecord};
6use crate::tool_annotations::ToolKind;
7
8use super::tool::{ToolCallErrorCategory, ToolCallStatus, ToolExecutor};
9use super::worker::{FsWatchEvent, WorkerEvent};
10
11/// Events emitted by the agent loop. Some variants map 1:1 to ACP
12/// `sessionUpdate` variants; Harn-specific lifecycle events ride on the
13/// extension stream.
14#[derive(Clone, Debug, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum AgentEvent {
17    AgentMessageChunk {
18        session_id: String,
19        content: String,
20    },
21    AgentThoughtChunk {
22        session_id: String,
23        content: String,
24    },
25    UserMessage {
26        session_id: String,
27        message_id: String,
28        content: Vec<serde_json::Value>,
29    },
30    ToolCall {
31        session_id: String,
32        tool_call_id: String,
33        tool_name: String,
34        kind: Option<ToolKind>,
35        status: ToolCallStatus,
36        raw_input: serde_json::Value,
37        /// Set to `Some(true)` by the streaming candidate detector
38        /// (harn#692) when this event represents a tool-call shape
39        /// detected in the model's in-flight assistant text but whose
40        /// arguments have not finished parsing yet. Clients can render a
41        /// spinner / placeholder while the model writes the body. The
42        /// detector follows up with a `ToolCallUpdate { parsing: false,
43        /// .. }` carrying either `status: pending` (promoted) or
44        /// `status: failed` with `error_category: parse_aborted`.
45        /// `None` (the default) means "this is a normal post-parse tool
46        /// call, no candidate phase was active" so the on-disk shape
47        /// stays compatible with replays recorded before this field
48        /// existed.
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        parsing: Option<bool>,
51        /// Mutation-session audit context active when the tool was
52        /// dispatched (see harn#699). Hosts use it to group every tool
53        /// emission belonging to the same write-capable session.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        audit: Option<MutationSessionRecord>,
56    },
57    ToolCallUpdate {
58        session_id: String,
59        tool_call_id: String,
60        tool_name: String,
61        status: ToolCallStatus,
62        raw_output: Option<serde_json::Value>,
63        error: Option<String>,
64        /// Wall-clock milliseconds from the parse-to-execution boundary
65        /// to the terminal `Completed`/`Failed` update. Includes the
66        /// time spent in any wrapping orchestration logic (loop checks,
67        /// post-tool hooks, microcompaction). Populated only on the
68        /// terminal update — `None` on intermediate `Pending` /
69        /// `InProgress` updates so clients can ignore the field until
70        /// it shows up.
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        duration_ms: Option<u64>,
73        /// Milliseconds spent in the actual host/builtin/MCP dispatch
74        /// call only (the inner `dispatch_tool_execution` window).
75        /// Populated only on the terminal update; `None` otherwise.
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        execution_duration_ms: Option<u64>,
78        /// Structured classification of the failure (when `status` is
79        /// `Failed`). Paired with `error` so clients can render each
80        /// category distinctly without parsing free-form strings. Always
81        /// `None` for non-Failed updates and serialized as
82        /// `errorCategory` in the ACP wire format.
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        error_category: Option<ToolCallErrorCategory>,
85        /// Where the tool actually ran. `None` only for events emitted
86        /// from sites that pre-date the dispatch decision (e.g. the
87        /// pending → in-progress transition the loop emits before the
88        /// dispatcher picks a backend).
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        executor: Option<ToolExecutor>,
91        /// Companion to `ToolCall.parsing` (harn#692). The streaming
92        /// candidate detector emits the *terminal* candidate event as a
93        /// `ToolCallUpdate` with `parsing: Some(false)` to retract the
94        /// in-flight `parsing: true` chip — either by promoting the
95        /// candidate (`status: pending`, populated `raw_output: None`,
96        /// `error: None`) or aborting it (`status: failed`,
97        /// `error_category: parse_aborted`). `None` means this update is
98        /// not part of a candidate-phase transition.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        parsing: Option<bool>,
101        /// Best-effort partial parse of the streamed tool-call arguments.
102        /// Populated by the SSE transport on `Pending` updates as the
103        /// model streams `input_json_delta` (Anthropic) or
104        /// `tool_calls[].function.arguments` deltas (OpenAI). `None` on
105        /// terminal updates and on emissions from non-streaming paths
106        /// (#693). When the partial bytes are not yet parseable as JSON
107        /// the transport falls back to `raw_input_partial`.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        raw_input: Option<serde_json::Value>,
110        /// Raw concatenated bytes of the streamed tool-call arguments
111        /// when a permissive parse failed (#693). Mutually exclusive
112        /// with `raw_input`: clients render whichever is present.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        raw_input_partial: Option<String>,
115        /// Mutation-session audit context for the tool call. Carries the
116        /// same payload as on the paired `ToolCall` event so a host
117        /// processing a single update doesn't have to correlate against
118        /// the prior pending event.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        audit: Option<MutationSessionRecord>,
121    },
122    Plan {
123        session_id: String,
124        plan: serde_json::Value,
125    },
126    ProgressReported {
127        session_id: String,
128        message: Option<String>,
129        entries: serde_json::Value,
130        replace: bool,
131        metadata: serde_json::Value,
132    },
133    /// Fires at the top of every model round-trip inside an
134    /// `agent_loop` invocation. Maps to the `iteration_start` steering
135    /// seam. Not the same as ACP's outer `prompt_turn` boundary; an
136    /// `agent_turn`/`prompt_turn` cycle contains many of these.
137    IterationStart {
138        session_id: String,
139        iteration: usize,
140        /// Configured provider for the impending LLM call. Empty when the
141        /// caller defers provider selection to the routing layer.
142        /// Surfaces here so observers can show "about to call X/Y" before
143        /// the call returns — previously this only landed in the
144        /// transcript after the response, leaving live pulse-check
145        /// consumers without a model attribution for in-flight iterations.
146        #[serde(default, skip_serializing_if = "String::is_empty")]
147        provider: String,
148        /// Configured model id. Same semantics as `provider`.
149        #[serde(default, skip_serializing_if = "String::is_empty")]
150        model: String,
151    },
152    /// Fires at the bottom of every model round-trip, after tool
153    /// dispatch (or after the dispatch was skipped). Sibling of
154    /// `IterationStart`.
155    IterationEnd {
156        session_id: String,
157        iteration: usize,
158        /// Free-form dict carrying the post-call snapshot. Stable keys
159        /// emitted by the agent loop: `tool_count`, `text`, plus the
160        /// LLM-result projection — `provider`, `model`, `response_ms`,
161        /// `input_tokens`, `output_tokens`, `thinking_chars`. Hosts that
162        /// surface latency/cost panes key off these without re-parsing
163        /// the transcript JSONL.
164        iteration_info: serde_json::Value,
165    },
166    /// Emitted when a first-class agent session is explicitly closed by
167    /// `agent_session_close`. This gives event-log consumers a typed
168    /// terminal marker even when no final model turn runs.
169    SessionClosed {
170        session_id: String,
171        reason: String,
172        status: String,
173        metadata: serde_json::Value,
174    },
175    /// Emitted when `agent_session_reanchor` swaps the primary workspace
176    /// anchor (#2218). Hosts use this to drive cross-project handoff UX.
177    /// Carries the previous and current anchors so consumers can diff
178    /// without re-fetching session state.
179    AnchorChanged {
180        session_id: String,
181        previous: Option<serde_json::Value>,
182        current: serde_json::Value,
183        carry_transcript: bool,
184        compacted: bool,
185        reason: Option<String>,
186    },
187    JudgeDecision {
188        session_id: String,
189        iteration: usize,
190        verdict: String,
191        reasoning: String,
192        next_step: Option<String>,
193        judge_duration_ms: u64,
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        trigger: Option<String>,
196    },
197    /// Per-step critique decision emitted by `agent_step_judge`.
198    /// Sibling of [`JudgeDecision`] but fired BEFORE tool dispatch on
199    /// every assistant turn (when configured), not just at completion.
200    /// `on_veto` carries the configured remediation shape
201    /// (`"replace"` or `"retain"`); `cost_usd` is best-effort from the
202    /// stdlib economics estimator and may be 0 when pricing is unknown.
203    /// `skipped` marks configured short-circuits that did not call the
204    /// judge model.
205    StepJudgeDecision {
206        session_id: String,
207        iteration: usize,
208        verdict: String,
209        reasoning: String,
210        critique: String,
211        confidence: f64,
212        judge_duration_ms: u64,
213        vetoed: bool,
214        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215        skipped: bool,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        reason: Option<String>,
218        on_veto: String,
219        input_tokens: u64,
220        output_tokens: u64,
221        cost_usd: f64,
222        provider: String,
223        model: String,
224    },
225    /// Deterministic pre-dispatch critique emitted by the structural
226    /// validator middleware. Fires before any LLM-backed judge so hosts
227    /// can distinguish "$0 structural retry" from semantic critique.
228    StructuralValidatorDecision {
229        session_id: String,
230        iteration: usize,
231        rule: String,
232        diagnostic: String,
233        recommended_action: String,
234        vetoed: bool,
235        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
236        skipped: bool,
237        #[serde(default, skip_serializing_if = "Option::is_none")]
238        reason: Option<String>,
239        on_failure: String,
240        attempts: usize,
241        max_attempts: usize,
242    },
243    ScopeClassifierVerdict {
244        session_id: String,
245        iteration: usize,
246        label: String,
247        original_label: String,
248        confidence: f64,
249        confidence_threshold: f64,
250        evidence: String,
251        skip_main_turn: bool,
252        #[serde(default, skip_serializing_if = "Option::is_none")]
253        classifier_kind: Option<String>,
254        #[serde(default, skip_serializing_if = "Option::is_none")]
255        model: Option<String>,
256        #[serde(default, skip_serializing_if = "Option::is_none")]
257        error: Option<String>,
258    },
259    TypedCheckpoint {
260        session_id: String,
261        checkpoint: serde_json::Value,
262    },
263    FeedbackInjected {
264        session_id: String,
265        kind: String,
266        content: String,
267    },
268    /// Emitted when the agent loop exhausts `max_iterations` without any
269    /// explicit break condition firing. Distinct from a natural "done" or
270    /// a "stuck" nudge-exhaustion: this is strictly a budget cap.
271    BudgetExhausted {
272        session_id: String,
273        max_iterations: usize,
274        #[serde(default, skip_serializing_if = "Option::is_none")]
275        kind: Option<String>,
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        cost_usd: Option<f64>,
278        #[serde(default, skip_serializing_if = "Option::is_none")]
279        wall_clock_ms: Option<u64>,
280    },
281    /// Emitted when a loop-level budget circuit breaker trips after N
282    /// consecutive retryable failures. `paused_for_ms` is the mock-time-aware
283    /// backoff already honored before the terminal budget event.
284    BudgetCircuitBreaker {
285        session_id: String,
286        kind: String,
287        consecutive_count: usize,
288        paused_for_ms: u64,
289    },
290    /// Emitted when the loop breaks because consecutive text-only turns
291    /// hit `max_nudges`. Parity with `BudgetExhausted` / `IterationEnd` for
292    /// hosts that key off agent-terminal events.
293    LoopStuck {
294        session_id: String,
295        max_nudges: usize,
296        last_iteration: usize,
297        tail_excerpt: String,
298    },
299    /// Emitted when the daemon idle-wait loop trips its watchdog because
300    /// every configured wake source returned `None` for N consecutive
301    /// attempts. Exists so a broken daemon doesn't hang the session
302    /// silently.
303    DaemonWatchdogTripped {
304        session_id: String,
305        attempts: usize,
306        elapsed_ms: u64,
307    },
308    /// Emitted when a skill is activated. Carries the match reason so
309    /// replayers can reconstruct *why* a given skill took effect at
310    /// this iteration.
311    SkillActivated {
312        session_id: String,
313        skill_name: String,
314        iteration: usize,
315        reason: String,
316    },
317    /// Emitted when a previously-active skill is deactivated because
318    /// the reassess phase no longer matches it.
319    SkillDeactivated {
320        session_id: String,
321        skill_name: String,
322        iteration: usize,
323    },
324    /// Emitted once per activation when the skill's `allowed_tools` filter
325    /// narrows the effective tool surface exposed to the model.
326    SkillScopeTools {
327        session_id: String,
328        skill_name: String,
329        allowed_tools: Vec<String>,
330    },
331    /// Emitted when the agent loop ratchets the model-visible tool
332    /// surface narrower after observing recent tool-call usage. Unlike
333    /// `SkillScopeTools`, this is session-local and can only remove
334    /// tools from the currently-effective surface.
335    SkillNarrow {
336        session_id: String,
337        reason: String,
338        removed_tools: Vec<String>,
339        remaining_tools: Vec<String>,
340    },
341    /// Emitted when a `tool_search` query is issued by the model. Carries
342    /// the raw query args, the configured strategy, and a `mode` tag
343    /// distinguishing the client-executed fallback (`"client"`) from
344    /// provider-native paths (`"anthropic"` / `"openai"`). Mirrors the
345    /// transcript event shape so hosts can render a search-in-progress
346    /// chip in real time — the replay path walks the transcript after
347    /// the turn, which is too late for live UX.
348    ToolSearchQuery {
349        session_id: String,
350        tool_use_id: String,
351        name: String,
352        query: serde_json::Value,
353        strategy: String,
354        mode: String,
355    },
356    /// Emitted when `tool_search` resolves — carries the list of tool
357    /// names newly promoted into the model's effective surface for the
358    /// next turn. Pair-emitted with `ToolSearchQuery` on every search.
359    ToolSearchResult {
360        session_id: String,
361        tool_use_id: String,
362        promoted: Vec<String>,
363        strategy: String,
364        mode: String,
365    },
366    TranscriptCompacted {
367        session_id: String,
368        mode: String,
369        reason: String,
370        strategy: String,
371        archived_messages: usize,
372        estimated_tokens_before: usize,
373        estimated_tokens_after: usize,
374        snapshot_asset_id: Option<String>,
375        #[serde(default, skip_serializing_if = "Option::is_none")]
376        instruction_mode: Option<String>,
377        #[serde(default, skip_serializing_if = "Option::is_none")]
378        instruction_source: Option<String>,
379        #[serde(default, skip_serializing_if = "Option::is_none")]
380        compaction_policy: Option<serde_json::Value>,
381    },
382    /// Emitted whenever `transcript_project` derives a model-visible
383    /// prefix from the immutable raw transcript. Hosts that render a
384    /// side-by-side raw/projected view subscribe to this — the typed
385    /// payload mirrors the metadata on the persisted
386    /// `transcript.projection` transcript event so clients don't have to
387    /// re-parse the transcript to sync UI state.
388    TranscriptProjected {
389        session_id: String,
390        policy: String,
391        reason: String,
392        prefix_hash: String,
393        kept_count: usize,
394        dropped_count: usize,
395        provider_safety_blocked: bool,
396    },
397    /// Emitted when a pending `system_reminder` is rendered into the
398    /// next provider request. ACP clients show these in a reminder lane
399    /// instead of mixing them into assistant text chunks.
400    ReminderEmitted {
401        session_id: String,
402        reminder_id: String,
403        tags: Vec<String>,
404        body: String,
405        role_hint: String,
406        rendered_role: String,
407        source: String,
408        ttl_turns: Option<i64>,
409    },
410    Handoff {
411        session_id: String,
412        artifact_id: String,
413        handoff: Box<HandoffArtifact>,
414    },
415    FsWatch {
416        session_id: String,
417        subscription_id: String,
418        events: Vec<FsWatchEvent>,
419    },
420    /// Emitted when hostlib staged filesystem state changes for a session.
421    /// The ACP adapter maps this to the existing `progress` extension so
422    /// clients can update rollup-diff badges without waiting for a prompt
423    /// turn boundary.
424    StagedWritesPending {
425        session_id: String,
426        pending_count: usize,
427        total_bytes: u64,
428    },
429    /// Per-call outcome of `hostlib_fs_safe_text_patch`. Hosts subscribe to
430    /// this to roll up stale-base / hunk-conflict rates and average
431    /// hunks-per-patch without scraping result dicts out of pipeline logs.
432    /// Fired from both the staged-overlay and direct-disk code paths so
433    /// the rollup is comprehensive.
434    SafeTextPatchResult {
435        session_id: String,
436        path: String,
437        result: String,
438        hunks_count: usize,
439        bytes_written: u64,
440        #[serde(default, skip_serializing_if = "Option::is_none")]
441        failed_hunk_index: Option<usize>,
442    },
443    /// ACP control-plane arbitration outcome. Emitted for accepted,
444    /// idempotent, and rejected controls so replay/audit consumers can show
445    /// who acted and why a late or unauthorized action lost.
446    ControlOutcome {
447        session_id: String,
448        control_id: String,
449        method: String,
450        outcome: String,
451        status: String,
452        actor: serde_json::Value,
453        target: serde_json::Value,
454        #[serde(default, skip_serializing_if = "Option::is_none")]
455        reason: Option<String>,
456        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
457        metadata: serde_json::Value,
458    },
459    /// Lifecycle update for a delegated/background worker. Carries the
460    /// canonical typed `event` variant alongside the worker's current
461    /// `status` string and the structured `metadata` payload that
462    /// `worker_bridge_metadata` builds (task, mode, timing, child
463    /// run/snapshot paths, audit-session, etc.). The `audit` field is
464    /// the same `MutationSessionRecord` JSON serialization carried on
465    /// the bridge wire so ACP/A2A consumers don't need to re-derive it.
466    ///
467    /// One-to-one with the bridge-side `worker_update` session-update
468    /// notification: ACP and A2A adapters subscribe to this variant
469    /// and translate it into their respective wire formats. The
470    /// `session_id` is the parent agent session that owns the worker
471    /// (i.e. the session whose VM spawned the worker), so a single
472    /// host stays subscribed to the same sink for both message and
473    /// worker traffic.
474    WorkerUpdate {
475        session_id: String,
476        worker_id: String,
477        worker_name: String,
478        worker_task: String,
479        worker_mode: String,
480        event: WorkerEvent,
481        status: String,
482        metadata: serde_json::Value,
483        audit: Option<serde_json::Value>,
484    },
485    /// A human-in-the-loop primitive (`ask_user`, `request_approval`,
486    /// `dual_control`, `escalate`) has just suspended the script and is
487    /// waiting on a response. Hosts that bridge the VM onto a remote
488    /// transport (ACP, A2A) translate this into a "paused / awaiting
489    /// input" wire signal so the client knows the task isn't stuck —
490    /// it's blocked on the human side. Pair-emitted with `HitlResolved`
491    /// when the waitpoint completes/cancels/times out.
492    HitlRequested {
493        session_id: String,
494        request_id: String,
495        kind: String,
496        payload: serde_json::Value,
497    },
498    /// Companion to `HitlRequested`: the waitpoint has resolved (either
499    /// a response arrived, the deadline elapsed, or the request was
500    /// cancelled). `outcome` is one of `"answered"`, `"timeout"`,
501    /// `"cancelled"`. Hosts use this to flip task state back to
502    /// `working` after an `input-required` pause.
503    HitlResolved {
504        session_id: String,
505        request_id: String,
506        kind: String,
507        outcome: String,
508    },
509    /// Emitted by the agent loop's adaptive iteration budget /
510    /// `loop_control` policy when a budget extension or early stop fires.
511    /// Generic enough to cover both shapes — `action` distinguishes them.
512    /// Carries the iteration the decision applied to, the previous /
513    /// resulting iteration limit, the policy reason string, and (for
514    /// stops) the loop status.
515    LoopControlDecision {
516        session_id: String,
517        iteration: usize,
518        action: String,
519        old_limit: usize,
520        new_limit: usize,
521        reason: String,
522        status: String,
523    },
524    /// Emitted when `agent_loop` detects adjacent repeated tool calls with
525    /// identical arguments. The warning payload avoids raw arguments by
526    /// default and carries digests so hosts can correlate repeats without
527    /// exposing potentially sensitive tool inputs.
528    AgentLoopStallWarning {
529        session_id: String,
530        warning: serde_json::Value,
531    },
532    /// Emitted when a concrete provider/model pair lacks a catalog
533    /// recommendation for a capability and the runtime chooses a fallback.
534    CapabilityGap {
535        session_id: String,
536        level: String,
537        capability: String,
538        provider: String,
539        model: String,
540        fallback_tool_format: String,
541        #[serde(default, skip_serializing_if = "Option::is_none")]
542        requested_tool_format: Option<String>,
543        message: String,
544    },
545    /// Emitted when a caller explicitly forces a tool format that
546    /// differs from the capability catalog's recommendation or known
547    /// native/text parity guidance.
548    ToolFormatOverride {
549        session_id: String,
550        provider: String,
551        model: String,
552        requested_format: String,
553        recommended_format: String,
554        catalog_parity: String,
555        #[serde(default, skip_serializing_if = "Option::is_none")]
556        override_reason: Option<String>,
557    },
558    /// Emitted when a `tool_caller` middleware (see std/llm/tool_middleware)
559    /// attaches structured audit metadata to a tool call — typically a
560    /// user-facing `summary`, a `description`, an ACP-style `kind`, an MCP
561    /// `hints` block, a `consent` decision, the per-layer `layers` log, or
562    /// free-form `metadata` keys (A2A-style extension slot).
563    ///
564    /// One-to-one with the underlying tool-call: hosts can join on
565    /// `tool_call_id` to render middleware-attached chips alongside the
566    /// existing `ToolCall` / `ToolCallUpdate` stream. The `audit` payload
567    /// is intentionally free-form JSON so middleware can carry whatever
568    /// shape the harness author chooses without needing protocol-level
569    /// changes per new middleware. When present, `receipt` carries the
570    /// stable typed, privacy-preserving record hosts can persist or mirror.
571    ToolCallAudit {
572        session_id: String,
573        tool_call_id: String,
574        tool_name: String,
575        audit: serde_json::Value,
576        #[serde(default, skip_serializing_if = "Option::is_none")]
577        receipt: Option<ToolCallReceipt>,
578    },
579    /// Emitted by `std/cache::with_cache` (both the generic and LLM
580    /// forms) when a cached lookup returns a hit. Carries the
581    /// content-addressed key, the backend that served the value, and a
582    /// `metrics` block with the cost-moat receipts the persona value
583    /// ledger (harn-cloud#58) and crystallization receipts read:
584    /// `model_calls_avoided`, plus `tokens_saved` / `latency_saved_ms`
585    /// when the cached envelope carried `usage` / `latency_ms`.
586    CacheHit {
587        session_id: String,
588        key: String,
589        backend: String,
590        namespace: String,
591        payload: serde_json::Value,
592    },
593    /// Paired with `CacheHit`. Emitted on the miss path when the
594    /// fresh result is stored. `payload.metrics.compute_ms` carries
595    /// the wall-clock cost of the underlying computation, which
596    /// callers can feed back as `estimate.latency_saved_ms` on the
597    /// next hit.
598    CacheMiss {
599        session_id: String,
600        key: String,
601        backend: String,
602        namespace: String,
603        payload: serde_json::Value,
604    },
605    /// A language-neutral tool-composition snippet has started. The envelope
606    /// identifies the snippet and binding manifest hashes plus the side-effect
607    /// ceiling requested for the whole parent run.
608    CompositionStart {
609        session_id: String,
610        run: CompositionRunEnvelope,
611    },
612    /// A composition snippet is dispatching a child binding call. The child
613    /// remains visible as its own operation with annotations and policy context
614    /// instead of being hidden inside the parent composition blob.
615    CompositionChildCall {
616        session_id: String,
617        call: CompositionChildCall,
618    },
619    /// A child binding operation emitted a status/result update.
620    CompositionChildResult {
621        session_id: String,
622        result: CompositionChildResult,
623    },
624    /// A composition run finished successfully and carries stdout/stderr,
625    /// artifacts, and the structured result in the terminal envelope.
626    CompositionFinish {
627        session_id: String,
628        run: CompositionRunEnvelope,
629    },
630    /// A composition run failed before producing a successful terminal result.
631    /// The terminal envelope carries the failure category and optional error.
632    CompositionError {
633        session_id: String,
634        run: CompositionRunEnvelope,
635    },
636    /// Emitted once per `__agent_loop_checkpoint(...)` pass. The single
637    /// named seam through which the agent loop drains queued bridge
638    /// injections and inbox feedback. Hosts use it to debug "did the
639    /// loop check for steering at the expected boundary" without having
640    /// to grep the loop body for inline drain calls.
641    ///
642    /// `kind` is one of the documented seam names: `iteration_start`,
643    /// `pre_tool_dispatch`, `post_tool_dispatch`, `iteration_end`,
644    /// `pre_compact`, `post_compact`, `daemon_idle_pre`,
645    /// `daemon_idle_post`, `loop_exit`. `delivered` is the count of
646    /// bridge injections drained at this seam (inbox drains are
647    /// reported separately under `inbox_delivered`). `dispatch_skipped`
648    /// is true only when an `interrupt_immediate` injection arrived at
649    /// `pre_tool_dispatch` and the pending tool batch was skipped.
650    LoopCheckpoint {
651        session_id: String,
652        iteration: usize,
653        kind: String,
654        delivered: usize,
655        #[serde(default, skip_serializing_if = "is_zero_usize")]
656        inbox_delivered: usize,
657        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
658        dispatch_skipped: bool,
659    },
660}
661
662fn is_zero_usize(value: &usize) -> bool {
663    *value == 0
664}
665
666impl AgentEvent {
667    pub fn session_id(&self) -> &str {
668        match self {
669            Self::AgentMessageChunk { session_id, .. }
670            | Self::AgentThoughtChunk { session_id, .. }
671            | Self::UserMessage { session_id, .. }
672            | Self::ToolCall { session_id, .. }
673            | Self::ToolCallUpdate { session_id, .. }
674            | Self::Plan { session_id, .. }
675            | Self::ProgressReported { session_id, .. }
676            | Self::IterationStart { session_id, .. }
677            | Self::IterationEnd { session_id, .. }
678            | Self::SessionClosed { session_id, .. }
679            | Self::AnchorChanged { session_id, .. }
680            | Self::JudgeDecision { session_id, .. }
681            | Self::StepJudgeDecision { session_id, .. }
682            | Self::StructuralValidatorDecision { session_id, .. }
683            | Self::ScopeClassifierVerdict { session_id, .. }
684            | Self::TypedCheckpoint { session_id, .. }
685            | Self::FeedbackInjected { session_id, .. }
686            | Self::BudgetExhausted { session_id, .. }
687            | Self::BudgetCircuitBreaker { session_id, .. }
688            | Self::LoopStuck { session_id, .. }
689            | Self::DaemonWatchdogTripped { session_id, .. }
690            | Self::SkillActivated { session_id, .. }
691            | Self::SkillDeactivated { session_id, .. }
692            | Self::SkillScopeTools { session_id, .. }
693            | Self::SkillNarrow { session_id, .. }
694            | Self::ToolSearchQuery { session_id, .. }
695            | Self::ToolSearchResult { session_id, .. }
696            | Self::TranscriptCompacted { session_id, .. }
697            | Self::TranscriptProjected { session_id, .. }
698            | Self::ReminderEmitted { session_id, .. }
699            | Self::Handoff { session_id, .. }
700            | Self::FsWatch { session_id, .. }
701            | Self::StagedWritesPending { session_id, .. }
702            | Self::SafeTextPatchResult { session_id, .. }
703            | Self::ControlOutcome { session_id, .. }
704            | Self::WorkerUpdate { session_id, .. }
705            | Self::HitlRequested { session_id, .. }
706            | Self::HitlResolved { session_id, .. }
707            | Self::LoopControlDecision { session_id, .. }
708            | Self::AgentLoopStallWarning { session_id, .. }
709            | Self::CapabilityGap { session_id, .. }
710            | Self::ToolFormatOverride { session_id, .. }
711            | Self::ToolCallAudit { session_id, .. }
712            | Self::CacheHit { session_id, .. }
713            | Self::CacheMiss { session_id, .. }
714            | Self::CompositionStart { session_id, .. }
715            | Self::CompositionChildCall { session_id, .. }
716            | Self::CompositionChildResult { session_id, .. }
717            | Self::CompositionFinish { session_id, .. }
718            | Self::CompositionError { session_id, .. }
719            | Self::LoopCheckpoint { session_id, .. } => session_id,
720        }
721    }
722}