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 /// Fires at the top of every model round-trip inside an
134 /// `agent_loop` invocation. Maps to the `iteration_start` steering
135 /// seam. Not the same as ACP's outer `prompt_turn` boundary; an
136 /// `agent_turn`/`prompt_turn` cycle contains many of these.
137 IterationStart {
138 session_id: String,
139 iteration: usize,
140 /// Configured provider for the impending LLM call. Empty when the
141 /// caller defers provider selection to the routing layer.
142 /// Surfaces here so observers can show "about to call X/Y" before
143 /// the call returns — previously this only landed in the
144 /// transcript after the response, leaving live pulse-check
145 /// consumers without a model attribution for in-flight iterations.
146 #[serde(default, skip_serializing_if = "String::is_empty")]
147 provider: String,
148 /// Configured model id. Same semantics as `provider`.
149 #[serde(default, skip_serializing_if = "String::is_empty")]
150 model: String,
151 },
152 /// Fires at the bottom of every model round-trip, after tool
153 /// dispatch (or after the dispatch was skipped). Sibling of
154 /// `IterationStart`.
155 IterationEnd {
156 session_id: String,
157 iteration: usize,
158 /// Free-form dict carrying the post-call snapshot. Stable keys
159 /// emitted by the agent loop: `tool_count`, `text`, plus the
160 /// LLM-result projection — `provider`, `model`, `response_ms`,
161 /// `input_tokens`, `output_tokens`, `thinking_chars`. Hosts that
162 /// surface latency/cost panes key off these without re-parsing
163 /// the transcript JSONL.
164 iteration_info: serde_json::Value,
165 },
166 /// Emitted when a first-class agent session is explicitly closed by
167 /// `agent_session_close`. This gives event-log consumers a typed
168 /// terminal marker even when no final model turn runs.
169 SessionClosed {
170 session_id: String,
171 reason: String,
172 status: String,
173 metadata: serde_json::Value,
174 },
175 /// Emitted when `agent_session_reanchor` swaps the primary workspace
176 /// anchor (#2218). Hosts use this to drive cross-project handoff UX.
177 /// Carries the previous and current anchors so consumers can diff
178 /// without re-fetching session state.
179 AnchorChanged {
180 session_id: String,
181 previous: Option<serde_json::Value>,
182 current: serde_json::Value,
183 carry_transcript: bool,
184 compacted: bool,
185 reason: Option<String>,
186 },
187 JudgeDecision {
188 session_id: String,
189 iteration: usize,
190 verdict: String,
191 reasoning: String,
192 next_step: Option<String>,
193 judge_duration_ms: u64,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 trigger: Option<String>,
196 },
197 /// Per-step critique decision emitted by `agent_step_judge`.
198 /// Sibling of [`JudgeDecision`] but fired BEFORE tool dispatch on
199 /// every assistant turn (when configured), not just at completion.
200 /// `on_veto` carries the configured remediation shape
201 /// (`"replace"` or `"retain"`); `cost_usd` is best-effort from the
202 /// stdlib economics estimator and may be 0 when pricing is unknown.
203 /// `skipped` marks configured short-circuits that did not call the
204 /// judge model.
205 StepJudgeDecision {
206 session_id: String,
207 iteration: usize,
208 verdict: String,
209 reasoning: String,
210 critique: String,
211 confidence: f64,
212 judge_duration_ms: u64,
213 vetoed: bool,
214 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215 skipped: bool,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 reason: Option<String>,
218 on_veto: String,
219 input_tokens: u64,
220 output_tokens: u64,
221 cost_usd: f64,
222 provider: String,
223 model: String,
224 },
225 /// Deterministic pre-dispatch critique emitted by the structural
226 /// validator middleware. Fires before any LLM-backed judge so hosts
227 /// can distinguish "$0 structural retry" from semantic critique.
228 StructuralValidatorDecision {
229 session_id: String,
230 iteration: usize,
231 rule: String,
232 diagnostic: String,
233 recommended_action: String,
234 vetoed: bool,
235 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
236 skipped: bool,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 reason: Option<String>,
239 on_failure: String,
240 attempts: usize,
241 max_attempts: usize,
242 },
243 ScopeClassifierVerdict {
244 session_id: String,
245 iteration: usize,
246 label: String,
247 original_label: String,
248 confidence: f64,
249 confidence_threshold: f64,
250 evidence: String,
251 skip_main_turn: bool,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 classifier_kind: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 model: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 error: Option<String>,
258 },
259 TypedCheckpoint {
260 session_id: String,
261 checkpoint: serde_json::Value,
262 },
263 FeedbackInjected {
264 session_id: String,
265 kind: String,
266 content: String,
267 },
268 /// Emitted when the agent loop exhausts `max_iterations` without any
269 /// explicit break condition firing. Distinct from a natural "done" or
270 /// a "stuck" nudge-exhaustion: this is strictly a budget cap.
271 BudgetExhausted {
272 session_id: String,
273 max_iterations: usize,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 kind: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 cost_usd: Option<f64>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 wall_clock_ms: Option<u64>,
280 },
281 /// Emitted when a loop-level budget circuit breaker trips after N
282 /// consecutive retryable failures. `paused_for_ms` is the mock-time-aware
283 /// backoff already honored before the terminal budget event.
284 BudgetCircuitBreaker {
285 session_id: String,
286 kind: String,
287 consecutive_count: usize,
288 paused_for_ms: u64,
289 },
290 /// Emitted when the loop breaks because consecutive text-only turns
291 /// hit `max_nudges`. Parity with `BudgetExhausted` / `IterationEnd` for
292 /// hosts that key off agent-terminal events.
293 LoopStuck {
294 session_id: String,
295 max_nudges: usize,
296 last_iteration: usize,
297 tail_excerpt: String,
298 },
299 /// Emitted when the daemon idle-wait loop trips its watchdog because
300 /// every configured wake source returned `None` for N consecutive
301 /// attempts. Exists so a broken daemon doesn't hang the session
302 /// silently.
303 DaemonWatchdogTripped {
304 session_id: String,
305 attempts: usize,
306 elapsed_ms: u64,
307 },
308 /// Emitted when a skill is activated. Carries the match reason so
309 /// replayers can reconstruct *why* a given skill took effect at
310 /// this iteration.
311 SkillActivated {
312 session_id: String,
313 skill_name: String,
314 iteration: usize,
315 reason: String,
316 },
317 /// Emitted when a previously-active skill is deactivated because
318 /// the reassess phase no longer matches it.
319 SkillDeactivated {
320 session_id: String,
321 skill_name: String,
322 iteration: usize,
323 },
324 /// Emitted once per activation when the skill's `allowed_tools` filter
325 /// narrows the effective tool surface exposed to the model.
326 SkillScopeTools {
327 session_id: String,
328 skill_name: String,
329 allowed_tools: Vec<String>,
330 },
331 /// Emitted when the agent loop ratchets the model-visible tool
332 /// surface narrower after observing recent tool-call usage. Unlike
333 /// `SkillScopeTools`, this is session-local and can only remove
334 /// tools from the currently-effective surface.
335 SkillNarrow {
336 session_id: String,
337 reason: String,
338 removed_tools: Vec<String>,
339 remaining_tools: Vec<String>,
340 },
341 /// Emitted when a `tool_search` query is issued by the model. Carries
342 /// the raw query args, the configured strategy, and a `mode` tag
343 /// distinguishing the client-executed fallback (`"client"`) from
344 /// provider-native paths (`"anthropic"` / `"openai"`). Mirrors the
345 /// transcript event shape so hosts can render a search-in-progress
346 /// chip in real time — the replay path walks the transcript after
347 /// the turn, which is too late for live UX.
348 ToolSearchQuery {
349 session_id: String,
350 tool_use_id: String,
351 name: String,
352 query: serde_json::Value,
353 strategy: String,
354 mode: String,
355 },
356 /// Emitted when `tool_search` resolves — carries the list of tool
357 /// names newly promoted into the model's effective surface for the
358 /// next turn. Pair-emitted with `ToolSearchQuery` on every search.
359 ToolSearchResult {
360 session_id: String,
361 tool_use_id: String,
362 promoted: Vec<String>,
363 strategy: String,
364 mode: String,
365 },
366 TranscriptCompacted {
367 session_id: String,
368 mode: String,
369 reason: String,
370 strategy: String,
371 archived_messages: usize,
372 estimated_tokens_before: usize,
373 estimated_tokens_after: usize,
374 snapshot_asset_id: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 instruction_mode: Option<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 instruction_source: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 compaction_policy: Option<serde_json::Value>,
381 },
382 /// Emitted whenever `transcript_project` derives a model-visible
383 /// prefix from the immutable raw transcript. Hosts that render a
384 /// side-by-side raw/projected view subscribe to this — the typed
385 /// payload mirrors the metadata on the persisted
386 /// `transcript.projection` transcript event so clients don't have to
387 /// re-parse the transcript to sync UI state.
388 TranscriptProjected {
389 session_id: String,
390 policy: String,
391 reason: String,
392 prefix_hash: String,
393 kept_count: usize,
394 dropped_count: usize,
395 provider_safety_blocked: bool,
396 },
397 /// Emitted when a pending `system_reminder` is rendered into the
398 /// next provider request. ACP clients show these in a reminder lane
399 /// instead of mixing them into assistant text chunks.
400 ReminderEmitted {
401 session_id: String,
402 reminder_id: String,
403 tags: Vec<String>,
404 body: String,
405 role_hint: String,
406 rendered_role: String,
407 source: String,
408 ttl_turns: Option<i64>,
409 },
410 Handoff {
411 session_id: String,
412 artifact_id: String,
413 handoff: Box<HandoffArtifact>,
414 },
415 FsWatch {
416 session_id: String,
417 subscription_id: String,
418 events: Vec<FsWatchEvent>,
419 },
420 /// Emitted when hostlib staged filesystem state changes for a session.
421 /// The ACP adapter maps this to the existing `progress` extension so
422 /// clients can update rollup-diff badges without waiting for a prompt
423 /// turn boundary.
424 StagedWritesPending {
425 session_id: String,
426 pending_count: usize,
427 total_bytes: u64,
428 },
429 /// Per-call outcome of `hostlib_fs_safe_text_patch`. Hosts subscribe to
430 /// this to roll up stale-base / hunk-conflict rates and average
431 /// hunks-per-patch without scraping result dicts out of pipeline logs.
432 /// Fired from both the staged-overlay and direct-disk code paths so
433 /// the rollup is comprehensive.
434 SafeTextPatchResult {
435 session_id: String,
436 path: String,
437 result: String,
438 hunks_count: usize,
439 bytes_written: u64,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 failed_hunk_index: Option<usize>,
442 },
443 /// ACP control-plane arbitration outcome. Emitted for accepted,
444 /// idempotent, and rejected controls so replay/audit consumers can show
445 /// who acted and why a late or unauthorized action lost.
446 ControlOutcome {
447 session_id: String,
448 control_id: String,
449 method: String,
450 outcome: String,
451 status: String,
452 actor: serde_json::Value,
453 target: serde_json::Value,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 reason: Option<String>,
456 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
457 metadata: serde_json::Value,
458 },
459 /// Lifecycle update for a delegated/background worker. Carries the
460 /// canonical typed `event` variant alongside the worker's current
461 /// `status` string and the structured `metadata` payload that
462 /// `worker_bridge_metadata` builds (task, mode, timing, child
463 /// run/snapshot paths, audit-session, etc.). The `audit` field is
464 /// the same `MutationSessionRecord` JSON serialization carried on
465 /// the bridge wire so ACP/A2A consumers don't need to re-derive it.
466 ///
467 /// One-to-one with the bridge-side `worker_update` session-update
468 /// notification: ACP and A2A adapters subscribe to this variant
469 /// and translate it into their respective wire formats. The
470 /// `session_id` is the parent agent session that owns the worker
471 /// (i.e. the session whose VM spawned the worker), so a single
472 /// host stays subscribed to the same sink for both message and
473 /// worker traffic.
474 WorkerUpdate {
475 session_id: String,
476 worker_id: String,
477 worker_name: String,
478 worker_task: String,
479 worker_mode: String,
480 event: WorkerEvent,
481 status: String,
482 metadata: serde_json::Value,
483 audit: Option<serde_json::Value>,
484 },
485 /// A human-in-the-loop primitive (`ask_user`, `request_approval`,
486 /// `dual_control`, `escalate`) has just suspended the script and is
487 /// waiting on a response. Hosts that bridge the VM onto a remote
488 /// transport (ACP, A2A) translate this into a "paused / awaiting
489 /// input" wire signal so the client knows the task isn't stuck —
490 /// it's blocked on the human side. Pair-emitted with `HitlResolved`
491 /// when the waitpoint completes/cancels/times out.
492 HitlRequested {
493 session_id: String,
494 request_id: String,
495 kind: String,
496 payload: serde_json::Value,
497 },
498 /// Companion to `HitlRequested`: the waitpoint has resolved (either
499 /// a response arrived, the deadline elapsed, or the request was
500 /// cancelled). `outcome` is one of `"answered"`, `"timeout"`,
501 /// `"cancelled"`. Hosts use this to flip task state back to
502 /// `working` after an `input-required` pause.
503 HitlResolved {
504 session_id: String,
505 request_id: String,
506 kind: String,
507 outcome: String,
508 },
509 /// Emitted by the agent loop's adaptive iteration budget /
510 /// `loop_control` policy when a budget extension or early stop fires.
511 /// Generic enough to cover both shapes — `action` distinguishes them.
512 /// Carries the iteration the decision applied to, the previous /
513 /// resulting iteration limit, the policy reason string, and (for
514 /// stops) the loop status.
515 LoopControlDecision {
516 session_id: String,
517 iteration: usize,
518 action: String,
519 old_limit: usize,
520 new_limit: usize,
521 reason: String,
522 status: String,
523 },
524 /// Emitted when `agent_loop` detects adjacent repeated tool calls with
525 /// identical arguments. The warning payload avoids raw arguments by
526 /// default and carries digests so hosts can correlate repeats without
527 /// exposing potentially sensitive tool inputs.
528 AgentLoopStallWarning {
529 session_id: String,
530 warning: serde_json::Value,
531 },
532 /// Emitted when a concrete provider/model pair lacks a catalog
533 /// recommendation for a capability and the runtime chooses a fallback.
534 CapabilityGap {
535 session_id: String,
536 level: String,
537 capability: String,
538 provider: String,
539 model: String,
540 fallback_tool_format: String,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
542 requested_tool_format: Option<String>,
543 message: String,
544 },
545 /// Emitted when a caller explicitly forces a tool format that
546 /// differs from the capability catalog's recommendation or known
547 /// native/text parity guidance.
548 ToolFormatOverride {
549 session_id: String,
550 provider: String,
551 model: String,
552 requested_format: String,
553 recommended_format: String,
554 catalog_parity: String,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 override_reason: Option<String>,
557 },
558 /// Emitted when a `tool_caller` middleware (see std/llm/tool_middleware)
559 /// attaches structured audit metadata to a tool call — typically a
560 /// user-facing `summary`, a `description`, an ACP-style `kind`, an MCP
561 /// `hints` block, a `consent` decision, the per-layer `layers` log, or
562 /// free-form `metadata` keys (A2A-style extension slot).
563 ///
564 /// One-to-one with the underlying tool-call: hosts can join on
565 /// `tool_call_id` to render middleware-attached chips alongside the
566 /// existing `ToolCall` / `ToolCallUpdate` stream. The `audit` payload
567 /// is intentionally free-form JSON so middleware can carry whatever
568 /// shape the harness author chooses without needing protocol-level
569 /// changes per new middleware. When present, `receipt` carries the
570 /// stable typed, privacy-preserving record hosts can persist or mirror.
571 ToolCallAudit {
572 session_id: String,
573 tool_call_id: String,
574 tool_name: String,
575 audit: serde_json::Value,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 receipt: Option<ToolCallReceipt>,
578 },
579 /// Emitted by `std/cache::with_cache` (both the generic and LLM
580 /// forms) when a cached lookup returns a hit. Carries the
581 /// content-addressed key, the backend that served the value, and a
582 /// `metrics` block with the cost-moat receipts the persona value
583 /// ledger (harn-cloud#58) and crystallization receipts read:
584 /// `model_calls_avoided`, plus `tokens_saved` / `latency_saved_ms`
585 /// when the cached envelope carried `usage` / `latency_ms`.
586 CacheHit {
587 session_id: String,
588 key: String,
589 backend: String,
590 namespace: String,
591 payload: serde_json::Value,
592 },
593 /// Paired with `CacheHit`. Emitted on the miss path when the
594 /// fresh result is stored. `payload.metrics.compute_ms` carries
595 /// the wall-clock cost of the underlying computation, which
596 /// callers can feed back as `estimate.latency_saved_ms` on the
597 /// next hit.
598 CacheMiss {
599 session_id: String,
600 key: String,
601 backend: String,
602 namespace: String,
603 payload: serde_json::Value,
604 },
605 /// A language-neutral tool-composition snippet has started. The envelope
606 /// identifies the snippet and binding manifest hashes plus the side-effect
607 /// ceiling requested for the whole parent run.
608 CompositionStart {
609 session_id: String,
610 run: CompositionRunEnvelope,
611 },
612 /// A composition snippet is dispatching a child binding call. The child
613 /// remains visible as its own operation with annotations and policy context
614 /// instead of being hidden inside the parent composition blob.
615 CompositionChildCall {
616 session_id: String,
617 call: CompositionChildCall,
618 },
619 /// A child binding operation emitted a status/result update.
620 CompositionChildResult {
621 session_id: String,
622 result: CompositionChildResult,
623 },
624 /// A composition run finished successfully and carries stdout/stderr,
625 /// artifacts, and the structured result in the terminal envelope.
626 CompositionFinish {
627 session_id: String,
628 run: CompositionRunEnvelope,
629 },
630 /// A composition run failed before producing a successful terminal result.
631 /// The terminal envelope carries the failure category and optional error.
632 CompositionError {
633 session_id: String,
634 run: CompositionRunEnvelope,
635 },
636 /// Emitted once per `__agent_loop_checkpoint(...)` pass. The single
637 /// named seam through which the agent loop drains queued bridge
638 /// injections and inbox feedback. Hosts use it to debug "did the
639 /// loop check for steering at the expected boundary" without having
640 /// to grep the loop body for inline drain calls.
641 ///
642 /// `kind` is one of the documented seam names: `iteration_start`,
643 /// `pre_tool_dispatch`, `post_tool_dispatch`, `iteration_end`,
644 /// `pre_compact`, `post_compact`, `daemon_idle_pre`,
645 /// `daemon_idle_post`, `loop_exit`. `delivered` is the count of
646 /// bridge injections drained at this seam (inbox drains are
647 /// reported separately under `inbox_delivered`). `dispatch_skipped`
648 /// is true only when an `interrupt_immediate` injection arrived at
649 /// `pre_tool_dispatch` and the pending tool batch was skipped.
650 LoopCheckpoint {
651 session_id: String,
652 iteration: usize,
653 kind: String,
654 delivered: usize,
655 #[serde(default, skip_serializing_if = "is_zero_usize")]
656 inbox_delivered: usize,
657 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
658 dispatch_skipped: bool,
659 },
660}
661
662fn is_zero_usize(value: &usize) -> bool {
663 *value == 0
664}
665
666impl AgentEvent {
667 pub fn session_id(&self) -> &str {
668 match self {
669 Self::AgentMessageChunk { session_id, .. }
670 | Self::AgentThoughtChunk { session_id, .. }
671 | Self::UserMessage { session_id, .. }
672 | Self::ToolCall { session_id, .. }
673 | Self::ToolCallUpdate { session_id, .. }
674 | Self::Plan { session_id, .. }
675 | Self::ProgressReported { session_id, .. }
676 | Self::IterationStart { session_id, .. }
677 | Self::IterationEnd { session_id, .. }
678 | Self::SessionClosed { session_id, .. }
679 | Self::AnchorChanged { session_id, .. }
680 | Self::JudgeDecision { session_id, .. }
681 | Self::StepJudgeDecision { session_id, .. }
682 | Self::StructuralValidatorDecision { session_id, .. }
683 | Self::ScopeClassifierVerdict { session_id, .. }
684 | Self::TypedCheckpoint { session_id, .. }
685 | Self::FeedbackInjected { session_id, .. }
686 | Self::BudgetExhausted { session_id, .. }
687 | Self::BudgetCircuitBreaker { session_id, .. }
688 | Self::LoopStuck { session_id, .. }
689 | Self::DaemonWatchdogTripped { session_id, .. }
690 | Self::SkillActivated { session_id, .. }
691 | Self::SkillDeactivated { session_id, .. }
692 | Self::SkillScopeTools { session_id, .. }
693 | Self::SkillNarrow { session_id, .. }
694 | Self::ToolSearchQuery { session_id, .. }
695 | Self::ToolSearchResult { session_id, .. }
696 | Self::TranscriptCompacted { session_id, .. }
697 | Self::TranscriptProjected { session_id, .. }
698 | Self::ReminderEmitted { session_id, .. }
699 | Self::Handoff { session_id, .. }
700 | Self::FsWatch { session_id, .. }
701 | Self::StagedWritesPending { session_id, .. }
702 | Self::SafeTextPatchResult { session_id, .. }
703 | Self::ControlOutcome { session_id, .. }
704 | Self::WorkerUpdate { session_id, .. }
705 | Self::HitlRequested { session_id, .. }
706 | Self::HitlResolved { session_id, .. }
707 | Self::LoopControlDecision { session_id, .. }
708 | Self::AgentLoopStallWarning { session_id, .. }
709 | Self::CapabilityGap { session_id, .. }
710 | Self::ToolFormatOverride { session_id, .. }
711 | Self::ToolCallAudit { session_id, .. }
712 | Self::CacheHit { session_id, .. }
713 | Self::CacheMiss { session_id, .. }
714 | Self::CompositionStart { session_id, .. }
715 | Self::CompositionChildCall { session_id, .. }
716 | Self::CompositionChildResult { session_id, .. }
717 | Self::CompositionFinish { session_id, .. }
718 | Self::CompositionError { session_id, .. }
719 | Self::LoopCheckpoint { session_id, .. } => session_id,
720 }
721 }
722}