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}