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}