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::host_injection::{
9    AttachmentFlavor, AttachmentRendering, HostInjectionProvenance, InjectionDelivery,
10    SanitizationVerdict,
11};
12use super::tool::{ToolCallErrorCategory, ToolCallStatus, ToolExecutor, ToolMutationStatus};
13use super::worker::{FsWatchEvent, SubagentTerminalStatus, WorkerEvent};
14
15/// Reviewable summary of one path in the staged filesystem overlay.
16#[derive(Clone, Debug, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct StagedWriteSummary {
19    pub path: String,
20    pub kind: String,
21    pub byte_delta: i64,
22    pub snapshot_id: Option<String>,
23}
24
25/// The dependency/effect phase assigned to one model-proposed tool call.
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ToolBatchPhase {
29    Observation,
30    Mutation,
31    ProcessVerification,
32    Terminal,
33    ProviderNative,
34}
35
36/// What the dispatcher did with one model-proposed tool call.
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ToolBatchDisposition {
40    Executed,
41    Deferred,
42    SkippedAfterBlockingResult,
43}
44
45/// Whether this call is new or is a model re-proposal of a deferred call.
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ToolBatchProposalStatus {
49    New,
50    ReProposed,
51}
52
53/// Auditable decision for one call in a model-proposed tool batch.
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55pub struct ToolBatchDispositionReceipt {
56    pub schema: String,
57    pub batch_id: String,
58    pub source_batch_id: Option<String>,
59    pub call_index: usize,
60    pub tool_call_id: String,
61    pub tool_name: String,
62    pub phase: ToolBatchPhase,
63    pub selected_phase: ToolBatchPhase,
64    pub disposition: ToolBatchDisposition,
65    pub proposal_status: ToolBatchProposalStatus,
66    pub reason: String,
67    pub planned_at_ms: f64,
68    pub started_at_ms: Option<f64>,
69    pub finished_at_ms: Option<f64>,
70    pub duration_ms: Option<f64>,
71    pub blocking_tool_call_id: Option<String>,
72    pub blocking_tool_name: Option<String>,
73    pub blocking_mutation_status: Option<ToolMutationStatus>,
74}
75
76/// Events emitted by the agent loop. Some variants map 1:1 to ACP
77/// `sessionUpdate` variants; Harn-specific lifecycle events ride on the
78/// extension stream.
79#[derive(Clone, Debug, Serialize, Deserialize)]
80#[serde(tag = "type", rename_all = "snake_case")]
81pub enum AgentEvent {
82    AgentMessageChunk {
83        session_id: String,
84        content: String,
85    },
86    AgentThoughtChunk {
87        session_id: String,
88        content: String,
89    },
90    UserMessage {
91        session_id: String,
92        message_id: String,
93        content: Vec<serde_json::Value>,
94    },
95    ToolCall {
96        session_id: String,
97        tool_call_id: String,
98        tool_name: String,
99        kind: Option<ToolKind>,
100        status: ToolCallStatus,
101        raw_input: serde_json::Value,
102        /// Set to `Some(true)` by the streaming candidate detector
103        /// (harn#692) when this event represents a tool-call shape
104        /// detected in the model's in-flight assistant text but whose
105        /// arguments have not finished parsing yet. Clients can render a
106        /// spinner / placeholder while the model writes the body. The
107        /// detector follows up with a `ToolCallUpdate { parsing: false,
108        /// .. }` carrying either `status: pending` (promoted) or
109        /// `status: failed` with `error_category: parse_aborted`.
110        /// `None` (the default) means "this is a normal post-parse tool
111        /// call, no candidate phase was active" so the on-disk shape
112        /// stays compatible with replays recorded before this field
113        /// existed.
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        parsing: Option<bool>,
116        /// Mutation-session audit context active when the tool was
117        /// dispatched (see harn#699). Hosts use it to group every tool
118        /// emission belonging to the same write-capable session.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        audit: Option<MutationSessionRecord>,
121    },
122    ToolCallUpdate {
123        session_id: String,
124        tool_call_id: String,
125        tool_name: String,
126        status: ToolCallStatus,
127        raw_output: Option<serde_json::Value>,
128        error: Option<String>,
129        /// Wall-clock milliseconds from the parse-to-execution boundary
130        /// to the terminal `Completed`/`Failed` update. Includes the
131        /// time spent in any wrapping orchestration logic (loop checks,
132        /// post-tool hooks, microcompaction). Populated only on the
133        /// terminal update — `None` on intermediate `Pending` /
134        /// `InProgress` updates so clients can ignore the field until
135        /// it shows up.
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        duration_ms: Option<u64>,
138        /// Milliseconds spent in the actual host/builtin/MCP dispatch
139        /// call only (the inner `dispatch_tool_execution` window).
140        /// Populated only on the terminal update; `None` otherwise.
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        execution_duration_ms: Option<u64>,
143        /// Structured classification of the failure (when `status` is
144        /// `Failed`). Paired with `error` so clients can render each
145        /// category distinctly without parsing free-form strings. Always
146        /// `None` for non-Failed updates and serialized as
147        /// `errorCategory` in the ACP wire format.
148        #[serde(default, skip_serializing_if = "Option::is_none")]
149        error_category: Option<ToolCallErrorCategory>,
150        /// Structured workspace mutation outcome supplied by the tool execution
151        /// boundary. Nonterminal, non-write, and opaque outcomes are `Unknown`;
152        /// no rendered output is inspected to derive this value.
153        mutation_status: ToolMutationStatus,
154        /// Workspace-relative paths changed by this tool result when the
155        /// execution boundary can report them precisely.
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        changed_paths: Option<Vec<String>>,
158        /// Producer-owned facts declared by an
159        /// `harn.agent_tool_handler_result.v1` envelope. The dispatcher
160        /// projects the complete map without interpreting producer-specific
161        /// keys or parsing rendered output.
162        #[serde(default, skip_serializing_if = "Option::is_none")]
163        data: Option<serde_json::Value>,
164        /// Where the tool actually ran. `None` only for events emitted
165        /// from sites that pre-date the dispatch decision (e.g. the
166        /// pending → in-progress transition the loop emits before the
167        /// dispatcher picks a backend).
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        executor: Option<ToolExecutor>,
170        /// Companion to `ToolCall.parsing` (harn#692). The streaming
171        /// candidate detector emits the *terminal* candidate event as a
172        /// `ToolCallUpdate` with `parsing: Some(false)` to retract the
173        /// in-flight `parsing: true` chip — either by promoting the
174        /// candidate (`status: pending`, populated `raw_output: None`,
175        /// `error: None`) or aborting it (`status: failed`,
176        /// `error_category: parse_aborted`). `None` means this update is
177        /// not part of a candidate-phase transition.
178        #[serde(default, skip_serializing_if = "Option::is_none")]
179        parsing: Option<bool>,
180        /// Best-effort partial parse of the streamed tool-call arguments.
181        /// Populated by the SSE transport on `Pending` updates as the
182        /// model streams `input_json_delta` (Anthropic) or
183        /// `tool_calls[].function.arguments` deltas (OpenAI). `None` on
184        /// terminal updates and on emissions from non-streaming paths
185        /// (#693). When the partial bytes are not yet parseable as JSON
186        /// the transport falls back to `raw_input_partial`.
187        #[serde(default, skip_serializing_if = "Option::is_none")]
188        raw_input: Option<serde_json::Value>,
189        /// Raw concatenated bytes of the streamed tool-call arguments
190        /// when a permissive parse failed (#693). Mutually exclusive
191        /// with `raw_input`: clients render whichever is present.
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        raw_input_partial: Option<String>,
194        /// Mutation-session audit context for the tool call. Carries the
195        /// same payload as on the paired `ToolCall` event so a host
196        /// processing a single update doesn't have to correlate against
197        /// the prior pending event.
198        #[serde(default, skip_serializing_if = "Option::is_none")]
199        audit: Option<MutationSessionRecord>,
200    },
201    PlanDocumentUpdated {
202        session_id: String,
203        event: Box<crate::llm::plan::PlanDocumentEvent>,
204    },
205    /// Typed orchestration policy decision. This is intentionally distinct from
206    /// the collaborative plan-document lifecycle projected to ACP/A2A.
207    OrchestrationDecision {
208        session_id: String,
209        decision: serde_json::Value,
210    },
211    ProgressReported {
212        session_id: String,
213        message: Option<String>,
214        entries: serde_json::Value,
215        replace: bool,
216        metadata: serde_json::Value,
217    },
218    /// Emitted when the compass observes a freeform edit and either
219    /// suggests a structural primitive, rewrites the tool call, or falls
220    /// back because rewrite mode could not prove equivalence.
221    CompassRoutingDecision {
222        session_id: String,
223        tool_call_id: String,
224        mode: String,
225        action: String,
226        persona: String,
227        original_tool: String,
228        routed_tool: String,
229        target_tool: String,
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        path: Option<String>,
232    },
233    /// Emitted after an agent scratchpad reorganization attempt. The
234    /// scratchpad body itself stays in session state; this event carries
235    /// status and count/error metadata so live UIs and eval harnesses can
236    /// audit whether reorganization helped or damaged the working set.
237    AgentScratchpadReorganization {
238        session_id: String,
239        iteration: usize,
240        status: String,
241        details: serde_json::Value,
242    },
243    /// A renderable, declarative artifact spec emitted by an agent. Harn
244    /// validates the payload and transports it; host surfaces own rendering
245    /// and may fall back to the plain-text representation.
246    Artifact {
247        session_id: String,
248        artifact_id: String,
249        kind: String,
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        title: Option<String>,
252        mime_type: String,
253        spec: serde_json::Value,
254        fallback: String,
255        size_bytes: u64,
256        provenance: serde_json::Value,
257        metadata: serde_json::Value,
258    },
259    /// Fires at the top of every model round-trip inside an
260    /// `agent_loop` invocation. Maps to the `iteration_start` steering
261    /// seam. Not the same as ACP's outer `prompt_turn` boundary; an
262    /// outer `prompt_turn` cycle contains many of these.
263    IterationStart {
264        session_id: String,
265        iteration: usize,
266        /// Configured provider for the impending LLM call. Empty when the
267        /// caller defers provider selection to the routing layer.
268        /// Surfaces here so observers can show "about to call X/Y" before
269        /// the call returns — previously this only landed in the
270        /// transcript after the response, leaving live pulse-check
271        /// consumers without a model attribution for in-flight iterations.
272        #[serde(default, skip_serializing_if = "String::is_empty")]
273        provider: String,
274        /// Configured model id. Same semantics as `provider`.
275        #[serde(default, skip_serializing_if = "String::is_empty")]
276        model: String,
277    },
278    /// Fires at the bottom of every model round-trip, after tool
279    /// dispatch (or after the dispatch was skipped). Sibling of
280    /// `IterationStart`.
281    IterationEnd {
282        session_id: String,
283        iteration: usize,
284        /// Free-form dict carrying the post-call snapshot. Stable keys
285        /// emitted by the agent loop: `tool_count`, `text`, plus the
286        /// LLM-result projection — `provider`, `model`, `response_ms`,
287        /// `input_tokens`, `output_tokens`, `thinking_chars`. Hosts that
288        /// surface latency/cost panes key off these without re-parsing
289        /// the transcript JSONL.
290        iteration_info: serde_json::Value,
291    },
292    /// Emitted when a first-class agent session is explicitly closed by
293    /// `agent_session_close`. This gives event-log consumers a typed
294    /// terminal marker even when no final model turn runs.
295    SessionClosed {
296        session_id: String,
297        reason: String,
298        status: String,
299        metadata: serde_json::Value,
300    },
301    /// Emitted when `agent_session_reanchor` swaps the primary workspace
302    /// anchor (#2218). Hosts use this to drive cross-project handoff UX.
303    /// Carries the previous and current anchors so consumers can diff
304    /// without re-fetching session state.
305    AnchorChanged {
306        session_id: String,
307        previous: Option<serde_json::Value>,
308        current: serde_json::Value,
309        carry_transcript: bool,
310        compacted: bool,
311        reason: Option<String>,
312    },
313    JudgeDecision {
314        session_id: String,
315        iteration: usize,
316        verdict: String,
317        reasoning: String,
318        next_step: Option<String>,
319        judge_duration_ms: u64,
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        source: Option<String>,
322        #[serde(default, skip_serializing_if = "Option::is_none")]
323        trigger: Option<String>,
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        reason: Option<String>,
326        #[serde(default, skip_serializing_if = "Option::is_none")]
327        confirm: Option<bool>,
328        #[serde(default, skip_serializing_if = "Option::is_none")]
329        converted_from: Option<String>,
330        #[serde(default, skip_serializing_if = "Option::is_none")]
331        escalation_recommended: Option<bool>,
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        escalation_target: Option<String>,
334        #[serde(default, skip_serializing_if = "Vec::is_empty")]
335        specific_gaps: Vec<String>,
336        #[serde(default, skip_serializing_if = "Vec::is_empty")]
337        accepted_evidence: Vec<String>,
338    },
339    /// Per-step critique decision emitted by `agent_step_judge`.
340    /// Sibling of [`JudgeDecision`] but fired BEFORE tool dispatch on
341    /// every assistant turn (when configured), not just at completion.
342    /// `on_veto` carries the configured remediation shape
343    /// (`"replace"` or `"retain"`); `cost_usd` is best-effort from the
344    /// stdlib economics estimator and may be 0 when pricing is unknown.
345    /// `skipped` marks configured short-circuits that did not call the
346    /// judge model.
347    StepJudgeDecision {
348        session_id: String,
349        iteration: usize,
350        verdict: String,
351        reasoning: String,
352        critique: String,
353        confidence: f64,
354        judge_duration_ms: u64,
355        vetoed: bool,
356        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
357        skipped: bool,
358        #[serde(default, skip_serializing_if = "Option::is_none")]
359        reason: Option<String>,
360        /// True when this `verdict: "pass"` is the result of the step-judge
361        /// model itself erroring and `fail_open` swallowing the error — the
362        /// turn proceeded, but the adversarial-review surface was UNAVAILABLE
363        /// (not a genuine approval). Lets telemetry tell an inert reviewer
364        /// apart from a real pass. Mirrors `reason: "judge_unavailable"`.
365        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
366        judge_error: bool,
367        on_veto: String,
368        input_tokens: u64,
369        output_tokens: u64,
370        cost_usd: f64,
371        provider: String,
372        model: String,
373    },
374    /// Deterministic pre-dispatch critique emitted by the structural
375    /// validator middleware. Fires before any LLM-backed judge so hosts
376    /// can distinguish "$0 structural retry" from semantic critique.
377    StructuralValidatorDecision {
378        session_id: String,
379        iteration: usize,
380        rule: String,
381        diagnostic: String,
382        recommended_action: String,
383        vetoed: bool,
384        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
385        skipped: bool,
386        #[serde(default, skip_serializing_if = "Option::is_none")]
387        reason: Option<String>,
388        on_failure: String,
389        attempts: usize,
390        max_attempts: usize,
391    },
392    ScopeClassifierVerdict {
393        session_id: String,
394        iteration: usize,
395        label: String,
396        original_label: String,
397        confidence: f64,
398        confidence_threshold: f64,
399        evidence: String,
400        skip_main_turn: bool,
401        #[serde(default, skip_serializing_if = "Option::is_none")]
402        classifier_kind: Option<String>,
403        #[serde(default, skip_serializing_if = "Option::is_none")]
404        model: Option<String>,
405        #[serde(default, skip_serializing_if = "Option::is_none")]
406        error: Option<String>,
407    },
408    InputGuardrailVerdict {
409        session_id: String,
410        iteration: usize,
411        tripwire: bool,
412        reason: String,
413        label: String,
414        confidence: f64,
415        confidence_threshold: f64,
416        #[serde(default, skip_serializing_if = "Option::is_none")]
417        classifier_kind: Option<String>,
418        #[serde(default, skip_serializing_if = "Option::is_none")]
419        model: Option<String>,
420        #[serde(default, skip_serializing_if = "Option::is_none")]
421        error: Option<String>,
422    },
423    MissingToolCallVerdict {
424        session_id: String,
425        iteration: usize,
426        action: String,
427        original_action: String,
428        tool_name: String,
429        confidence: f64,
430        confidence_threshold: f64,
431        evidence: String,
432        #[serde(default, skip_serializing_if = "Option::is_none")]
433        language: Option<String>,
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        classifier_kind: Option<String>,
436        #[serde(default, skip_serializing_if = "Option::is_none")]
437        model: Option<String>,
438        #[serde(default, skip_serializing_if = "Option::is_none")]
439        error: Option<String>,
440    },
441    /// The loop reached a nominal terminal state without successfully calling
442    /// every tool required by policy. This is deliberately distinct from a
443    /// provider/tool execution failure: the run completed, but violated its
444    /// declared actuation contract.
445    RequireSuccessfulToolsViolation {
446        session_id: String,
447        kind: String,
448        source: String,
449        #[serde(default, skip_serializing_if = "Option::is_none")]
450        actor: Option<String>,
451        #[serde(default, skip_serializing_if = "Option::is_none")]
452        run_id: Option<String>,
453        redacted_summary: String,
454        recurrence_hints: Vec<String>,
455        metadata: serde_json::Value,
456    },
457    /// The loop recorded the extra, tools-disabled terminal answer produced by
458    /// its final-wrap-up turn.
459    FinalWrapup {
460        session_id: String,
461        final_status: String,
462        stop_reason: String,
463        iteration: usize,
464        host_directive: bool,
465        terminal_kind: String,
466    },
467    /// `llm::pack_for` removed an unsupported manual thinking policy while
468    /// lowering options for an adaptive-thinking model.
469    PackThinkingStripped {
470        session_id: String,
471        model: String,
472        requested: String,
473        reason: String,
474    },
475    /// `llm::self_consistency` resolved an equal top vote by its documented
476    /// deterministic lowest-sample-index rule.
477    SelfConsistencyTie {
478        session_id: String,
479        answer: String,
480        total: usize,
481        distribution: serde_json::Value,
482    },
483    /// The code librarian could not satisfy a natural-language query through
484    /// its compiled Cypher path and fell back to bounded graph search.
485    CodeLibrarianQueryNlFallback {
486        session_id: String,
487        #[serde(default, skip_serializing_if = "Option::is_none")]
488        attempted_cypher: Option<String>,
489        mcts_depth: usize,
490        mcts_expansions: usize,
491        result_count: usize,
492        text: String,
493    },
494    TypedCheckpoint {
495        session_id: String,
496        checkpoint: serde_json::Value,
497    },
498    /// A provider-neutral local or hosted model job changed lifecycle state.
499    ModelJob {
500        session_id: String,
501        event: serde_json::Value,
502    },
503    FeedbackInjected {
504        session_id: String,
505        kind: String,
506        content: String,
507        #[serde(default, skip_serializing_if = "Option::is_none")]
508        streak: Option<usize>,
509    },
510    HostToolResult {
511        session_id: String,
512        injection_id: String,
513        tool_call_id: String,
514        tool_name: String,
515        #[serde(default, skip_serializing_if = "Option::is_none")]
516        kind: Option<ToolKind>,
517        raw_input: serde_json::Value,
518        status: ToolCallStatus,
519        #[serde(default, skip_serializing_if = "Option::is_none")]
520        raw_output: Option<serde_json::Value>,
521        #[serde(default, skip_serializing_if = "Option::is_none")]
522        result_pointer: Option<String>,
523        #[serde(default, skip_serializing_if = "Option::is_none")]
524        error: Option<String>,
525        #[serde(default, skip_serializing_if = "Option::is_none")]
526        duration_ms: Option<u64>,
527        delivery: InjectionDelivery,
528        #[serde(default, skip_serializing_if = "Option::is_none")]
529        delivered_at_seam: Option<String>,
530        sequence: u64,
531        provenance: HostInjectionProvenance,
532        sanitization: SanitizationVerdict,
533    },
534    HostAttachment {
535        session_id: String,
536        injection_id: String,
537        media_type: String,
538        flavor: AttachmentFlavor,
539        artifact_pointer: String,
540        sha256: String,
541        size_bytes: u64,
542        rendered: AttachmentRendering,
543        #[serde(default, skip_serializing_if = "Option::is_none")]
544        description: Option<String>,
545        #[serde(default, skip_serializing_if = "Option::is_none")]
546        description_model: Option<String>,
547        delivery: InjectionDelivery,
548        #[serde(default, skip_serializing_if = "Option::is_none")]
549        delivered_at_seam: Option<String>,
550        sequence: u64,
551        provenance: HostInjectionProvenance,
552        sanitization: SanitizationVerdict,
553    },
554    /// Emitted when the agent loop exhausts `max_iterations` without any
555    /// explicit break condition firing. Distinct from a natural "done" or
556    /// a "stuck" nudge-exhaustion: this is strictly a budget cap.
557    BudgetExhausted {
558        session_id: String,
559        max_iterations: usize,
560        #[serde(default, skip_serializing_if = "Option::is_none")]
561        kind: Option<String>,
562        #[serde(default, skip_serializing_if = "Option::is_none")]
563        cost_usd: Option<f64>,
564        #[serde(default, skip_serializing_if = "Option::is_none")]
565        wall_clock_ms: Option<u64>,
566    },
567    /// Emitted when a loop-level budget circuit breaker trips after N
568    /// consecutive retryable failures. `paused_for_ms` is the mock-time-aware
569    /// backoff already honored before the terminal budget event.
570    BudgetCircuitBreaker {
571        session_id: String,
572        kind: String,
573        consecutive_count: usize,
574        paused_for_ms: u64,
575    },
576    /// Emitted when the loop breaks because consecutive text-only turns
577    /// hit `max_nudges`. Parity with `BudgetExhausted` / `IterationEnd` for
578    /// hosts that key off agent-terminal events.
579    LoopStuck {
580        session_id: String,
581        max_nudges: usize,
582        last_iteration: usize,
583        tail_excerpt: String,
584    },
585    /// Pipeline-authored stuck/escalation signal emitted through
586    /// `agent_emit_event("loop_stuck", payload)`. The runtime-level
587    /// `LoopStuck` variant above remains the built-in max-nudge terminal event;
588    /// this variant preserves the pipeline payload so hosts can surface richer
589    /// handoff/escalation details without inventing another wire kind.
590    LoopStuckSignal {
591        session_id: String,
592        payload: serde_json::Value,
593    },
594    /// Emitted by the reserved-budget terminal-verify guard
595    /// (`agent_emit_event("reserved_terminal_verify", payload)`). The guard
596    /// holds back a small iteration reserve the main loop cannot consume; when
597    /// the loop would otherwise terminate on a budget/stuck boundary with an
598    /// unverified source write, it spends the reserve on a final verify(+repair)
599    /// instead of ending blind on a red build. The payload's `phase` field tags
600    /// the step (`grant` / `verify_passed` / `verify_failed`) so replayers and
601    /// operators can see the guard fire and its outcome. Payload-preserving like
602    /// `LoopStuckSignal` so the guard can carry richer detail without inventing
603    /// another wire kind.
604    ReservedTerminalVerify {
605        session_id: String,
606        payload: serde_json::Value,
607    },
608    /// Emitted when the daemon idle-wait loop trips its watchdog because
609    /// every configured wake source returned `None` for N consecutive
610    /// attempts. Exists so a broken daemon doesn't hang the session
611    /// silently.
612    DaemonWatchdogTripped {
613        session_id: String,
614        attempts: usize,
615        elapsed_ms: u64,
616    },
617    /// Emitted when a skill is activated. Carries the match reason so
618    /// replayers can reconstruct *why* a given skill took effect at
619    /// this iteration.
620    SkillActivated {
621        session_id: String,
622        skill_name: String,
623        iteration: usize,
624        reason: String,
625    },
626    /// Emitted when a previously-active skill is deactivated because
627    /// the reassess phase no longer matches it.
628    SkillDeactivated {
629        session_id: String,
630        skill_name: String,
631        iteration: usize,
632    },
633    /// Emitted once per activation when the skill's `allowed_tools` filter
634    /// narrows the effective tool surface exposed to the model.
635    SkillScopeTools {
636        session_id: String,
637        skill_name: String,
638        allowed_tools: Vec<String>,
639    },
640    /// Emitted when the agent loop ratchets the model-visible tool
641    /// surface narrower after observing recent tool-call usage. Unlike
642    /// `SkillScopeTools`, this is session-local and can only remove
643    /// tools from the currently-effective surface.
644    SkillNarrow {
645        session_id: String,
646        reason: String,
647        removed_tools: Vec<String>,
648        remaining_tools: Vec<String>,
649        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
650        policy: serde_json::Value,
651        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
652        removed_tool_details: serde_json::Value,
653        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
654        kept_tool_details: serde_json::Value,
655    },
656    /// Read-only stance lifecycle (std/agent/stance): `phase` is one of
657    /// `armed`, `write_access_granted`, `write_access_denied`,
658    /// `disarmed`. Arming carries the permitted tool window; the
659    /// grant/deny phases carry the escape-hatch justification and the
660    /// consent verdict so a trace viewer can explain every elevation.
661    StanceTransition {
662        session_id: String,
663        phase: String,
664        escape_tool: String,
665        #[serde(default, skip_serializing_if = "Vec::is_empty")]
666        allowed_tools: Vec<String>,
667        #[serde(default, skip_serializing_if = "String::is_empty")]
668        justification: String,
669        #[serde(default, skip_serializing_if = "String::is_empty")]
670        consent: String,
671        #[serde(default, skip_serializing_if = "String::is_empty")]
672        reason: String,
673    },
674    /// Emitted when a `tool_search` query is issued by the model. Carries
675    /// the raw query args, the configured strategy, and a `mode` tag
676    /// distinguishing the client-executed fallback (`"client"`) from
677    /// provider-native paths (`"anthropic"` / `"openai"`). Mirrors the
678    /// transcript event shape so hosts can render a search-in-progress
679    /// chip in real time — the replay path walks the transcript after
680    /// the turn, which is too late for live UX.
681    ToolSearchQuery {
682        session_id: String,
683        tool_use_id: String,
684        name: String,
685        query: serde_json::Value,
686        strategy: String,
687        mode: String,
688    },
689    /// Emitted when `tool_search` resolves — carries the list of tool
690    /// names newly promoted into the model's effective surface for the
691    /// next turn. Pair-emitted with `ToolSearchQuery` on every search.
692    ToolSearchResult {
693        session_id: String,
694        tool_use_id: String,
695        promoted: Vec<String>,
696        strategy: String,
697        mode: String,
698    },
699    /// A transcript compaction completed. Carries the one canonical
700    /// [`CompactionReceipt`] (harn#4995) — the same receipt embedded verbatim in
701    /// the transcript `compaction` event, forwarded through ACP, and projected
702    /// into `RunObservabilityRecord.compaction_events`. `receipt.receipt_id` is
703    /// the stable identity shared across all four surfaces, so hosts never
704    /// synthesize their own.
705    TranscriptCompacted {
706        session_id: String,
707        receipt: crate::orchestration::CompactionReceipt,
708    },
709    /// Emitted whenever `transcript_project` derives a model-visible
710    /// prefix from the immutable raw transcript. Hosts that render a
711    /// side-by-side raw/projected view subscribe to this — the typed
712    /// payload mirrors the metadata on the persisted
713    /// `transcript.projection` transcript event so clients don't have to
714    /// re-parse the transcript to sync UI state.
715    TranscriptProjected {
716        session_id: String,
717        policy: String,
718        reason: String,
719        prefix_hash: String,
720        kept_count: usize,
721        dropped_count: usize,
722        provider_safety_blocked: bool,
723        #[serde(default, skip_serializing_if = "is_zero_usize")]
724        redacted_count: usize,
725        #[serde(default, skip_serializing_if = "is_zero_usize")]
726        reclaimed_tokens: usize,
727        #[serde(default, skip_serializing_if = "Vec::is_empty")]
728        roots_consulted: Vec<String>,
729        #[serde(default, skip_serializing_if = "Vec::is_empty")]
730        redaction_pointers: Vec<serde_json::Value>,
731    },
732    /// Emitted when a pending `system_reminder` is rendered into the
733    /// next provider request. ACP clients show these in a reminder lane
734    /// instead of mixing them into assistant text chunks.
735    ReminderEmitted {
736        session_id: String,
737        reminder_id: String,
738        tags: Vec<String>,
739        body: String,
740        role_hint: String,
741        authority: String,
742        rendered_role: String,
743        source: String,
744        ttl_turns: Option<i64>,
745    },
746    Handoff {
747        session_id: String,
748        artifact_id: String,
749        handoff: Box<HandoffArtifact>,
750    },
751    FsWatch {
752        session_id: String,
753        subscription_id: String,
754        events: Vec<FsWatchEvent>,
755    },
756    /// Emitted when hostlib staged filesystem state changes for a session.
757    /// The ACP adapter maps this to the existing `progress` extension so
758    /// clients can update rollup-diff badges without waiting for a prompt
759    /// turn boundary.
760    StagedWritesPending {
761        session_id: String,
762        pending_count: usize,
763        total_bytes: u64,
764        pending_writes: Vec<StagedWriteSummary>,
765    },
766    /// Per-call outcome of `hostlib_fs_safe_text_patch`. Hosts subscribe to
767    /// this to roll up stale-base / hunk-conflict rates and average
768    /// hunks-per-patch without scraping result dicts out of pipeline logs.
769    /// Fired from both the staged-overlay and direct-disk code paths so
770    /// the rollup is comprehensive.
771    SafeTextPatchResult {
772        session_id: String,
773        path: String,
774        result: String,
775        hunks_count: usize,
776        bytes_written: u64,
777        #[serde(default, skip_serializing_if = "Option::is_none")]
778        failed_hunk_index: Option<usize>,
779    },
780    /// ACP control-plane arbitration outcome. Emitted for accepted,
781    /// idempotent, and rejected controls so replay/audit consumers can show
782    /// who acted and why a late or unauthorized action lost.
783    ControlOutcome {
784        session_id: String,
785        control_id: String,
786        method: String,
787        outcome: String,
788        status: String,
789        actor: serde_json::Value,
790        target: serde_json::Value,
791        #[serde(default, skip_serializing_if = "Option::is_none")]
792        reason: Option<String>,
793        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
794        metadata: serde_json::Value,
795    },
796    /// Lifecycle update for a delegated/background worker. Carries the
797    /// canonical typed `event` variant alongside the worker's current
798    /// `status` string and the structured `metadata` payload that
799    /// `worker_bridge_metadata` builds (task, mode, timing, child
800    /// run/snapshot paths, audit-session, etc.). The `audit` field is
801    /// the same `MutationSessionRecord` JSON serialization carried on
802    /// the bridge wire so ACP/A2A consumers don't need to re-derive it.
803    ///
804    /// One-to-one with the bridge-side `worker_update` session-update
805    /// notification: ACP and A2A adapters subscribe to this variant
806    /// and translate it into their respective wire formats. The
807    /// `session_id` is the parent agent session that owns the worker
808    /// (i.e. the session whose VM spawned the worker), so a single
809    /// host stays subscribed to the same sink for both message and
810    /// worker traffic.
811    WorkerUpdate {
812        session_id: String,
813        worker_id: String,
814        worker_name: String,
815        worker_task: String,
816        worker_mode: String,
817        event: WorkerEvent,
818        status: String,
819        metadata: serde_json::Value,
820        audit: Option<serde_json::Value>,
821    },
822    /// Exactly-once terminal lifecycle record for `sub_agent_run`.
823    /// Unlike the generic worker update, this is present for both foreground
824    /// and background delegation and preserves the parent/child lineage.
825    SubagentStop {
826        session_id: String,
827        /// Typed identity is authoritative for events emitted by current
828        /// runtimes. `None` accepts persisted events from older runtimes whose
829        /// run-id fields actually contained session ids.
830        #[serde(default, skip_serializing_if = "Option::is_none")]
831        lineage: Option<super::DelegatedRunLineage>,
832        /// Compatibility projection of `lineage.parent.run_id`.
833        parent_run_id: String,
834        /// Compatibility projection of `lineage.child.run_id`.
835        child_run_id: String,
836        terminal_status: SubagentTerminalStatus,
837        terminal_class: String,
838        reason: String,
839        #[serde(default, skip_serializing_if = "Option::is_none")]
840        result_ref: Option<String>,
841        #[serde(default, skip_serializing_if = "Option::is_none")]
842        receipt_ref: Option<String>,
843        #[serde(default, skip_serializing_if = "Option::is_none")]
844        cancellation: Option<serde_json::Value>,
845        #[serde(default, skip_serializing_if = "Option::is_none")]
846        timeout: Option<serde_json::Value>,
847        completed_at_ms: i64,
848    },
849    /// Exactly-once receipt that an attached background child was observed in
850    /// a terminal state by its parent.
851    SubagentJoin {
852        session_id: String,
853        lineage: super::DelegatedRunLineage,
854        worker_id: String,
855        completed_at_ms: i64,
856        joined_at_ms: i64,
857        /// Wait and result-processing boundaries (#6074). Flattened so a
858        /// receipt written before these existed still deserializes, with every
859        /// new boundary absent rather than guessed.
860        #[serde(default, flatten)]
861        boundaries: super::DelegatedJoinBoundaries,
862    },
863    /// A human-in-the-loop primitive (`ask_user`, `request_approval`,
864    /// `dual_control`, `escalate`) has just suspended the script and is
865    /// waiting on a response. Hosts that bridge the VM onto a remote
866    /// transport (ACP, A2A) translate this into a "paused / awaiting
867    /// input" wire signal so the client knows the task isn't stuck —
868    /// it's blocked on the human side. Pair-emitted with `HitlResolved`
869    /// when the waitpoint completes/cancels/times out.
870    HitlRequested {
871        session_id: String,
872        request_id: String,
873        kind: String,
874        payload: serde_json::Value,
875    },
876    /// Companion to `HitlRequested`: the waitpoint has resolved (either
877    /// a response arrived, the deadline elapsed, or the request was
878    /// cancelled). `outcome` is one of `"answered"`, `"timeout"`,
879    /// `"cancelled"`. Hosts use this to flip task state back to
880    /// `working` after an `input-required` pause.
881    HitlResolved {
882        session_id: String,
883        request_id: String,
884        kind: String,
885        outcome: String,
886    },
887    /// Emitted by the agent loop's adaptive iteration budget /
888    /// `loop_control` policy when a budget extension or early stop fires.
889    /// Generic enough to cover both shapes — `action` distinguishes them.
890    /// Carries the iteration the decision applied to, the previous /
891    /// resulting iteration limit, the policy reason string, and (for
892    /// stops) the loop status.
893    LoopControlDecision {
894        session_id: String,
895        iteration: usize,
896        action: String,
897        old_limit: usize,
898        new_limit: usize,
899        reason: String,
900        status: String,
901    },
902    /// Emitted when `agent_loop` detects adjacent repeated tool calls with
903    /// identical arguments. The warning payload avoids raw arguments by
904    /// default and carries digests so hosts can correlate repeats without
905    /// exposing potentially sensitive tool inputs.
906    AgentLoopStallWarning {
907        session_id: String,
908        warning: serde_json::Value,
909    },
910    /// Emitted when a concrete provider/model pair lacks a catalog
911    /// recommendation for a capability and the runtime chooses a fallback.
912    CapabilityGap {
913        session_id: String,
914        level: String,
915        capability: String,
916        provider: String,
917        model: String,
918        fallback_tool_format: String,
919        #[serde(default, skip_serializing_if = "Option::is_none")]
920        requested_tool_format: Option<String>,
921        message: String,
922    },
923    /// Emitted when a caller explicitly forces a tool format that
924    /// differs from the capability catalog's recommendation or known
925    /// native/text parity guidance.
926    ToolFormatOverride {
927        session_id: String,
928        provider: String,
929        model: String,
930        requested_format: String,
931        recommended_format: String,
932        catalog_parity: String,
933        #[serde(default, skip_serializing_if = "Option::is_none")]
934        override_reason: Option<String>,
935    },
936    /// Emitted when a `tool_caller` middleware (see std/llm/tool_middleware)
937    /// attaches structured audit metadata to a tool call — typically a
938    /// user-facing `summary`, a `description`, an ACP-style `kind`, an MCP
939    /// `hints` block, a `consent` decision, the per-layer `layers` log, or
940    /// free-form `metadata` keys (A2A-style extension slot).
941    ///
942    /// One-to-one with the underlying tool-call: hosts can join on
943    /// `tool_call_id` to render middleware-attached chips alongside the
944    /// existing `ToolCall` / `ToolCallUpdate` stream. The `audit` payload
945    /// is intentionally free-form JSON so middleware can carry whatever
946    /// shape the harness author chooses without needing protocol-level
947    /// changes per new middleware. When present, `receipt` carries the
948    /// stable typed, privacy-preserving record hosts can persist or mirror.
949    ToolCallAudit {
950        session_id: String,
951        tool_call_id: String,
952        tool_name: String,
953        audit: serde_json::Value,
954        #[serde(default, skip_serializing_if = "Option::is_none")]
955        receipt: Option<ToolCallReceipt>,
956    },
957    /// Records the execute/defer/skip decision for every call in a
958    /// model-proposed tool batch.
959    ToolBatchDisposition {
960        session_id: String,
961        receipt: ToolBatchDispositionReceipt,
962    },
963    /// Emitted by `std/cache::with_cache` (both the generic and LLM
964    /// forms) when a cached lookup returns a hit. Carries the
965    /// content-addressed key, the backend that served the value, and a
966    /// `metrics` block with the cost-moat receipts the persona value
967    /// ledger (a cloud platform) and crystallization receipts read:
968    /// `model_calls_avoided`, plus `tokens_saved` / `latency_saved_ms`
969    /// when the cached envelope carried `usage` / `latency_ms`.
970    CacheHit {
971        session_id: String,
972        key: String,
973        backend: String,
974        namespace: String,
975        payload: serde_json::Value,
976    },
977    /// Paired with `CacheHit`. Emitted on the miss path when the
978    /// fresh result is stored. `payload.metrics.compute_ms` carries
979    /// the wall-clock cost of the underlying computation, which
980    /// callers can feed back as `estimate.latency_saved_ms` on the
981    /// next hit.
982    CacheMiss {
983        session_id: String,
984        key: String,
985        backend: String,
986        namespace: String,
987        payload: serde_json::Value,
988    },
989    /// Emitted by `std/llm::with_logging` once per provider call. Carries the
990    /// per-call latency, route, and outcome that spend and slow-call
991    /// attribution read, with the full record kept in `payload` so an
992    /// `include_prompt` caller loses nothing on the way through this boundary.
993    LlmCallLog {
994        session_id: String,
995        model: String,
996        provider: String,
997        status: String,
998        latency_ms: usize,
999        iteration: usize,
1000        attempt: usize,
1001        payload: serde_json::Value,
1002    },
1003    /// Emitted by `std/llm::with_routing` when the router picks a caller.
1004    /// `route_index` is `-1` when it fell through to the default route.
1005    LlmRoutingDecision {
1006        session_id: String,
1007        route_index: i64,
1008        route_name: String,
1009        used_default: bool,
1010        payload: serde_json::Value,
1011    },
1012    /// Emitted by `std/llm::with_fallback` once per caller it tries, so a run
1013    /// shows how far down the chain it had to go and why each rung failed.
1014    LlmFallbackAttempt {
1015        session_id: String,
1016        fallback_index: usize,
1017        fallback_total: usize,
1018        ok: bool,
1019        status: String,
1020        payload: serde_json::Value,
1021    },
1022    /// Emitted by `std/llm::with_shadow` when the shadow caller's result
1023    /// differs from the primary's under the configured comparison policy.
1024    LlmShadowDiff {
1025        session_id: String,
1026        primary_ok: bool,
1027        shadow_ok: bool,
1028        primary_status: String,
1029        shadow_status: String,
1030        primary_len: usize,
1031        shadow_len: usize,
1032        payload: serde_json::Value,
1033    },
1034    /// Emitted by `std/llm::with_semantic_cache` when an embedding lookup
1035    /// clears the similarity threshold. `payload.metrics` carries the same
1036    /// cost-moat receipts as [`AgentEvent::CacheHit`].
1037    SemanticCacheHit {
1038        session_id: String,
1039        similarity: f64,
1040        provider: String,
1041        model: String,
1042        payload: serde_json::Value,
1043    },
1044    /// Paired with [`AgentEvent::SemanticCacheHit`]. `nearest_similarity` is
1045    /// how close the best candidate came, which is what tells an operator
1046    /// whether the threshold is set wrong or the corpus is simply cold.
1047    SemanticCacheMiss {
1048        session_id: String,
1049        nearest_similarity: f64,
1050        payload: serde_json::Value,
1051    },
1052    /// A language-neutral tool-composition snippet has started. The envelope
1053    /// identifies the snippet and binding manifest hashes plus the side-effect
1054    /// ceiling requested for the whole parent run.
1055    CompositionStart {
1056        session_id: String,
1057        run: CompositionRunEnvelope,
1058    },
1059    /// A composition snippet is dispatching a child binding call. The child
1060    /// remains visible as its own operation with annotations and policy context
1061    /// instead of being hidden inside the parent composition blob.
1062    CompositionChildCall {
1063        session_id: String,
1064        call: CompositionChildCall,
1065    },
1066    /// A child binding operation emitted a status/result update.
1067    CompositionChildResult {
1068        session_id: String,
1069        result: CompositionChildResult,
1070    },
1071    /// A composition run finished successfully and carries stdout/stderr,
1072    /// artifacts, and the structured result in the terminal envelope.
1073    CompositionFinish {
1074        session_id: String,
1075        run: CompositionRunEnvelope,
1076    },
1077    /// A composition run failed before producing a successful terminal result.
1078    /// The terminal envelope carries the failure category and optional error.
1079    CompositionError {
1080        session_id: String,
1081        run: CompositionRunEnvelope,
1082    },
1083    /// Emitted once per `agent_stage(...)` pass. The single
1084    /// named seam through which the agent loop drains queued bridge
1085    /// injections and inbox feedback. Hosts use it to debug "did the
1086    /// loop check for steering at the expected boundary" without having
1087    /// to grep the loop body for inline drain calls.
1088    ///
1089    /// `kind` is one of the documented seam names: `iteration_start`,
1090    /// `pre_tool_dispatch`, `post_tool_dispatch`, `iteration_end`,
1091    /// `pre_compact`, `post_compact`, `daemon_idle_pre`,
1092    /// `daemon_idle_post`, `loop_exit`. `delivered` is the count of
1093    /// bridge injections drained at this seam (inbox drains are
1094    /// reported separately under `inbox_delivered`). Typed host
1095    /// injections delivered from `agent_inbox` are reported under
1096    /// `typed_delivered`. `dispatch_skipped` is true only when an
1097    /// `interrupt_immediate` injection arrived at `pre_tool_dispatch`
1098    /// and the pending tool batch was skipped.
1099    LoopCheckpoint {
1100        session_id: String,
1101        iteration: usize,
1102        kind: String,
1103        delivered: usize,
1104        #[serde(default, skip_serializing_if = "is_zero_usize")]
1105        inbox_delivered: usize,
1106        #[serde(default, skip_serializing_if = "is_zero_usize")]
1107        typed_delivered: usize,
1108        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1109        dispatch_skipped: bool,
1110    },
1111    /// Surfaced when Harn is acting as an MCP **client** and a peer
1112    /// server sends a server-to-client message during an agent session:
1113    /// a `notifications/progress` / `notifications/message` /
1114    /// `notifications/*/list_changed` notification, or an inbound
1115    /// `elicitation/create` / `sampling/createMessage` request.
1116    ///
1117    /// Emitted alongside (not in place of) the existing agent-inbox
1118    /// relay so a thin ACP client can render a live progress bar, log
1119    /// line, elicitation prompt, or sampling affordance without parsing
1120    /// the inbox transcript. `direction` is `"notification"` for
1121    /// fire-and-forget server notifications and `"request"` for inbound
1122    /// requests that still resolve through the existing client-role
1123    /// dispatch path (this event does not change that response). `method`
1124    /// is the raw MCP JSON-RPC method; `params` is its untouched payload.
1125    McpNotification {
1126        session_id: String,
1127        server: String,
1128        method: String,
1129        direction: String,
1130        params: serde_json::Value,
1131    },
1132    /// Surfaced when the effective MCP catalog changes — either because a
1133    /// server emitted a `notifications/tools/list_changed` (or the
1134    /// resource/prompt equivalents), or because the persisted enable/disable
1135    /// allowlist was edited. A thin ACP client (an IDE host's TUI / GUI)
1136    /// treats this as a cue to re-fetch the catalog (e.g. via the
1137    /// `mcp/catalog` request) and re-render its toggle UI, rather than
1138    /// reconciling any local state. `server` is the server whose list
1139    /// changed, or `None` when the change is allowlist-wide. `reason` is a
1140    /// short tag (`"list_changed"` or `"allowlist_updated"`).
1141    McpCatalogChanged {
1142        session_id: String,
1143        #[serde(default, skip_serializing_if = "Option::is_none")]
1144        server: Option<String>,
1145        reason: String,
1146    },
1147    /// Surfaced when an MCP server harn is acting as a client for answers a
1148    /// request with `401 Unauthorized` mid-session, meaning its OAuth token is
1149    /// missing or expired. This is a cue for a thin ACP client (an IDE host's
1150    /// TUI / GUI) to start an authorization: call `mcp/authorize` to mint a
1151    /// browser URL, open it, and forward the redirect's `code`+`state` back via
1152    /// `mcp/oauth_callback`. Token exchange and storage stay in harn. `server`
1153    /// is the configured server name; `resource` is its canonical RFC 8707
1154    /// resource indicator; `scope` is the `scope` parameter from the
1155    /// `WWW-Authenticate` challenge, when present.
1156    McpAuthRequired {
1157        session_id: String,
1158        server: String,
1159        resource: String,
1160        #[serde(default, skip_serializing_if = "Option::is_none")]
1161        scope: Option<String>,
1162    },
1163    /// A boundary between model-produced bytes and executed action declined to
1164    /// carry part of that flow: a parser dropped a span, an extractor skipped a
1165    /// content block, a sanitizer stripped text, a cap stopped a loop.
1166    ///
1167    /// This is the one wire form of the loud-boundary invariant (harn#5142).
1168    /// Before it existed, each of those losses was invisible, and every
1169    /// downstream signal — stall feedback, the rate governor, the verdict layer
1170    /// — read the resulting inaction as model pathology. `owner` carries the
1171    /// attribution in the same vocabulary as
1172    /// [`super::AgentTerminalKind::owner`], so a consumer can bucket the run as
1173    /// harness/policy fault without re-deriving it from `boundary`.
1174    ///
1175    /// Constructed through [`crate::boundary::BoundaryFailure`], never by hand;
1176    /// `scripts/loud_boundaries.toml` enumerates every boundary that can emit
1177    /// it and `make check-loud-boundaries` keeps that enumeration honest.
1178    BoundaryFailure {
1179        session_id: String,
1180        boundary: crate::boundary::BoundaryId,
1181        kind: crate::boundary::BoundaryFailureKind,
1182        owner: String,
1183        detail: String,
1184        #[serde(default, skip_serializing_if = "Option::is_none")]
1185        excerpt: Option<String>,
1186        #[serde(default, skip_serializing_if = "is_zero_usize")]
1187        dropped_count: usize,
1188        #[serde(default, skip_serializing_if = "is_zero_usize")]
1189        dropped_bytes: usize,
1190        /// Set when the failure reached the bus through the `Drop` backstop
1191        /// rather than an explicit `report()` — i.e. a drop path built the
1192        /// record and then lost track of it. Always `false` in normal
1193        /// operation; a `true` here is a harness bug worth fixing.
1194        #[serde(default, skip_serializing_if = "is_false")]
1195        unreported: bool,
1196    },
1197}
1198
1199fn is_zero_usize(value: &usize) -> bool {
1200    *value == 0
1201}
1202
1203fn is_false(value: &bool) -> bool {
1204    !*value
1205}
1206
1207impl AgentEvent {
1208    pub fn session_id(&self) -> &str {
1209        match self {
1210            Self::AgentMessageChunk { session_id, .. }
1211            | Self::AgentThoughtChunk { session_id, .. }
1212            | Self::UserMessage { session_id, .. }
1213            | Self::ToolCall { session_id, .. }
1214            | Self::ToolCallUpdate { session_id, .. }
1215            | Self::PlanDocumentUpdated { session_id, .. }
1216            | Self::OrchestrationDecision { session_id, .. }
1217            | Self::ProgressReported { session_id, .. }
1218            | Self::CompassRoutingDecision { session_id, .. }
1219            | Self::AgentScratchpadReorganization { session_id, .. }
1220            | Self::Artifact { session_id, .. }
1221            | Self::IterationStart { session_id, .. }
1222            | Self::IterationEnd { session_id, .. }
1223            | Self::SessionClosed { session_id, .. }
1224            | Self::AnchorChanged { session_id, .. }
1225            | Self::JudgeDecision { session_id, .. }
1226            | Self::StepJudgeDecision { session_id, .. }
1227            | Self::StructuralValidatorDecision { session_id, .. }
1228            | Self::ScopeClassifierVerdict { session_id, .. }
1229            | Self::InputGuardrailVerdict { session_id, .. }
1230            | Self::MissingToolCallVerdict { session_id, .. }
1231            | Self::RequireSuccessfulToolsViolation { session_id, .. }
1232            | Self::FinalWrapup { session_id, .. }
1233            | Self::PackThinkingStripped { session_id, .. }
1234            | Self::SelfConsistencyTie { session_id, .. }
1235            | Self::CodeLibrarianQueryNlFallback { session_id, .. }
1236            | Self::TypedCheckpoint { session_id, .. }
1237            | Self::ModelJob { session_id, .. }
1238            | Self::FeedbackInjected { session_id, .. }
1239            | Self::HostToolResult { session_id, .. }
1240            | Self::HostAttachment { session_id, .. }
1241            | Self::BudgetExhausted { session_id, .. }
1242            | Self::BudgetCircuitBreaker { session_id, .. }
1243            | Self::LoopStuck { session_id, .. }
1244            | Self::LoopStuckSignal { session_id, .. }
1245            | Self::ReservedTerminalVerify { session_id, .. }
1246            | Self::DaemonWatchdogTripped { session_id, .. }
1247            | Self::SkillActivated { session_id, .. }
1248            | Self::SkillDeactivated { session_id, .. }
1249            | Self::SkillScopeTools { session_id, .. }
1250            | Self::SkillNarrow { session_id, .. }
1251            | Self::StanceTransition { session_id, .. }
1252            | Self::ToolSearchQuery { session_id, .. }
1253            | Self::ToolSearchResult { session_id, .. }
1254            | Self::TranscriptCompacted { session_id, .. }
1255            | Self::TranscriptProjected { session_id, .. }
1256            | Self::ReminderEmitted { session_id, .. }
1257            | Self::Handoff { session_id, .. }
1258            | Self::FsWatch { session_id, .. }
1259            | Self::StagedWritesPending { session_id, .. }
1260            | Self::SafeTextPatchResult { session_id, .. }
1261            | Self::ControlOutcome { session_id, .. }
1262            | Self::WorkerUpdate { session_id, .. }
1263            | Self::SubagentStop { session_id, .. }
1264            | Self::SubagentJoin { session_id, .. }
1265            | Self::HitlRequested { session_id, .. }
1266            | Self::HitlResolved { session_id, .. }
1267            | Self::LoopControlDecision { session_id, .. }
1268            | Self::AgentLoopStallWarning { session_id, .. }
1269            | Self::CapabilityGap { session_id, .. }
1270            | Self::ToolFormatOverride { session_id, .. }
1271            | Self::ToolCallAudit { session_id, .. }
1272            | Self::ToolBatchDisposition { session_id, .. }
1273            | Self::CacheHit { session_id, .. }
1274            | Self::CacheMiss { session_id, .. }
1275            | Self::LlmCallLog { session_id, .. }
1276            | Self::LlmRoutingDecision { session_id, .. }
1277            | Self::LlmFallbackAttempt { session_id, .. }
1278            | Self::LlmShadowDiff { session_id, .. }
1279            | Self::SemanticCacheHit { session_id, .. }
1280            | Self::SemanticCacheMiss { session_id, .. }
1281            | Self::CompositionStart { session_id, .. }
1282            | Self::CompositionChildCall { session_id, .. }
1283            | Self::CompositionChildResult { session_id, .. }
1284            | Self::CompositionFinish { session_id, .. }
1285            | Self::CompositionError { session_id, .. }
1286            | Self::LoopCheckpoint { session_id, .. }
1287            | Self::McpNotification { session_id, .. }
1288            | Self::McpCatalogChanged { session_id, .. }
1289            | Self::McpAuthRequired { session_id, .. }
1290            | Self::BoundaryFailure { session_id, .. } => session_id,
1291        }
1292    }
1293}