Skip to main content

harn_vm/agent_events/
agent.rs

1use serde::{Deserialize, Serialize};
2
3use crate::composition::{CompositionChildCall, CompositionChildResult, CompositionRunEnvelope};
4use crate::llm::receipts::ToolCallReceipt;
5use crate::orchestration::{HandoffArtifact, MutationSessionRecord};
6use crate::tool_annotations::ToolKind;
7
8use super::tool::{ToolCallErrorCategory, ToolCallStatus, ToolExecutor};
9use super::worker::{FsWatchEvent, WorkerEvent};
10
11/// Events emitted by the agent loop. Some variants map 1:1 to ACP
12/// `sessionUpdate` variants; Harn-specific lifecycle events ride on the
13/// extension stream.
14#[derive(Clone, Debug, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum AgentEvent {
17    AgentMessageChunk {
18        session_id: String,
19        content: String,
20    },
21    AgentThoughtChunk {
22        session_id: String,
23        content: String,
24    },
25    UserMessage {
26        session_id: String,
27        message_id: String,
28        content: Vec<serde_json::Value>,
29    },
30    ToolCall {
31        session_id: String,
32        tool_call_id: String,
33        tool_name: String,
34        kind: Option<ToolKind>,
35        status: ToolCallStatus,
36        raw_input: serde_json::Value,
37        /// Set to `Some(true)` by the streaming candidate detector
38        /// (harn#692) when this event represents a tool-call shape
39        /// detected in the model's in-flight assistant text but whose
40        /// arguments have not finished parsing yet. Clients can render a
41        /// spinner / placeholder while the model writes the body. The
42        /// detector follows up with a `ToolCallUpdate { parsing: false,
43        /// .. }` carrying either `status: pending` (promoted) or
44        /// `status: failed` with `error_category: parse_aborted`.
45        /// `None` (the default) means "this is a normal post-parse tool
46        /// call, no candidate phase was active" so the on-disk shape
47        /// stays compatible with replays recorded before this field
48        /// existed.
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        parsing: Option<bool>,
51        /// Mutation-session audit context active when the tool was
52        /// dispatched (see harn#699). Hosts use it to group every tool
53        /// emission belonging to the same write-capable session.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        audit: Option<MutationSessionRecord>,
56    },
57    ToolCallUpdate {
58        session_id: String,
59        tool_call_id: String,
60        tool_name: String,
61        status: ToolCallStatus,
62        raw_output: Option<serde_json::Value>,
63        error: Option<String>,
64        /// Wall-clock milliseconds from the parse-to-execution boundary
65        /// to the terminal `Completed`/`Failed` update. Includes the
66        /// time spent in any wrapping orchestration logic (loop checks,
67        /// post-tool hooks, microcompaction). Populated only on the
68        /// terminal update — `None` on intermediate `Pending` /
69        /// `InProgress` updates so clients can ignore the field until
70        /// it shows up.
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        duration_ms: Option<u64>,
73        /// Milliseconds spent in the actual host/builtin/MCP dispatch
74        /// call only (the inner `dispatch_tool_execution` window).
75        /// Populated only on the terminal update; `None` otherwise.
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        execution_duration_ms: Option<u64>,
78        /// Structured classification of the failure (when `status` is
79        /// `Failed`). Paired with `error` so clients can render each
80        /// category distinctly without parsing free-form strings. Always
81        /// `None` for non-Failed updates and serialized as
82        /// `errorCategory` in the ACP wire format.
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        error_category: Option<ToolCallErrorCategory>,
85        /// Where the tool actually ran. `None` only for events emitted
86        /// from sites that pre-date the dispatch decision (e.g. the
87        /// pending → in-progress transition the loop emits before the
88        /// dispatcher picks a backend).
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        executor: Option<ToolExecutor>,
91        /// Companion to `ToolCall.parsing` (harn#692). The streaming
92        /// candidate detector emits the *terminal* candidate event as a
93        /// `ToolCallUpdate` with `parsing: Some(false)` to retract the
94        /// in-flight `parsing: true` chip — either by promoting the
95        /// candidate (`status: pending`, populated `raw_output: None`,
96        /// `error: None`) or aborting it (`status: failed`,
97        /// `error_category: parse_aborted`). `None` means this update is
98        /// not part of a candidate-phase transition.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        parsing: Option<bool>,
101        /// Best-effort partial parse of the streamed tool-call arguments.
102        /// Populated by the SSE transport on `Pending` updates as the
103        /// model streams `input_json_delta` (Anthropic) or
104        /// `tool_calls[].function.arguments` deltas (OpenAI). `None` on
105        /// terminal updates and on emissions from non-streaming paths
106        /// (#693). When the partial bytes are not yet parseable as JSON
107        /// the transport falls back to `raw_input_partial`.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        raw_input: Option<serde_json::Value>,
110        /// Raw concatenated bytes of the streamed tool-call arguments
111        /// when a permissive parse failed (#693). Mutually exclusive
112        /// with `raw_input`: clients render whichever is present.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        raw_input_partial: Option<String>,
115        /// Mutation-session audit context for the tool call. Carries the
116        /// same payload as on the paired `ToolCall` event so a host
117        /// processing a single update doesn't have to correlate against
118        /// the prior pending event.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        audit: Option<MutationSessionRecord>,
121    },
122    Plan {
123        session_id: String,
124        plan: serde_json::Value,
125    },
126    ProgressReported {
127        session_id: String,
128        message: Option<String>,
129        entries: serde_json::Value,
130        replace: bool,
131        metadata: serde_json::Value,
132    },
133    /// Emitted when the compass observes a freeform edit and either
134    /// suggests a structural primitive, rewrites the tool call, or falls
135    /// back because rewrite mode could not prove equivalence.
136    CompassRoutingDecision {
137        session_id: String,
138        tool_call_id: String,
139        mode: String,
140        action: String,
141        persona: String,
142        original_tool: String,
143        routed_tool: String,
144        target_tool: String,
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        path: Option<String>,
147    },
148    /// Emitted after an agent scratchpad reorganization attempt. The
149    /// scratchpad body itself stays in session state; this event carries
150    /// status and count/error metadata so live UIs and eval harnesses can
151    /// audit whether reorganization helped or damaged the working set.
152    AgentScratchpadReorganization {
153        session_id: String,
154        iteration: usize,
155        status: String,
156        details: serde_json::Value,
157    },
158    /// A renderable, declarative artifact spec emitted by an agent. Harn
159    /// validates the payload and transports it; host surfaces own rendering
160    /// and may fall back to the plain-text representation.
161    Artifact {
162        session_id: String,
163        artifact_id: String,
164        kind: String,
165        #[serde(default, skip_serializing_if = "Option::is_none")]
166        title: Option<String>,
167        mime_type: String,
168        spec: serde_json::Value,
169        fallback: String,
170        size_bytes: u64,
171        provenance: serde_json::Value,
172        metadata: serde_json::Value,
173    },
174    /// Fires at the top of every model round-trip inside an
175    /// `agent_loop` invocation. Maps to the `iteration_start` steering
176    /// seam. Not the same as ACP's outer `prompt_turn` boundary; an
177    /// `agent_turn`/`prompt_turn` cycle contains many of these.
178    IterationStart {
179        session_id: String,
180        iteration: usize,
181        /// Configured provider for the impending LLM call. Empty when the
182        /// caller defers provider selection to the routing layer.
183        /// Surfaces here so observers can show "about to call X/Y" before
184        /// the call returns — previously this only landed in the
185        /// transcript after the response, leaving live pulse-check
186        /// consumers without a model attribution for in-flight iterations.
187        #[serde(default, skip_serializing_if = "String::is_empty")]
188        provider: String,
189        /// Configured model id. Same semantics as `provider`.
190        #[serde(default, skip_serializing_if = "String::is_empty")]
191        model: String,
192    },
193    /// Fires at the bottom of every model round-trip, after tool
194    /// dispatch (or after the dispatch was skipped). Sibling of
195    /// `IterationStart`.
196    IterationEnd {
197        session_id: String,
198        iteration: usize,
199        /// Free-form dict carrying the post-call snapshot. Stable keys
200        /// emitted by the agent loop: `tool_count`, `text`, plus the
201        /// LLM-result projection — `provider`, `model`, `response_ms`,
202        /// `input_tokens`, `output_tokens`, `thinking_chars`. Hosts that
203        /// surface latency/cost panes key off these without re-parsing
204        /// the transcript JSONL.
205        iteration_info: serde_json::Value,
206    },
207    /// Emitted when a first-class agent session is explicitly closed by
208    /// `agent_session_close`. This gives event-log consumers a typed
209    /// terminal marker even when no final model turn runs.
210    SessionClosed {
211        session_id: String,
212        reason: String,
213        status: String,
214        metadata: serde_json::Value,
215    },
216    /// Emitted when `agent_session_reanchor` swaps the primary workspace
217    /// anchor (#2218). Hosts use this to drive cross-project handoff UX.
218    /// Carries the previous and current anchors so consumers can diff
219    /// without re-fetching session state.
220    AnchorChanged {
221        session_id: String,
222        previous: Option<serde_json::Value>,
223        current: serde_json::Value,
224        carry_transcript: bool,
225        compacted: bool,
226        reason: Option<String>,
227    },
228    JudgeDecision {
229        session_id: String,
230        iteration: usize,
231        verdict: String,
232        reasoning: String,
233        next_step: Option<String>,
234        judge_duration_ms: u64,
235        #[serde(default, skip_serializing_if = "Option::is_none")]
236        source: Option<String>,
237        #[serde(default, skip_serializing_if = "Option::is_none")]
238        trigger: Option<String>,
239        #[serde(default, skip_serializing_if = "Option::is_none")]
240        reason: Option<String>,
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        confirm: Option<bool>,
243        #[serde(default, skip_serializing_if = "Option::is_none")]
244        converted_from: Option<String>,
245        #[serde(default, skip_serializing_if = "Vec::is_empty")]
246        specific_gaps: Vec<String>,
247        #[serde(default, skip_serializing_if = "Vec::is_empty")]
248        accepted_evidence: Vec<String>,
249    },
250    /// Per-step critique decision emitted by `agent_step_judge`.
251    /// Sibling of [`JudgeDecision`] but fired BEFORE tool dispatch on
252    /// every assistant turn (when configured), not just at completion.
253    /// `on_veto` carries the configured remediation shape
254    /// (`"replace"` or `"retain"`); `cost_usd` is best-effort from the
255    /// stdlib economics estimator and may be 0 when pricing is unknown.
256    /// `skipped` marks configured short-circuits that did not call the
257    /// judge model.
258    StepJudgeDecision {
259        session_id: String,
260        iteration: usize,
261        verdict: String,
262        reasoning: String,
263        critique: String,
264        confidence: f64,
265        judge_duration_ms: u64,
266        vetoed: bool,
267        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
268        skipped: bool,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        reason: Option<String>,
271        /// True when this `verdict: "pass"` is the result of the step-judge
272        /// model itself erroring and `fail_open` swallowing the error — the
273        /// turn proceeded, but the adversarial-review surface was UNAVAILABLE
274        /// (not a genuine approval). Lets telemetry tell an inert reviewer
275        /// apart from a real pass. Mirrors `reason: "judge_unavailable"`.
276        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
277        judge_error: bool,
278        on_veto: String,
279        input_tokens: u64,
280        output_tokens: u64,
281        cost_usd: f64,
282        provider: String,
283        model: String,
284    },
285    /// Deterministic pre-dispatch critique emitted by the structural
286    /// validator middleware. Fires before any LLM-backed judge so hosts
287    /// can distinguish "$0 structural retry" from semantic critique.
288    StructuralValidatorDecision {
289        session_id: String,
290        iteration: usize,
291        rule: String,
292        diagnostic: String,
293        recommended_action: String,
294        vetoed: bool,
295        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
296        skipped: bool,
297        #[serde(default, skip_serializing_if = "Option::is_none")]
298        reason: Option<String>,
299        on_failure: String,
300        attempts: usize,
301        max_attempts: usize,
302    },
303    ScopeClassifierVerdict {
304        session_id: String,
305        iteration: usize,
306        label: String,
307        original_label: String,
308        confidence: f64,
309        confidence_threshold: f64,
310        evidence: String,
311        skip_main_turn: bool,
312        #[serde(default, skip_serializing_if = "Option::is_none")]
313        classifier_kind: Option<String>,
314        #[serde(default, skip_serializing_if = "Option::is_none")]
315        model: Option<String>,
316        #[serde(default, skip_serializing_if = "Option::is_none")]
317        error: Option<String>,
318    },
319    InputGuardrailVerdict {
320        session_id: String,
321        iteration: usize,
322        tripwire: bool,
323        reason: String,
324        label: String,
325        confidence: f64,
326        confidence_threshold: f64,
327        #[serde(default, skip_serializing_if = "Option::is_none")]
328        classifier_kind: Option<String>,
329        #[serde(default, skip_serializing_if = "Option::is_none")]
330        model: Option<String>,
331        #[serde(default, skip_serializing_if = "Option::is_none")]
332        error: Option<String>,
333    },
334    MissingToolCallVerdict {
335        session_id: String,
336        iteration: usize,
337        action: String,
338        original_action: String,
339        tool_name: String,
340        confidence: f64,
341        confidence_threshold: f64,
342        evidence: String,
343        #[serde(default, skip_serializing_if = "Option::is_none")]
344        language: Option<String>,
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        classifier_kind: Option<String>,
347        #[serde(default, skip_serializing_if = "Option::is_none")]
348        model: Option<String>,
349        #[serde(default, skip_serializing_if = "Option::is_none")]
350        error: Option<String>,
351    },
352    TypedCheckpoint {
353        session_id: String,
354        checkpoint: serde_json::Value,
355    },
356    FeedbackInjected {
357        session_id: String,
358        kind: String,
359        content: String,
360        #[serde(default, skip_serializing_if = "Option::is_none")]
361        streak: Option<usize>,
362    },
363    /// Emitted when the agent loop exhausts `max_iterations` without any
364    /// explicit break condition firing. Distinct from a natural "done" or
365    /// a "stuck" nudge-exhaustion: this is strictly a budget cap.
366    BudgetExhausted {
367        session_id: String,
368        max_iterations: usize,
369        #[serde(default, skip_serializing_if = "Option::is_none")]
370        kind: Option<String>,
371        #[serde(default, skip_serializing_if = "Option::is_none")]
372        cost_usd: Option<f64>,
373        #[serde(default, skip_serializing_if = "Option::is_none")]
374        wall_clock_ms: Option<u64>,
375    },
376    /// Emitted when a loop-level budget circuit breaker trips after N
377    /// consecutive retryable failures. `paused_for_ms` is the mock-time-aware
378    /// backoff already honored before the terminal budget event.
379    BudgetCircuitBreaker {
380        session_id: String,
381        kind: String,
382        consecutive_count: usize,
383        paused_for_ms: u64,
384    },
385    /// Emitted when the loop breaks because consecutive text-only turns
386    /// hit `max_nudges`. Parity with `BudgetExhausted` / `IterationEnd` for
387    /// hosts that key off agent-terminal events.
388    LoopStuck {
389        session_id: String,
390        max_nudges: usize,
391        last_iteration: usize,
392        tail_excerpt: String,
393    },
394    /// Pipeline-authored stuck/escalation signal emitted through
395    /// `agent_emit_event("loop_stuck", payload)`. The runtime-level
396    /// `LoopStuck` variant above remains the built-in max-nudge terminal event;
397    /// this variant preserves the pipeline payload so hosts can surface richer
398    /// handoff/escalation details without inventing another wire kind.
399    LoopStuckSignal {
400        session_id: String,
401        payload: serde_json::Value,
402    },
403    /// Emitted by the reserved-budget terminal-verify guard
404    /// (`agent_emit_event("reserved_terminal_verify", payload)`). The guard
405    /// holds back a small iteration reserve the main loop cannot consume; when
406    /// the loop would otherwise terminate on a budget/stuck boundary with an
407    /// unverified source write, it spends the reserve on a final verify(+repair)
408    /// instead of ending blind on a red build. The payload's `phase` field tags
409    /// the step (`grant` / `verify_passed` / `verify_failed`) so replayers and
410    /// operators can see the guard fire and its outcome. Payload-preserving like
411    /// `LoopStuckSignal` so the guard can carry richer detail without inventing
412    /// another wire kind.
413    ReservedTerminalVerify {
414        session_id: String,
415        payload: serde_json::Value,
416    },
417    /// Emitted when the daemon idle-wait loop trips its watchdog because
418    /// every configured wake source returned `None` for N consecutive
419    /// attempts. Exists so a broken daemon doesn't hang the session
420    /// silently.
421    DaemonWatchdogTripped {
422        session_id: String,
423        attempts: usize,
424        elapsed_ms: u64,
425    },
426    /// Emitted when a skill is activated. Carries the match reason so
427    /// replayers can reconstruct *why* a given skill took effect at
428    /// this iteration.
429    SkillActivated {
430        session_id: String,
431        skill_name: String,
432        iteration: usize,
433        reason: String,
434    },
435    /// Emitted when a previously-active skill is deactivated because
436    /// the reassess phase no longer matches it.
437    SkillDeactivated {
438        session_id: String,
439        skill_name: String,
440        iteration: usize,
441    },
442    /// Emitted once per activation when the skill's `allowed_tools` filter
443    /// narrows the effective tool surface exposed to the model.
444    SkillScopeTools {
445        session_id: String,
446        skill_name: String,
447        allowed_tools: Vec<String>,
448    },
449    /// Emitted when the agent loop ratchets the model-visible tool
450    /// surface narrower after observing recent tool-call usage. Unlike
451    /// `SkillScopeTools`, this is session-local and can only remove
452    /// tools from the currently-effective surface.
453    SkillNarrow {
454        session_id: String,
455        reason: String,
456        removed_tools: Vec<String>,
457        remaining_tools: Vec<String>,
458        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
459        policy: serde_json::Value,
460        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
461        removed_tool_details: serde_json::Value,
462        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
463        kept_tool_details: serde_json::Value,
464    },
465    /// Read-only stance lifecycle (std/agent/stance): `phase` is one of
466    /// `armed`, `write_access_granted`, `write_access_denied`,
467    /// `disarmed`. Arming carries the permitted tool window; the
468    /// grant/deny phases carry the escape-hatch justification and the
469    /// consent verdict so a trace viewer can explain every elevation.
470    StanceTransition {
471        session_id: String,
472        phase: String,
473        escape_tool: String,
474        #[serde(default, skip_serializing_if = "Vec::is_empty")]
475        allowed_tools: Vec<String>,
476        #[serde(default, skip_serializing_if = "String::is_empty")]
477        justification: String,
478        #[serde(default, skip_serializing_if = "String::is_empty")]
479        consent: String,
480        #[serde(default, skip_serializing_if = "String::is_empty")]
481        reason: String,
482    },
483    /// Emitted when a `tool_search` query is issued by the model. Carries
484    /// the raw query args, the configured strategy, and a `mode` tag
485    /// distinguishing the client-executed fallback (`"client"`) from
486    /// provider-native paths (`"anthropic"` / `"openai"`). Mirrors the
487    /// transcript event shape so hosts can render a search-in-progress
488    /// chip in real time — the replay path walks the transcript after
489    /// the turn, which is too late for live UX.
490    ToolSearchQuery {
491        session_id: String,
492        tool_use_id: String,
493        name: String,
494        query: serde_json::Value,
495        strategy: String,
496        mode: String,
497    },
498    /// Emitted when `tool_search` resolves — carries the list of tool
499    /// names newly promoted into the model's effective surface for the
500    /// next turn. Pair-emitted with `ToolSearchQuery` on every search.
501    ToolSearchResult {
502        session_id: String,
503        tool_use_id: String,
504        promoted: Vec<String>,
505        strategy: String,
506        mode: String,
507    },
508    TranscriptCompacted {
509        session_id: String,
510        mode: String,
511        reason: String,
512        strategy: String,
513        archived_messages: usize,
514        estimated_tokens_before: usize,
515        estimated_tokens_after: usize,
516        snapshot_asset_id: Option<String>,
517        #[serde(default, skip_serializing_if = "Option::is_none")]
518        instruction_mode: Option<String>,
519        #[serde(default, skip_serializing_if = "Option::is_none")]
520        instruction_source: Option<String>,
521        #[serde(default, skip_serializing_if = "Option::is_none")]
522        compaction_policy: Option<serde_json::Value>,
523    },
524    /// Emitted whenever `transcript_project` derives a model-visible
525    /// prefix from the immutable raw transcript. Hosts that render a
526    /// side-by-side raw/projected view subscribe to this — the typed
527    /// payload mirrors the metadata on the persisted
528    /// `transcript.projection` transcript event so clients don't have to
529    /// re-parse the transcript to sync UI state.
530    TranscriptProjected {
531        session_id: String,
532        policy: String,
533        reason: String,
534        prefix_hash: String,
535        kept_count: usize,
536        dropped_count: usize,
537        provider_safety_blocked: bool,
538        #[serde(default, skip_serializing_if = "is_zero_usize")]
539        redacted_count: usize,
540        #[serde(default, skip_serializing_if = "is_zero_usize")]
541        reclaimed_tokens: usize,
542        #[serde(default, skip_serializing_if = "Vec::is_empty")]
543        roots_consulted: Vec<String>,
544        #[serde(default, skip_serializing_if = "Vec::is_empty")]
545        redaction_pointers: Vec<serde_json::Value>,
546    },
547    /// Emitted when a pending `system_reminder` is rendered into the
548    /// next provider request. ACP clients show these in a reminder lane
549    /// instead of mixing them into assistant text chunks.
550    ReminderEmitted {
551        session_id: String,
552        reminder_id: String,
553        tags: Vec<String>,
554        body: String,
555        role_hint: String,
556        rendered_role: String,
557        source: String,
558        ttl_turns: Option<i64>,
559    },
560    Handoff {
561        session_id: String,
562        artifact_id: String,
563        handoff: Box<HandoffArtifact>,
564    },
565    FsWatch {
566        session_id: String,
567        subscription_id: String,
568        events: Vec<FsWatchEvent>,
569    },
570    /// Emitted when hostlib staged filesystem state changes for a session.
571    /// The ACP adapter maps this to the existing `progress` extension so
572    /// clients can update rollup-diff badges without waiting for a prompt
573    /// turn boundary.
574    StagedWritesPending {
575        session_id: String,
576        pending_count: usize,
577        total_bytes: u64,
578    },
579    /// Per-call outcome of `hostlib_fs_safe_text_patch`. Hosts subscribe to
580    /// this to roll up stale-base / hunk-conflict rates and average
581    /// hunks-per-patch without scraping result dicts out of pipeline logs.
582    /// Fired from both the staged-overlay and direct-disk code paths so
583    /// the rollup is comprehensive.
584    SafeTextPatchResult {
585        session_id: String,
586        path: String,
587        result: String,
588        hunks_count: usize,
589        bytes_written: u64,
590        #[serde(default, skip_serializing_if = "Option::is_none")]
591        failed_hunk_index: Option<usize>,
592    },
593    /// ACP control-plane arbitration outcome. Emitted for accepted,
594    /// idempotent, and rejected controls so replay/audit consumers can show
595    /// who acted and why a late or unauthorized action lost.
596    ControlOutcome {
597        session_id: String,
598        control_id: String,
599        method: String,
600        outcome: String,
601        status: String,
602        actor: serde_json::Value,
603        target: serde_json::Value,
604        #[serde(default, skip_serializing_if = "Option::is_none")]
605        reason: Option<String>,
606        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
607        metadata: serde_json::Value,
608    },
609    /// Lifecycle update for a delegated/background worker. Carries the
610    /// canonical typed `event` variant alongside the worker's current
611    /// `status` string and the structured `metadata` payload that
612    /// `worker_bridge_metadata` builds (task, mode, timing, child
613    /// run/snapshot paths, audit-session, etc.). The `audit` field is
614    /// the same `MutationSessionRecord` JSON serialization carried on
615    /// the bridge wire so ACP/A2A consumers don't need to re-derive it.
616    ///
617    /// One-to-one with the bridge-side `worker_update` session-update
618    /// notification: ACP and A2A adapters subscribe to this variant
619    /// and translate it into their respective wire formats. The
620    /// `session_id` is the parent agent session that owns the worker
621    /// (i.e. the session whose VM spawned the worker), so a single
622    /// host stays subscribed to the same sink for both message and
623    /// worker traffic.
624    WorkerUpdate {
625        session_id: String,
626        worker_id: String,
627        worker_name: String,
628        worker_task: String,
629        worker_mode: String,
630        event: WorkerEvent,
631        status: String,
632        metadata: serde_json::Value,
633        audit: Option<serde_json::Value>,
634    },
635    /// A human-in-the-loop primitive (`ask_user`, `request_approval`,
636    /// `dual_control`, `escalate`) has just suspended the script and is
637    /// waiting on a response. Hosts that bridge the VM onto a remote
638    /// transport (ACP, A2A) translate this into a "paused / awaiting
639    /// input" wire signal so the client knows the task isn't stuck —
640    /// it's blocked on the human side. Pair-emitted with `HitlResolved`
641    /// when the waitpoint completes/cancels/times out.
642    HitlRequested {
643        session_id: String,
644        request_id: String,
645        kind: String,
646        payload: serde_json::Value,
647    },
648    /// Companion to `HitlRequested`: the waitpoint has resolved (either
649    /// a response arrived, the deadline elapsed, or the request was
650    /// cancelled). `outcome` is one of `"answered"`, `"timeout"`,
651    /// `"cancelled"`. Hosts use this to flip task state back to
652    /// `working` after an `input-required` pause.
653    HitlResolved {
654        session_id: String,
655        request_id: String,
656        kind: String,
657        outcome: String,
658    },
659    /// Emitted by the agent loop's adaptive iteration budget /
660    /// `loop_control` policy when a budget extension or early stop fires.
661    /// Generic enough to cover both shapes — `action` distinguishes them.
662    /// Carries the iteration the decision applied to, the previous /
663    /// resulting iteration limit, the policy reason string, and (for
664    /// stops) the loop status.
665    LoopControlDecision {
666        session_id: String,
667        iteration: usize,
668        action: String,
669        old_limit: usize,
670        new_limit: usize,
671        reason: String,
672        status: String,
673    },
674    /// Emitted when `agent_loop` detects adjacent repeated tool calls with
675    /// identical arguments. The warning payload avoids raw arguments by
676    /// default and carries digests so hosts can correlate repeats without
677    /// exposing potentially sensitive tool inputs.
678    AgentLoopStallWarning {
679        session_id: String,
680        warning: serde_json::Value,
681    },
682    /// Emitted when a concrete provider/model pair lacks a catalog
683    /// recommendation for a capability and the runtime chooses a fallback.
684    CapabilityGap {
685        session_id: String,
686        level: String,
687        capability: String,
688        provider: String,
689        model: String,
690        fallback_tool_format: String,
691        #[serde(default, skip_serializing_if = "Option::is_none")]
692        requested_tool_format: Option<String>,
693        message: String,
694    },
695    /// Emitted when a caller explicitly forces a tool format that
696    /// differs from the capability catalog's recommendation or known
697    /// native/text parity guidance.
698    ToolFormatOverride {
699        session_id: String,
700        provider: String,
701        model: String,
702        requested_format: String,
703        recommended_format: String,
704        catalog_parity: String,
705        #[serde(default, skip_serializing_if = "Option::is_none")]
706        override_reason: Option<String>,
707    },
708    /// Emitted when a `tool_caller` middleware (see std/llm/tool_middleware)
709    /// attaches structured audit metadata to a tool call — typically a
710    /// user-facing `summary`, a `description`, an ACP-style `kind`, an MCP
711    /// `hints` block, a `consent` decision, the per-layer `layers` log, or
712    /// free-form `metadata` keys (A2A-style extension slot).
713    ///
714    /// One-to-one with the underlying tool-call: hosts can join on
715    /// `tool_call_id` to render middleware-attached chips alongside the
716    /// existing `ToolCall` / `ToolCallUpdate` stream. The `audit` payload
717    /// is intentionally free-form JSON so middleware can carry whatever
718    /// shape the harness author chooses without needing protocol-level
719    /// changes per new middleware. When present, `receipt` carries the
720    /// stable typed, privacy-preserving record hosts can persist or mirror.
721    ToolCallAudit {
722        session_id: String,
723        tool_call_id: String,
724        tool_name: String,
725        audit: serde_json::Value,
726        #[serde(default, skip_serializing_if = "Option::is_none")]
727        receipt: Option<ToolCallReceipt>,
728    },
729    /// Emitted by `std/cache::with_cache` (both the generic and LLM
730    /// forms) when a cached lookup returns a hit. Carries the
731    /// content-addressed key, the backend that served the value, and a
732    /// `metrics` block with the cost-moat receipts the persona value
733    /// ledger (a cloud platform) and crystallization receipts read:
734    /// `model_calls_avoided`, plus `tokens_saved` / `latency_saved_ms`
735    /// when the cached envelope carried `usage` / `latency_ms`.
736    CacheHit {
737        session_id: String,
738        key: String,
739        backend: String,
740        namespace: String,
741        payload: serde_json::Value,
742    },
743    /// Paired with `CacheHit`. Emitted on the miss path when the
744    /// fresh result is stored. `payload.metrics.compute_ms` carries
745    /// the wall-clock cost of the underlying computation, which
746    /// callers can feed back as `estimate.latency_saved_ms` on the
747    /// next hit.
748    CacheMiss {
749        session_id: String,
750        key: String,
751        backend: String,
752        namespace: String,
753        payload: serde_json::Value,
754    },
755    /// A language-neutral tool-composition snippet has started. The envelope
756    /// identifies the snippet and binding manifest hashes plus the side-effect
757    /// ceiling requested for the whole parent run.
758    CompositionStart {
759        session_id: String,
760        run: CompositionRunEnvelope,
761    },
762    /// A composition snippet is dispatching a child binding call. The child
763    /// remains visible as its own operation with annotations and policy context
764    /// instead of being hidden inside the parent composition blob.
765    CompositionChildCall {
766        session_id: String,
767        call: CompositionChildCall,
768    },
769    /// A child binding operation emitted a status/result update.
770    CompositionChildResult {
771        session_id: String,
772        result: CompositionChildResult,
773    },
774    /// A composition run finished successfully and carries stdout/stderr,
775    /// artifacts, and the structured result in the terminal envelope.
776    CompositionFinish {
777        session_id: String,
778        run: CompositionRunEnvelope,
779    },
780    /// A composition run failed before producing a successful terminal result.
781    /// The terminal envelope carries the failure category and optional error.
782    CompositionError {
783        session_id: String,
784        run: CompositionRunEnvelope,
785    },
786    /// Emitted once per `__agent_loop_checkpoint(...)` pass. The single
787    /// named seam through which the agent loop drains queued bridge
788    /// injections and inbox feedback. Hosts use it to debug "did the
789    /// loop check for steering at the expected boundary" without having
790    /// to grep the loop body for inline drain calls.
791    ///
792    /// `kind` is one of the documented seam names: `iteration_start`,
793    /// `pre_tool_dispatch`, `post_tool_dispatch`, `iteration_end`,
794    /// `pre_compact`, `post_compact`, `daemon_idle_pre`,
795    /// `daemon_idle_post`, `loop_exit`. `delivered` is the count of
796    /// bridge injections drained at this seam (inbox drains are
797    /// reported separately under `inbox_delivered`). `dispatch_skipped`
798    /// is true only when an `interrupt_immediate` injection arrived at
799    /// `pre_tool_dispatch` and the pending tool batch was skipped.
800    LoopCheckpoint {
801        session_id: String,
802        iteration: usize,
803        kind: String,
804        delivered: usize,
805        #[serde(default, skip_serializing_if = "is_zero_usize")]
806        inbox_delivered: usize,
807        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
808        dispatch_skipped: bool,
809    },
810    /// Surfaced when Harn is acting as an MCP **client** and a peer
811    /// server sends a server-to-client message during an agent session:
812    /// a `notifications/progress` / `notifications/message` /
813    /// `notifications/*/list_changed` notification, or an inbound
814    /// `elicitation/create` / `sampling/createMessage` request.
815    ///
816    /// Emitted alongside (not in place of) the existing agent-inbox
817    /// relay so a thin ACP client can render a live progress bar, log
818    /// line, elicitation prompt, or sampling affordance without parsing
819    /// the inbox transcript. `direction` is `"notification"` for
820    /// fire-and-forget server notifications and `"request"` for inbound
821    /// requests that still resolve through the existing client-role
822    /// dispatch path (this event does not change that response). `method`
823    /// is the raw MCP JSON-RPC method; `params` is its untouched payload.
824    McpNotification {
825        session_id: String,
826        server: String,
827        method: String,
828        direction: String,
829        params: serde_json::Value,
830    },
831    /// Surfaced when the effective MCP catalog changes — either because a
832    /// server emitted a `notifications/tools/list_changed` (or the
833    /// resource/prompt equivalents), or because the persisted enable/disable
834    /// allowlist was edited. A thin ACP client (an IDE host's TUI / GUI)
835    /// treats this as a cue to re-fetch the catalog (e.g. via the
836    /// `mcp/catalog` request) and re-render its toggle UI, rather than
837    /// reconciling any local state. `server` is the server whose list
838    /// changed, or `None` when the change is allowlist-wide. `reason` is a
839    /// short tag (`"list_changed"` or `"allowlist_updated"`).
840    McpCatalogChanged {
841        session_id: String,
842        #[serde(default, skip_serializing_if = "Option::is_none")]
843        server: Option<String>,
844        reason: String,
845    },
846    /// Surfaced when an MCP server harn is acting as a client for answers a
847    /// request with `401 Unauthorized` mid-session, meaning its OAuth token is
848    /// missing or expired. This is a cue for a thin ACP client (an IDE host's
849    /// TUI / GUI) to start an authorization: call `mcp/authorize` to mint a
850    /// browser URL, open it, and forward the redirect's `code`+`state` back via
851    /// `mcp/oauth_callback`. Token exchange and storage stay in harn. `server`
852    /// is the configured server name; `resource` is its canonical RFC 8707
853    /// resource indicator; `scope` is the `scope` parameter from the
854    /// `WWW-Authenticate` challenge, when present.
855    McpAuthRequired {
856        session_id: String,
857        server: String,
858        resource: String,
859        #[serde(default, skip_serializing_if = "Option::is_none")]
860        scope: Option<String>,
861    },
862}
863
864fn is_zero_usize(value: &usize) -> bool {
865    *value == 0
866}
867
868impl AgentEvent {
869    pub fn session_id(&self) -> &str {
870        match self {
871            Self::AgentMessageChunk { session_id, .. }
872            | Self::AgentThoughtChunk { session_id, .. }
873            | Self::UserMessage { session_id, .. }
874            | Self::ToolCall { session_id, .. }
875            | Self::ToolCallUpdate { session_id, .. }
876            | Self::Plan { session_id, .. }
877            | Self::ProgressReported { session_id, .. }
878            | Self::CompassRoutingDecision { session_id, .. }
879            | Self::AgentScratchpadReorganization { session_id, .. }
880            | Self::Artifact { session_id, .. }
881            | Self::IterationStart { session_id, .. }
882            | Self::IterationEnd { session_id, .. }
883            | Self::SessionClosed { session_id, .. }
884            | Self::AnchorChanged { session_id, .. }
885            | Self::JudgeDecision { session_id, .. }
886            | Self::StepJudgeDecision { session_id, .. }
887            | Self::StructuralValidatorDecision { session_id, .. }
888            | Self::ScopeClassifierVerdict { session_id, .. }
889            | Self::InputGuardrailVerdict { session_id, .. }
890            | Self::MissingToolCallVerdict { session_id, .. }
891            | Self::TypedCheckpoint { session_id, .. }
892            | Self::FeedbackInjected { session_id, .. }
893            | Self::BudgetExhausted { session_id, .. }
894            | Self::BudgetCircuitBreaker { session_id, .. }
895            | Self::LoopStuck { session_id, .. }
896            | Self::LoopStuckSignal { session_id, .. }
897            | Self::ReservedTerminalVerify { session_id, .. }
898            | Self::DaemonWatchdogTripped { session_id, .. }
899            | Self::SkillActivated { session_id, .. }
900            | Self::SkillDeactivated { session_id, .. }
901            | Self::SkillScopeTools { session_id, .. }
902            | Self::SkillNarrow { session_id, .. }
903            | Self::StanceTransition { session_id, .. }
904            | Self::ToolSearchQuery { session_id, .. }
905            | Self::ToolSearchResult { session_id, .. }
906            | Self::TranscriptCompacted { session_id, .. }
907            | Self::TranscriptProjected { session_id, .. }
908            | Self::ReminderEmitted { session_id, .. }
909            | Self::Handoff { session_id, .. }
910            | Self::FsWatch { session_id, .. }
911            | Self::StagedWritesPending { session_id, .. }
912            | Self::SafeTextPatchResult { session_id, .. }
913            | Self::ControlOutcome { session_id, .. }
914            | Self::WorkerUpdate { session_id, .. }
915            | Self::HitlRequested { session_id, .. }
916            | Self::HitlResolved { session_id, .. }
917            | Self::LoopControlDecision { session_id, .. }
918            | Self::AgentLoopStallWarning { session_id, .. }
919            | Self::CapabilityGap { session_id, .. }
920            | Self::ToolFormatOverride { session_id, .. }
921            | Self::ToolCallAudit { session_id, .. }
922            | Self::CacheHit { session_id, .. }
923            | Self::CacheMiss { session_id, .. }
924            | Self::CompositionStart { session_id, .. }
925            | Self::CompositionChildCall { session_id, .. }
926            | Self::CompositionChildResult { session_id, .. }
927            | Self::CompositionFinish { session_id, .. }
928            | Self::CompositionError { session_id, .. }
929            | Self::LoopCheckpoint { session_id, .. }
930            | Self::McpNotification { session_id, .. }
931            | Self::McpCatalogChanged { session_id, .. }
932            | Self::McpAuthRequired { session_id, .. } => session_id,
933        }
934    }
935}