Skip to main content

harn_vm/agent_events/
agent.rs

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