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