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}