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    /// `agent_turn`/`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    FeedbackInjected {
499        session_id: String,
500        kind: String,
501        content: String,
502        #[serde(default, skip_serializing_if = "Option::is_none")]
503        streak: Option<usize>,
504    },
505    HostToolResult {
506        session_id: String,
507        injection_id: String,
508        tool_call_id: String,
509        tool_name: String,
510        #[serde(default, skip_serializing_if = "Option::is_none")]
511        kind: Option<ToolKind>,
512        raw_input: serde_json::Value,
513        status: ToolCallStatus,
514        #[serde(default, skip_serializing_if = "Option::is_none")]
515        raw_output: Option<serde_json::Value>,
516        #[serde(default, skip_serializing_if = "Option::is_none")]
517        result_pointer: Option<String>,
518        #[serde(default, skip_serializing_if = "Option::is_none")]
519        error: Option<String>,
520        #[serde(default, skip_serializing_if = "Option::is_none")]
521        duration_ms: Option<u64>,
522        delivery: InjectionDelivery,
523        #[serde(default, skip_serializing_if = "Option::is_none")]
524        delivered_at_seam: Option<String>,
525        sequence: u64,
526        provenance: HostInjectionProvenance,
527        sanitization: SanitizationVerdict,
528    },
529    HostAttachment {
530        session_id: String,
531        injection_id: String,
532        media_type: String,
533        flavor: AttachmentFlavor,
534        artifact_pointer: String,
535        sha256: String,
536        size_bytes: u64,
537        rendered: AttachmentRendering,
538        #[serde(default, skip_serializing_if = "Option::is_none")]
539        description: Option<String>,
540        #[serde(default, skip_serializing_if = "Option::is_none")]
541        description_model: Option<String>,
542        delivery: InjectionDelivery,
543        #[serde(default, skip_serializing_if = "Option::is_none")]
544        delivered_at_seam: Option<String>,
545        sequence: u64,
546        provenance: HostInjectionProvenance,
547        sanitization: SanitizationVerdict,
548    },
549    /// Emitted when the agent loop exhausts `max_iterations` without any
550    /// explicit break condition firing. Distinct from a natural "done" or
551    /// a "stuck" nudge-exhaustion: this is strictly a budget cap.
552    BudgetExhausted {
553        session_id: String,
554        max_iterations: usize,
555        #[serde(default, skip_serializing_if = "Option::is_none")]
556        kind: Option<String>,
557        #[serde(default, skip_serializing_if = "Option::is_none")]
558        cost_usd: Option<f64>,
559        #[serde(default, skip_serializing_if = "Option::is_none")]
560        wall_clock_ms: Option<u64>,
561    },
562    /// Emitted when a loop-level budget circuit breaker trips after N
563    /// consecutive retryable failures. `paused_for_ms` is the mock-time-aware
564    /// backoff already honored before the terminal budget event.
565    BudgetCircuitBreaker {
566        session_id: String,
567        kind: String,
568        consecutive_count: usize,
569        paused_for_ms: u64,
570    },
571    /// Emitted when the loop breaks because consecutive text-only turns
572    /// hit `max_nudges`. Parity with `BudgetExhausted` / `IterationEnd` for
573    /// hosts that key off agent-terminal events.
574    LoopStuck {
575        session_id: String,
576        max_nudges: usize,
577        last_iteration: usize,
578        tail_excerpt: String,
579    },
580    /// Pipeline-authored stuck/escalation signal emitted through
581    /// `agent_emit_event("loop_stuck", payload)`. The runtime-level
582    /// `LoopStuck` variant above remains the built-in max-nudge terminal event;
583    /// this variant preserves the pipeline payload so hosts can surface richer
584    /// handoff/escalation details without inventing another wire kind.
585    LoopStuckSignal {
586        session_id: String,
587        payload: serde_json::Value,
588    },
589    /// Emitted by the reserved-budget terminal-verify guard
590    /// (`agent_emit_event("reserved_terminal_verify", payload)`). The guard
591    /// holds back a small iteration reserve the main loop cannot consume; when
592    /// the loop would otherwise terminate on a budget/stuck boundary with an
593    /// unverified source write, it spends the reserve on a final verify(+repair)
594    /// instead of ending blind on a red build. The payload's `phase` field tags
595    /// the step (`grant` / `verify_passed` / `verify_failed`) so replayers and
596    /// operators can see the guard fire and its outcome. Payload-preserving like
597    /// `LoopStuckSignal` so the guard can carry richer detail without inventing
598    /// another wire kind.
599    ReservedTerminalVerify {
600        session_id: String,
601        payload: serde_json::Value,
602    },
603    /// Emitted when the daemon idle-wait loop trips its watchdog because
604    /// every configured wake source returned `None` for N consecutive
605    /// attempts. Exists so a broken daemon doesn't hang the session
606    /// silently.
607    DaemonWatchdogTripped {
608        session_id: String,
609        attempts: usize,
610        elapsed_ms: u64,
611    },
612    /// Emitted when a skill is activated. Carries the match reason so
613    /// replayers can reconstruct *why* a given skill took effect at
614    /// this iteration.
615    SkillActivated {
616        session_id: String,
617        skill_name: String,
618        iteration: usize,
619        reason: String,
620    },
621    /// Emitted when a previously-active skill is deactivated because
622    /// the reassess phase no longer matches it.
623    SkillDeactivated {
624        session_id: String,
625        skill_name: String,
626        iteration: usize,
627    },
628    /// Emitted once per activation when the skill's `allowed_tools` filter
629    /// narrows the effective tool surface exposed to the model.
630    SkillScopeTools {
631        session_id: String,
632        skill_name: String,
633        allowed_tools: Vec<String>,
634    },
635    /// Emitted when the agent loop ratchets the model-visible tool
636    /// surface narrower after observing recent tool-call usage. Unlike
637    /// `SkillScopeTools`, this is session-local and can only remove
638    /// tools from the currently-effective surface.
639    SkillNarrow {
640        session_id: String,
641        reason: String,
642        removed_tools: Vec<String>,
643        remaining_tools: Vec<String>,
644        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
645        policy: serde_json::Value,
646        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
647        removed_tool_details: serde_json::Value,
648        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
649        kept_tool_details: serde_json::Value,
650    },
651    /// Read-only stance lifecycle (std/agent/stance): `phase` is one of
652    /// `armed`, `write_access_granted`, `write_access_denied`,
653    /// `disarmed`. Arming carries the permitted tool window; the
654    /// grant/deny phases carry the escape-hatch justification and the
655    /// consent verdict so a trace viewer can explain every elevation.
656    StanceTransition {
657        session_id: String,
658        phase: String,
659        escape_tool: String,
660        #[serde(default, skip_serializing_if = "Vec::is_empty")]
661        allowed_tools: Vec<String>,
662        #[serde(default, skip_serializing_if = "String::is_empty")]
663        justification: String,
664        #[serde(default, skip_serializing_if = "String::is_empty")]
665        consent: String,
666        #[serde(default, skip_serializing_if = "String::is_empty")]
667        reason: String,
668    },
669    /// Emitted when a `tool_search` query is issued by the model. Carries
670    /// the raw query args, the configured strategy, and a `mode` tag
671    /// distinguishing the client-executed fallback (`"client"`) from
672    /// provider-native paths (`"anthropic"` / `"openai"`). Mirrors the
673    /// transcript event shape so hosts can render a search-in-progress
674    /// chip in real time — the replay path walks the transcript after
675    /// the turn, which is too late for live UX.
676    ToolSearchQuery {
677        session_id: String,
678        tool_use_id: String,
679        name: String,
680        query: serde_json::Value,
681        strategy: String,
682        mode: String,
683    },
684    /// Emitted when `tool_search` resolves — carries the list of tool
685    /// names newly promoted into the model's effective surface for the
686    /// next turn. Pair-emitted with `ToolSearchQuery` on every search.
687    ToolSearchResult {
688        session_id: String,
689        tool_use_id: String,
690        promoted: Vec<String>,
691        strategy: String,
692        mode: String,
693    },
694    /// A transcript compaction completed. Carries the one canonical
695    /// [`CompactionReceipt`] (harn#4995) — the same receipt embedded verbatim in
696    /// the transcript `compaction` event, forwarded through ACP, and projected
697    /// into `RunObservabilityRecord.compaction_events`. `receipt.receipt_id` is
698    /// the stable identity shared across all four surfaces, so hosts never
699    /// synthesize their own.
700    TranscriptCompacted {
701        session_id: String,
702        receipt: crate::orchestration::CompactionReceipt,
703    },
704    /// Emitted whenever `transcript_project` derives a model-visible
705    /// prefix from the immutable raw transcript. Hosts that render a
706    /// side-by-side raw/projected view subscribe to this — the typed
707    /// payload mirrors the metadata on the persisted
708    /// `transcript.projection` transcript event so clients don't have to
709    /// re-parse the transcript to sync UI state.
710    TranscriptProjected {
711        session_id: String,
712        policy: String,
713        reason: String,
714        prefix_hash: String,
715        kept_count: usize,
716        dropped_count: usize,
717        provider_safety_blocked: bool,
718        #[serde(default, skip_serializing_if = "is_zero_usize")]
719        redacted_count: usize,
720        #[serde(default, skip_serializing_if = "is_zero_usize")]
721        reclaimed_tokens: usize,
722        #[serde(default, skip_serializing_if = "Vec::is_empty")]
723        roots_consulted: Vec<String>,
724        #[serde(default, skip_serializing_if = "Vec::is_empty")]
725        redaction_pointers: Vec<serde_json::Value>,
726    },
727    /// Emitted when a pending `system_reminder` is rendered into the
728    /// next provider request. ACP clients show these in a reminder lane
729    /// instead of mixing them into assistant text chunks.
730    ReminderEmitted {
731        session_id: String,
732        reminder_id: String,
733        tags: Vec<String>,
734        body: String,
735        role_hint: String,
736        authority: String,
737        rendered_role: String,
738        source: String,
739        ttl_turns: Option<i64>,
740    },
741    Handoff {
742        session_id: String,
743        artifact_id: String,
744        handoff: Box<HandoffArtifact>,
745    },
746    FsWatch {
747        session_id: String,
748        subscription_id: String,
749        events: Vec<FsWatchEvent>,
750    },
751    /// Emitted when hostlib staged filesystem state changes for a session.
752    /// The ACP adapter maps this to the existing `progress` extension so
753    /// clients can update rollup-diff badges without waiting for a prompt
754    /// turn boundary.
755    StagedWritesPending {
756        session_id: String,
757        pending_count: usize,
758        total_bytes: u64,
759        pending_writes: Vec<StagedWriteSummary>,
760    },
761    /// Per-call outcome of `hostlib_fs_safe_text_patch`. Hosts subscribe to
762    /// this to roll up stale-base / hunk-conflict rates and average
763    /// hunks-per-patch without scraping result dicts out of pipeline logs.
764    /// Fired from both the staged-overlay and direct-disk code paths so
765    /// the rollup is comprehensive.
766    SafeTextPatchResult {
767        session_id: String,
768        path: String,
769        result: String,
770        hunks_count: usize,
771        bytes_written: u64,
772        #[serde(default, skip_serializing_if = "Option::is_none")]
773        failed_hunk_index: Option<usize>,
774    },
775    /// ACP control-plane arbitration outcome. Emitted for accepted,
776    /// idempotent, and rejected controls so replay/audit consumers can show
777    /// who acted and why a late or unauthorized action lost.
778    ControlOutcome {
779        session_id: String,
780        control_id: String,
781        method: String,
782        outcome: String,
783        status: String,
784        actor: serde_json::Value,
785        target: serde_json::Value,
786        #[serde(default, skip_serializing_if = "Option::is_none")]
787        reason: Option<String>,
788        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
789        metadata: serde_json::Value,
790    },
791    /// Lifecycle update for a delegated/background worker. Carries the
792    /// canonical typed `event` variant alongside the worker's current
793    /// `status` string and the structured `metadata` payload that
794    /// `worker_bridge_metadata` builds (task, mode, timing, child
795    /// run/snapshot paths, audit-session, etc.). The `audit` field is
796    /// the same `MutationSessionRecord` JSON serialization carried on
797    /// the bridge wire so ACP/A2A consumers don't need to re-derive it.
798    ///
799    /// One-to-one with the bridge-side `worker_update` session-update
800    /// notification: ACP and A2A adapters subscribe to this variant
801    /// and translate it into their respective wire formats. The
802    /// `session_id` is the parent agent session that owns the worker
803    /// (i.e. the session whose VM spawned the worker), so a single
804    /// host stays subscribed to the same sink for both message and
805    /// worker traffic.
806    WorkerUpdate {
807        session_id: String,
808        worker_id: String,
809        worker_name: String,
810        worker_task: String,
811        worker_mode: String,
812        event: WorkerEvent,
813        status: String,
814        metadata: serde_json::Value,
815        audit: Option<serde_json::Value>,
816    },
817    /// Exactly-once terminal lifecycle record for `sub_agent_run`.
818    /// Unlike the generic worker update, this is present for both foreground
819    /// and background delegation and preserves the parent/child lineage.
820    SubagentStop {
821        session_id: String,
822        parent_run_id: String,
823        child_run_id: String,
824        terminal_status: SubagentTerminalStatus,
825        terminal_class: String,
826        reason: String,
827        #[serde(default, skip_serializing_if = "Option::is_none")]
828        result_ref: Option<String>,
829        #[serde(default, skip_serializing_if = "Option::is_none")]
830        receipt_ref: Option<String>,
831        #[serde(default, skip_serializing_if = "Option::is_none")]
832        cancellation: Option<serde_json::Value>,
833        #[serde(default, skip_serializing_if = "Option::is_none")]
834        timeout: Option<serde_json::Value>,
835        completed_at_ms: i64,
836    },
837    /// A human-in-the-loop primitive (`ask_user`, `request_approval`,
838    /// `dual_control`, `escalate`) has just suspended the script and is
839    /// waiting on a response. Hosts that bridge the VM onto a remote
840    /// transport (ACP, A2A) translate this into a "paused / awaiting
841    /// input" wire signal so the client knows the task isn't stuck —
842    /// it's blocked on the human side. Pair-emitted with `HitlResolved`
843    /// when the waitpoint completes/cancels/times out.
844    HitlRequested {
845        session_id: String,
846        request_id: String,
847        kind: String,
848        payload: serde_json::Value,
849    },
850    /// Companion to `HitlRequested`: the waitpoint has resolved (either
851    /// a response arrived, the deadline elapsed, or the request was
852    /// cancelled). `outcome` is one of `"answered"`, `"timeout"`,
853    /// `"cancelled"`. Hosts use this to flip task state back to
854    /// `working` after an `input-required` pause.
855    HitlResolved {
856        session_id: String,
857        request_id: String,
858        kind: String,
859        outcome: String,
860    },
861    /// Emitted by the agent loop's adaptive iteration budget /
862    /// `loop_control` policy when a budget extension or early stop fires.
863    /// Generic enough to cover both shapes — `action` distinguishes them.
864    /// Carries the iteration the decision applied to, the previous /
865    /// resulting iteration limit, the policy reason string, and (for
866    /// stops) the loop status.
867    LoopControlDecision {
868        session_id: String,
869        iteration: usize,
870        action: String,
871        old_limit: usize,
872        new_limit: usize,
873        reason: String,
874        status: String,
875    },
876    /// Emitted when `agent_loop` detects adjacent repeated tool calls with
877    /// identical arguments. The warning payload avoids raw arguments by
878    /// default and carries digests so hosts can correlate repeats without
879    /// exposing potentially sensitive tool inputs.
880    AgentLoopStallWarning {
881        session_id: String,
882        warning: serde_json::Value,
883    },
884    /// Emitted when a concrete provider/model pair lacks a catalog
885    /// recommendation for a capability and the runtime chooses a fallback.
886    CapabilityGap {
887        session_id: String,
888        level: String,
889        capability: String,
890        provider: String,
891        model: String,
892        fallback_tool_format: String,
893        #[serde(default, skip_serializing_if = "Option::is_none")]
894        requested_tool_format: Option<String>,
895        message: String,
896    },
897    /// Emitted when a caller explicitly forces a tool format that
898    /// differs from the capability catalog's recommendation or known
899    /// native/text parity guidance.
900    ToolFormatOverride {
901        session_id: String,
902        provider: String,
903        model: String,
904        requested_format: String,
905        recommended_format: String,
906        catalog_parity: String,
907        #[serde(default, skip_serializing_if = "Option::is_none")]
908        override_reason: Option<String>,
909    },
910    /// Emitted when a `tool_caller` middleware (see std/llm/tool_middleware)
911    /// attaches structured audit metadata to a tool call — typically a
912    /// user-facing `summary`, a `description`, an ACP-style `kind`, an MCP
913    /// `hints` block, a `consent` decision, the per-layer `layers` log, or
914    /// free-form `metadata` keys (A2A-style extension slot).
915    ///
916    /// One-to-one with the underlying tool-call: hosts can join on
917    /// `tool_call_id` to render middleware-attached chips alongside the
918    /// existing `ToolCall` / `ToolCallUpdate` stream. The `audit` payload
919    /// is intentionally free-form JSON so middleware can carry whatever
920    /// shape the harness author chooses without needing protocol-level
921    /// changes per new middleware. When present, `receipt` carries the
922    /// stable typed, privacy-preserving record hosts can persist or mirror.
923    ToolCallAudit {
924        session_id: String,
925        tool_call_id: String,
926        tool_name: String,
927        audit: serde_json::Value,
928        #[serde(default, skip_serializing_if = "Option::is_none")]
929        receipt: Option<ToolCallReceipt>,
930    },
931    /// Records the execute/defer/skip decision for every call in a
932    /// model-proposed tool batch.
933    ToolBatchDisposition {
934        session_id: String,
935        receipt: ToolBatchDispositionReceipt,
936    },
937    /// Emitted by `std/cache::with_cache` (both the generic and LLM
938    /// forms) when a cached lookup returns a hit. Carries the
939    /// content-addressed key, the backend that served the value, and a
940    /// `metrics` block with the cost-moat receipts the persona value
941    /// ledger (a cloud platform) and crystallization receipts read:
942    /// `model_calls_avoided`, plus `tokens_saved` / `latency_saved_ms`
943    /// when the cached envelope carried `usage` / `latency_ms`.
944    CacheHit {
945        session_id: String,
946        key: String,
947        backend: String,
948        namespace: String,
949        payload: serde_json::Value,
950    },
951    /// Paired with `CacheHit`. Emitted on the miss path when the
952    /// fresh result is stored. `payload.metrics.compute_ms` carries
953    /// the wall-clock cost of the underlying computation, which
954    /// callers can feed back as `estimate.latency_saved_ms` on the
955    /// next hit.
956    CacheMiss {
957        session_id: String,
958        key: String,
959        backend: String,
960        namespace: String,
961        payload: serde_json::Value,
962    },
963    /// A language-neutral tool-composition snippet has started. The envelope
964    /// identifies the snippet and binding manifest hashes plus the side-effect
965    /// ceiling requested for the whole parent run.
966    CompositionStart {
967        session_id: String,
968        run: CompositionRunEnvelope,
969    },
970    /// A composition snippet is dispatching a child binding call. The child
971    /// remains visible as its own operation with annotations and policy context
972    /// instead of being hidden inside the parent composition blob.
973    CompositionChildCall {
974        session_id: String,
975        call: CompositionChildCall,
976    },
977    /// A child binding operation emitted a status/result update.
978    CompositionChildResult {
979        session_id: String,
980        result: CompositionChildResult,
981    },
982    /// A composition run finished successfully and carries stdout/stderr,
983    /// artifacts, and the structured result in the terminal envelope.
984    CompositionFinish {
985        session_id: String,
986        run: CompositionRunEnvelope,
987    },
988    /// A composition run failed before producing a successful terminal result.
989    /// The terminal envelope carries the failure category and optional error.
990    CompositionError {
991        session_id: String,
992        run: CompositionRunEnvelope,
993    },
994    /// Emitted once per `__agent_loop_checkpoint(...)` pass. The single
995    /// named seam through which the agent loop drains queued bridge
996    /// injections and inbox feedback. Hosts use it to debug "did the
997    /// loop check for steering at the expected boundary" without having
998    /// to grep the loop body for inline drain calls.
999    ///
1000    /// `kind` is one of the documented seam names: `iteration_start`,
1001    /// `pre_tool_dispatch`, `post_tool_dispatch`, `iteration_end`,
1002    /// `pre_compact`, `post_compact`, `daemon_idle_pre`,
1003    /// `daemon_idle_post`, `loop_exit`. `delivered` is the count of
1004    /// bridge injections drained at this seam (inbox drains are
1005    /// reported separately under `inbox_delivered`). Typed host
1006    /// injections delivered from `agent_inbox` are reported under
1007    /// `typed_delivered`. `dispatch_skipped` is true only when an
1008    /// `interrupt_immediate` injection arrived at `pre_tool_dispatch`
1009    /// and the pending tool batch was skipped.
1010    LoopCheckpoint {
1011        session_id: String,
1012        iteration: usize,
1013        kind: String,
1014        delivered: usize,
1015        #[serde(default, skip_serializing_if = "is_zero_usize")]
1016        inbox_delivered: usize,
1017        #[serde(default, skip_serializing_if = "is_zero_usize")]
1018        typed_delivered: usize,
1019        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1020        dispatch_skipped: bool,
1021    },
1022    /// Surfaced when Harn is acting as an MCP **client** and a peer
1023    /// server sends a server-to-client message during an agent session:
1024    /// a `notifications/progress` / `notifications/message` /
1025    /// `notifications/*/list_changed` notification, or an inbound
1026    /// `elicitation/create` / `sampling/createMessage` request.
1027    ///
1028    /// Emitted alongside (not in place of) the existing agent-inbox
1029    /// relay so a thin ACP client can render a live progress bar, log
1030    /// line, elicitation prompt, or sampling affordance without parsing
1031    /// the inbox transcript. `direction` is `"notification"` for
1032    /// fire-and-forget server notifications and `"request"` for inbound
1033    /// requests that still resolve through the existing client-role
1034    /// dispatch path (this event does not change that response). `method`
1035    /// is the raw MCP JSON-RPC method; `params` is its untouched payload.
1036    McpNotification {
1037        session_id: String,
1038        server: String,
1039        method: String,
1040        direction: String,
1041        params: serde_json::Value,
1042    },
1043    /// Surfaced when the effective MCP catalog changes — either because a
1044    /// server emitted a `notifications/tools/list_changed` (or the
1045    /// resource/prompt equivalents), or because the persisted enable/disable
1046    /// allowlist was edited. A thin ACP client (an IDE host's TUI / GUI)
1047    /// treats this as a cue to re-fetch the catalog (e.g. via the
1048    /// `mcp/catalog` request) and re-render its toggle UI, rather than
1049    /// reconciling any local state. `server` is the server whose list
1050    /// changed, or `None` when the change is allowlist-wide. `reason` is a
1051    /// short tag (`"list_changed"` or `"allowlist_updated"`).
1052    McpCatalogChanged {
1053        session_id: String,
1054        #[serde(default, skip_serializing_if = "Option::is_none")]
1055        server: Option<String>,
1056        reason: String,
1057    },
1058    /// Surfaced when an MCP server harn is acting as a client for answers a
1059    /// request with `401 Unauthorized` mid-session, meaning its OAuth token is
1060    /// missing or expired. This is a cue for a thin ACP client (an IDE host's
1061    /// TUI / GUI) to start an authorization: call `mcp/authorize` to mint a
1062    /// browser URL, open it, and forward the redirect's `code`+`state` back via
1063    /// `mcp/oauth_callback`. Token exchange and storage stay in harn. `server`
1064    /// is the configured server name; `resource` is its canonical RFC 8707
1065    /// resource indicator; `scope` is the `scope` parameter from the
1066    /// `WWW-Authenticate` challenge, when present.
1067    McpAuthRequired {
1068        session_id: String,
1069        server: String,
1070        resource: String,
1071        #[serde(default, skip_serializing_if = "Option::is_none")]
1072        scope: Option<String>,
1073    },
1074    /// A boundary between model-produced bytes and executed action declined to
1075    /// carry part of that flow: a parser dropped a span, an extractor skipped a
1076    /// content block, a sanitizer stripped text, a cap stopped a loop.
1077    ///
1078    /// This is the one wire form of the loud-boundary invariant (harn#5142).
1079    /// Before it existed, each of those losses was invisible, and every
1080    /// downstream signal — stall feedback, the rate governor, the verdict layer
1081    /// — read the resulting inaction as model pathology. `owner` carries the
1082    /// attribution in the same vocabulary as
1083    /// [`super::AgentTerminalKind::owner`], so a consumer can bucket the run as
1084    /// harness/policy fault without re-deriving it from `boundary`.
1085    ///
1086    /// Constructed through [`crate::boundary::BoundaryFailure`], never by hand;
1087    /// `scripts/loud_boundaries.toml` enumerates every boundary that can emit
1088    /// it and `make check-loud-boundaries` keeps that enumeration honest.
1089    BoundaryFailure {
1090        session_id: String,
1091        boundary: crate::boundary::BoundaryId,
1092        kind: crate::boundary::BoundaryFailureKind,
1093        owner: String,
1094        detail: String,
1095        #[serde(default, skip_serializing_if = "Option::is_none")]
1096        excerpt: Option<String>,
1097        #[serde(default, skip_serializing_if = "is_zero_usize")]
1098        dropped_count: usize,
1099        #[serde(default, skip_serializing_if = "is_zero_usize")]
1100        dropped_bytes: usize,
1101        /// Set when the failure reached the bus through the `Drop` backstop
1102        /// rather than an explicit `report()` — i.e. a drop path built the
1103        /// record and then lost track of it. Always `false` in normal
1104        /// operation; a `true` here is a harness bug worth fixing.
1105        #[serde(default, skip_serializing_if = "is_false")]
1106        unreported: bool,
1107    },
1108}
1109
1110fn is_zero_usize(value: &usize) -> bool {
1111    *value == 0
1112}
1113
1114fn is_false(value: &bool) -> bool {
1115    !*value
1116}
1117
1118impl AgentEvent {
1119    pub fn session_id(&self) -> &str {
1120        match self {
1121            Self::AgentMessageChunk { session_id, .. }
1122            | Self::AgentThoughtChunk { session_id, .. }
1123            | Self::UserMessage { session_id, .. }
1124            | Self::ToolCall { session_id, .. }
1125            | Self::ToolCallUpdate { session_id, .. }
1126            | Self::PlanDocumentUpdated { session_id, .. }
1127            | Self::OrchestrationDecision { session_id, .. }
1128            | Self::ProgressReported { session_id, .. }
1129            | Self::CompassRoutingDecision { session_id, .. }
1130            | Self::AgentScratchpadReorganization { session_id, .. }
1131            | Self::Artifact { session_id, .. }
1132            | Self::IterationStart { session_id, .. }
1133            | Self::IterationEnd { session_id, .. }
1134            | Self::SessionClosed { session_id, .. }
1135            | Self::AnchorChanged { session_id, .. }
1136            | Self::JudgeDecision { session_id, .. }
1137            | Self::StepJudgeDecision { session_id, .. }
1138            | Self::StructuralValidatorDecision { session_id, .. }
1139            | Self::ScopeClassifierVerdict { session_id, .. }
1140            | Self::InputGuardrailVerdict { session_id, .. }
1141            | Self::MissingToolCallVerdict { session_id, .. }
1142            | Self::RequireSuccessfulToolsViolation { session_id, .. }
1143            | Self::FinalWrapup { session_id, .. }
1144            | Self::PackThinkingStripped { session_id, .. }
1145            | Self::SelfConsistencyTie { session_id, .. }
1146            | Self::CodeLibrarianQueryNlFallback { session_id, .. }
1147            | Self::TypedCheckpoint { session_id, .. }
1148            | Self::FeedbackInjected { session_id, .. }
1149            | Self::HostToolResult { session_id, .. }
1150            | Self::HostAttachment { session_id, .. }
1151            | Self::BudgetExhausted { session_id, .. }
1152            | Self::BudgetCircuitBreaker { session_id, .. }
1153            | Self::LoopStuck { session_id, .. }
1154            | Self::LoopStuckSignal { session_id, .. }
1155            | Self::ReservedTerminalVerify { session_id, .. }
1156            | Self::DaemonWatchdogTripped { session_id, .. }
1157            | Self::SkillActivated { session_id, .. }
1158            | Self::SkillDeactivated { session_id, .. }
1159            | Self::SkillScopeTools { session_id, .. }
1160            | Self::SkillNarrow { session_id, .. }
1161            | Self::StanceTransition { session_id, .. }
1162            | Self::ToolSearchQuery { session_id, .. }
1163            | Self::ToolSearchResult { session_id, .. }
1164            | Self::TranscriptCompacted { session_id, .. }
1165            | Self::TranscriptProjected { session_id, .. }
1166            | Self::ReminderEmitted { session_id, .. }
1167            | Self::Handoff { session_id, .. }
1168            | Self::FsWatch { session_id, .. }
1169            | Self::StagedWritesPending { session_id, .. }
1170            | Self::SafeTextPatchResult { session_id, .. }
1171            | Self::ControlOutcome { session_id, .. }
1172            | Self::WorkerUpdate { session_id, .. }
1173            | Self::SubagentStop { session_id, .. }
1174            | Self::HitlRequested { session_id, .. }
1175            | Self::HitlResolved { session_id, .. }
1176            | Self::LoopControlDecision { session_id, .. }
1177            | Self::AgentLoopStallWarning { session_id, .. }
1178            | Self::CapabilityGap { session_id, .. }
1179            | Self::ToolFormatOverride { session_id, .. }
1180            | Self::ToolCallAudit { session_id, .. }
1181            | Self::ToolBatchDisposition { session_id, .. }
1182            | Self::CacheHit { session_id, .. }
1183            | Self::CacheMiss { session_id, .. }
1184            | Self::CompositionStart { session_id, .. }
1185            | Self::CompositionChildCall { session_id, .. }
1186            | Self::CompositionChildResult { session_id, .. }
1187            | Self::CompositionFinish { session_id, .. }
1188            | Self::CompositionError { session_id, .. }
1189            | Self::LoopCheckpoint { session_id, .. }
1190            | Self::McpNotification { session_id, .. }
1191            | Self::McpCatalogChanged { session_id, .. }
1192            | Self::McpAuthRequired { session_id, .. }
1193            | Self::BoundaryFailure { session_id, .. } => session_id,
1194        }
1195    }
1196}