Skip to main content

harn_vm/agent_events/
from_host.rs

1//! Typed deserialization of host-emitted agent events.
2//!
3//! `__host_agent_emit_event` receives an untyped `(event_type, payload)`
4//! pair from the Harn agent loop and turns it into a typed [`AgentEvent`].
5//! Historically this lived in a ~570-line hand-written
6//! `match event_type.as_str()` in `llm::agent_session_host` that
7//! re-derived, field by field, the shape the `AgentEvent` enum already
8//! declares via its `serde` derives.
9//!
10//! [`AgentEvent::from_host_payload`] replaces that with a typed
11//! `serde_json::from_value::<AgentEvent>` path. The payload keys already
12//! match the enum's snake_case field names, so most event types
13//! deserialize directly once the `type` tag and `session_id` are injected.
14//! Only three classes of arm need bespoke handling:
15//!
16//! 1. **Special arms** ([`from_host_special`]) where the host `event_type`
17//!    does not map 1:1 onto a variant's fields — the whole payload becomes
18//!    one field (`loop_stuck`, `cache_hit`, …), or a nudge `event_type`
19//!    collapses onto a synthesized `FeedbackInjected`.
20//! 2. **Field defaults** ([`apply_host_payload_defaults`]) for the handful
21//!    of genuinely-optional payload fields the old match defaulted to a
22//!    non-serde-default value (`ToolCall.status` → `pending`,
23//!    `progress_reported.replace` → `true`, the container fields that
24//!    default to `[]`/`{}`, …), plus the bare-string `executor` alias
25//!    normalization the internally-tagged [`super::ToolExecutor`] can't
26//!    parse on its own. Required scalars the loop always emits are left to
27//!    serde (a malformed emit surfaces a loud error instead of a silent
28//!    zero-fill).
29//! 3. **Ambient audit** — `tool_call` / `tool_call_update` take their
30//!    `audit` from the active mutation session, never the payload.
31//!
32//! [`HOST_EVENT_POLICIES`] is the single registry for this boundary. It owns
33//! both which `event_type` strings may enter through the host path and whether
34//! each accepted event is copied into the live transcript journal. Many
35//! `AgentEvent` variants (`worker_update`, `handoff`, `artifact`, …) are
36//! constructed elsewhere and are *not* emittable through this host path.
37
38use serde_json::{Map, Value};
39
40use crate::value::VmError;
41
42use super::AgentEvent;
43
44const HOST_AGENT_EMIT_EVENT: &str = "__host_agent_emit_event";
45const NO_PROGRESS_STREAK_NUDGE_FALLBACK: &str =
46    "No progress was detected. Use the next turn to make concrete task progress or explain the remaining blocker.";
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub(crate) enum HostTranscriptRole {
50    Assistant,
51    Tool,
52}
53
54impl HostTranscriptRole {
55    pub(crate) const fn as_str(self) -> &'static str {
56        match self {
57            Self::Assistant => "assistant",
58            Self::Tool => "tool",
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug)]
64struct HostEventPolicy {
65    event_type: &'static str,
66    transcript_role: Option<HostTranscriptRole>,
67}
68
69const fn host_event(
70    event_type: &'static str,
71    transcript_role: Option<HostTranscriptRole>,
72) -> HostEventPolicy {
73    HostEventPolicy {
74        event_type,
75        transcript_role,
76    }
77}
78
79const ASSISTANT: Option<HostTranscriptRole> = Some(HostTranscriptRole::Assistant);
80const TOOL: Option<HostTranscriptRole> = Some(HostTranscriptRole::Tool);
81
82/// The one policy registry for events entering through `agent_emit_event`.
83///
84/// A registry row authorizes host deserialization. `transcript_role` controls
85/// whether the same accepted payload is copied into the durable live-session
86/// journal. Keeping both decisions together prevents an event from appearing
87/// registered at its stdlib call site while being silently absent from one of
88/// the runtime's observation surfaces.
89const HOST_EVENT_POLICIES: &[HostEventPolicy] = &[
90    host_event("tool_call", ASSISTANT),
91    host_event("tool_call_update", ASSISTANT),
92    host_event("iteration_start", None),
93    host_event("iteration_end", None),
94    host_event("judge_decision", None),
95    host_event("step_judge_decision", None),
96    host_event("structural_validator_decision", None),
97    host_event("scope_classifier_verdict", None),
98    host_event("input_guardrail_verdict", None),
99    host_event("missing_tool_call_verdict", None),
100    host_event("require_successful_tools_violation", ASSISTANT),
101    host_event("final_wrapup", ASSISTANT),
102    host_event("pack_thinking_stripped", ASSISTANT),
103    host_event("self_consistency_tie", ASSISTANT),
104    host_event("code_librarian_query_nl_fallback", ASSISTANT),
105    host_event("budget_exhausted", ASSISTANT),
106    host_event("budget_circuit_breaker", ASSISTANT),
107    host_event("progress_reported", None),
108    host_event("tool_search_query", ASSISTANT),
109    host_event("tool_search_result", TOOL),
110    host_event("skill_narrow", ASSISTANT),
111    host_event("loop_control_decision", None),
112    host_event("capability_gap", None),
113    host_event("tool_format_override", ASSISTANT),
114    host_event("tool_call_audit", TOOL),
115    host_event("tool_batch_disposition", TOOL),
116    host_event("loop_checkpoint", ASSISTANT),
117    // The loud-boundary funnel (harn#5142). Registered so a `.harn` boundary
118    // reports a drop through the same typed event as the Rust funnel.
119    host_event("boundary_failure", None),
120    host_event("typed_checkpoint", ASSISTANT),
121    host_event("model_job", TOOL),
122    host_event("loop_stuck", ASSISTANT),
123    host_event("reserved_terminal_verify", ASSISTANT),
124    host_event("agent_loop_stall_warning", ASSISTANT),
125    host_event("cache_hit", None),
126    host_event("cache_miss", None),
127    // `std/llm` handler telemetry. Every one of these shipped in the embedded
128    // stdlib while this registry refused it, so the events were emitted and
129    // dropped; the drift check in `from_host_tests` is what keeps the two
130    // halves together from here.
131    host_event("llm_call_log", None),
132    host_event("llm_routing_decision", None),
133    host_event("llm_fallback_attempt", None),
134    host_event("llm_shadow_diff", None),
135    host_event("semantic_cache_hit", None),
136    host_event("semantic_cache_miss", None),
137    host_event("agent_scratchpad_reorganization", None),
138    host_event("stance_armed", None),
139    host_event("stance_write_access_granted", None),
140    host_event("stance_write_access_denied", None),
141    host_event("stance_disarmed", None),
142    host_event("completion_confirmation_nudge", None),
143    host_event("fenced_call_attempt_nudge", None),
144    host_event("missing_tool_call_nudge", None),
145    host_event("no_progress_streak_nudge", None),
146    host_event("tool_call_blank_name_dropped", None),
147    host_event("llm_auto_continue", None),
148    host_event("context_overflow_recovery", ASSISTANT),
149];
150
151/// Every `event_type` this boundary accepts, for the drift check that keeps
152/// the embedded stdlib's emitters and this registry in sync.
153#[cfg(test)]
154pub(super) fn registered_host_event_types() -> impl Iterator<Item = &'static str> {
155    HOST_EVENT_POLICIES.iter().map(|policy| policy.event_type)
156}
157
158fn host_event_policy(event_type: &str) -> Option<&'static HostEventPolicy> {
159    HOST_EVENT_POLICIES
160        .iter()
161        .find(|policy| policy.event_type == event_type)
162}
163
164impl AgentEvent {
165    /// Build a typed [`AgentEvent`] from a host `emit_event` call.
166    ///
167    /// Mirrors the accept/reject boundary and per-field defaults of the
168    /// retired `build_agent_event` hand-match exactly; unsupported
169    /// `event_type` values return a `Runtime` error.
170    pub fn from_host_payload(
171        session_id: &str,
172        event_type: &str,
173        payload: &Value,
174    ) -> Result<AgentEvent, VmError> {
175        if host_event_policy(event_type).is_none() {
176            return Err(reject(
177                session_id,
178                format!("unsupported event type `{event_type}`"),
179                payload,
180            ));
181        }
182        if let Some(event) = from_host_special(session_id, event_type, payload) {
183            return Ok(event);
184        }
185        from_host_generic(session_id, event_type, payload)
186    }
187
188    pub(crate) fn host_transcript_role(event_type: &str) -> Option<HostTranscriptRole> {
189        host_event_policy(event_type).and_then(|policy| policy.transcript_role)
190    }
191}
192
193/// Arms whose host payload is not a 1:1 field mapping onto the variant:
194/// the whole payload becomes a single field, a couple of fields are
195/// derived, or a nudge `event_type` collapses onto `FeedbackInjected`.
196/// Returns `None` for everything else so the caller falls through to the
197/// generic deserialize path.
198fn from_host_special(session_id: &str, event_type: &str, payload: &Value) -> Option<AgentEvent> {
199    let sid = || session_id.to_string();
200    let feedback = |kind: &str, content: String| AgentEvent::FeedbackInjected {
201        session_id: sid(),
202        kind: kind.to_string(),
203        content,
204        streak: None,
205    };
206    let feedback_with_streak =
207        |kind: &str, content: String, streak: Option<usize>| AgentEvent::FeedbackInjected {
208            session_id: sid(),
209            kind: kind.to_string(),
210            content,
211            streak,
212        };
213    let feedback_content = |fallback: String| {
214        first_non_empty_string(payload, &["content", "message", "text"]).unwrap_or(fallback)
215    };
216    let event = match event_type {
217        "typed_checkpoint" => AgentEvent::TypedCheckpoint {
218            session_id: sid(),
219            checkpoint: payload.clone(),
220        },
221        "model_job" => AgentEvent::ModelJob {
222            session_id: sid(),
223            event: payload.clone(),
224        },
225        "loop_stuck" => AgentEvent::LoopStuckSignal {
226            session_id: sid(),
227            payload: payload.clone(),
228        },
229        "reserved_terminal_verify" => AgentEvent::ReservedTerminalVerify {
230            session_id: sid(),
231            payload: payload.clone(),
232        },
233        "agent_loop_stall_warning" => AgentEvent::AgentLoopStallWarning {
234            session_id: sid(),
235            warning: payload.clone(),
236        },
237        "cache_hit" => AgentEvent::CacheHit {
238            session_id: sid(),
239            key: obj_string(payload, "key"),
240            backend: obj_string(payload, "backend"),
241            namespace: obj_string(payload, "namespace"),
242            payload: payload.clone(),
243        },
244        "cache_miss" => AgentEvent::CacheMiss {
245            session_id: sid(),
246            key: obj_string(payload, "key"),
247            backend: obj_string(payload, "backend"),
248            namespace: obj_string(payload, "namespace"),
249            payload: payload.clone(),
250        },
251        "llm_call_log" => AgentEvent::LlmCallLog {
252            session_id: sid(),
253            model: obj_string(payload, "model"),
254            provider: obj_string(payload, "provider"),
255            status: obj_string(payload, "status"),
256            latency_ms: obj_usize(payload, "latency_ms"),
257            iteration: obj_usize(payload, "iteration"),
258            attempt: obj_usize(payload, "attempt"),
259            payload: payload.clone(),
260        },
261        "llm_routing_decision" => AgentEvent::LlmRoutingDecision {
262            session_id: sid(),
263            route_index: obj_i64(payload, "route_index"),
264            route_name: obj_string(payload, "route_name"),
265            used_default: obj_bool(payload, "used_default"),
266            payload: payload.clone(),
267        },
268        "llm_fallback_attempt" => AgentEvent::LlmFallbackAttempt {
269            session_id: sid(),
270            fallback_index: obj_usize(payload, "fallback_index"),
271            fallback_total: obj_usize(payload, "fallback_total"),
272            ok: obj_bool(payload, "ok"),
273            status: obj_string(payload, "status"),
274            payload: payload.clone(),
275        },
276        "llm_shadow_diff" => AgentEvent::LlmShadowDiff {
277            session_id: sid(),
278            primary_ok: obj_bool(payload, "primary_ok"),
279            shadow_ok: obj_bool(payload, "shadow_ok"),
280            primary_status: obj_string(payload, "primary_status"),
281            shadow_status: obj_string(payload, "shadow_status"),
282            primary_len: obj_usize(payload, "primary_len"),
283            shadow_len: obj_usize(payload, "shadow_len"),
284            payload: payload.clone(),
285        },
286        "semantic_cache_hit" => AgentEvent::SemanticCacheHit {
287            session_id: sid(),
288            similarity: obj_f64(payload, "similarity"),
289            provider: obj_string(payload, "provider"),
290            model: obj_string(payload, "model"),
291            payload: payload.clone(),
292        },
293        "semantic_cache_miss" => AgentEvent::SemanticCacheMiss {
294            session_id: sid(),
295            nearest_similarity: obj_f64(payload, "nearest_similarity"),
296            payload: payload.clone(),
297        },
298        "agent_scratchpad_reorganization" => {
299            let mut details = payload.clone();
300            if let Some(object) = details.as_object_mut() {
301                object.remove("iteration");
302                object.remove("status");
303            }
304            AgentEvent::AgentScratchpadReorganization {
305                session_id: sid(),
306                iteration: obj_usize(payload, "iteration"),
307                status: obj_string(payload, "status"),
308                details,
309            }
310        }
311        // Read-only stance lifecycle (std/agent/stance). The four stdlib
312        // event names map onto one typed variant distinguished by `phase`
313        // so trace consumers match on a single event type.
314        "stance_armed"
315        | "stance_write_access_granted"
316        | "stance_write_access_denied"
317        | "stance_disarmed" => {
318            let allowed_tools = payload
319                .get("allowed_tools")
320                .and_then(Value::as_array)
321                .map(|values| {
322                    values
323                        .iter()
324                        .filter_map(|value| value.as_str().map(str::to_string))
325                        .collect()
326                })
327                .unwrap_or_default();
328            AgentEvent::StanceTransition {
329                session_id: sid(),
330                phase: event_type
331                    .strip_prefix("stance_")
332                    .unwrap_or(event_type)
333                    .to_string(),
334                escape_tool: obj_string(payload, "escape_tool"),
335                allowed_tools,
336                justification: obj_string(payload, "justification"),
337                consent: obj_string(payload, "consent"),
338                reason: obj_string(payload, "reason"),
339            }
340        }
341        // Engine-side corrective nudges (see the retired match's doc
342        // comments): each surfaces to operators on the FeedbackInjected
343        // stream with a synthesized `kind` and a derived `content`.
344        "completion_confirmation_nudge" => feedback(
345            "completion_confirmation_nudge",
346            feedback_content(obj_string(payload, "visible_text_prefix")),
347        ),
348        "fenced_call_attempt_nudge" => feedback(
349            "fenced_call_attempt_nudge",
350            feedback_content(obj_string(payload, "fence")),
351        ),
352        "missing_tool_call_nudge" => feedback(
353            "missing_tool_call_nudge",
354            feedback_content(obj_string(payload, "tool")),
355        ),
356        "no_progress_streak_nudge" => feedback_with_streak(
357            "no_progress_streak_nudge",
358            feedback_content(NO_PROGRESS_STREAK_NUDGE_FALLBACK.to_string()),
359            feedback_streak(payload),
360        ),
361        "tool_call_blank_name_dropped" => feedback(
362            "tool_call_blank_name_dropped",
363            feedback_content(obj_usize(payload, "dropped_count").to_string()),
364        ),
365        "llm_auto_continue" => feedback(
366            "llm_auto_continue",
367            feedback_content(format!(
368                "{}->{} (attempt {}/{})",
369                obj_usize(payload, "previous_max_tokens"),
370                obj_usize(payload, "raised_max_tokens"),
371                obj_usize(payload, "attempt"),
372                obj_usize(payload, "max_continuations"),
373            )),
374        ),
375        "context_overflow_recovery" => feedback(
376            "context_overflow_recovery",
377            feedback_content(format!(
378                "attempt {}/{} archived {} messages",
379                obj_usize(payload, "attempt"),
380                obj_usize(payload, "max_recoveries"),
381                obj_usize(payload, "archived_messages"),
382            )),
383        ),
384        _ => return None,
385    };
386    Some(event)
387}
388
389fn first_non_empty_string(payload: &Value, keys: &[&str]) -> Option<String> {
390    keys.iter().find_map(|key| {
391        payload
392            .get(*key)
393            .and_then(Value::as_str)
394            .filter(|value| !value.trim().is_empty())
395            .map(str::to_string)
396    })
397}
398
399fn feedback_streak(payload: &Value) -> Option<usize> {
400    let streak = obj_usize(payload, "streak").max(obj_usize(payload, "turns_since_progress"));
401    (streak > 0).then_some(streak)
402}
403
404/// Generic path: allowlist-check, normalize the payload to match the
405/// enum's serde shape, deserialize, then override the ambient `audit`
406/// for the two tool-call variants.
407fn from_host_generic(
408    session_id: &str,
409    event_type: &str,
410    payload: &Value,
411) -> Result<AgentEvent, VmError> {
412    let mut obj = match payload {
413        Value::Object(map) => map.clone(),
414        _ => Map::new(),
415    };
416    apply_host_payload_defaults(event_type, &mut obj)?;
417    obj.insert("type".to_string(), Value::String(event_type.to_string()));
418    obj.insert(
419        "session_id".to_string(),
420        Value::String(session_id.to_string()),
421    );
422    let mut event: AgentEvent = serde_json::from_value(Value::Object(obj)).map_err(|error| {
423        reject(
424            session_id,
425            format!("invalid `{event_type}` payload: {error}"),
426            payload,
427        )
428    })?;
429    // `tool_call` / `tool_call_update` carry the mutation-session audit
430    // context active at emit time, never a payload-supplied value.
431    if let AgentEvent::ToolCall { audit, .. } | AgentEvent::ToolCallUpdate { audit, .. } =
432        &mut event
433    {
434        *audit = crate::orchestration::current_mutation_session();
435    }
436    Ok(event)
437}
438
439/// Refuse a host event, loudly.
440///
441/// The `VmError` alone was not enough: every stdlib emit site wraps
442/// `agent_emit_event` in `try { }` and discards the result, so a rejected
443/// event type or a malformed payload used to vanish without a trace — the
444/// runtime's own event bus had a silent boundary in it. The rejection now also
445/// goes out through the loud-boundary funnel (harn#5142), which is not
446/// swallowable by a caller's `try`, so a `.harn` boundary reporting through a
447/// name nobody registered is visible instead of merely ineffective.
448fn reject(session_id: &str, detail: String, payload: &Value) -> VmError {
449    crate::boundary::BoundaryFailure::new(
450        crate::boundary::BoundaryId::HostEventIngest,
451        crate::boundary::BoundaryFailureKind::Unrecognized,
452        detail.clone(),
453    )
454    .in_session(session_id)
455    .with_excerpt(&payload.to_string())
456    .report();
457    VmError::Runtime(format!("{HOST_AGENT_EMIT_EVENT}: {detail}"))
458}
459
460/// Fill in the required-field defaults the retired hand-match applied that
461/// differ from serde's own missing-field behavior (serde already defaults
462/// missing `Option<T>` fields to `None`, so only non-`Option` required
463/// fields with a non-zero/non-empty default need help here).
464fn apply_host_payload_defaults(
465    event_type: &str,
466    obj: &mut Map<String, Value>,
467) -> Result<(), VmError> {
468    match event_type {
469        "tool_call" => {
470            obj.remove("audit"); // sourced from the ambient mutation session
471            set_default(obj, "status", Value::String("pending".to_string()));
472            set_default(obj, "raw_input", Value::Null);
473        }
474        "tool_call_update" => {
475            obj.remove("audit"); // sourced from the ambient mutation session
476            set_default(obj, "status", Value::String("in_progress".to_string()));
477            normalize_executor(obj)?;
478        }
479        "iteration_end" => set_default(obj, "iteration_info", Value::Null),
480        "progress_reported" => {
481            set_default(obj, "entries", Value::Array(Vec::new()));
482            set_default(obj, "replace", Value::Bool(true));
483            set_default(obj, "metadata", Value::Object(Map::new()));
484        }
485        "tool_search_query" => set_default(obj, "query", Value::Null),
486        "tool_search_result" => set_default(obj, "promoted", Value::Array(Vec::new())),
487        "skill_narrow" => {
488            set_default(obj, "removed_tools", Value::Array(Vec::new()));
489            set_default(obj, "remaining_tools", Value::Array(Vec::new()));
490        }
491        "tool_call_audit" => set_default(obj, "audit", Value::Null),
492        // `owner` is derived from `kind`, never supplied: one attribution rule
493        // for the Rust funnel and the `.harn` boundaries alike. A payload that
494        // tries to set it is overruled rather than trusted.
495        "boundary_failure" => {
496            let owner = obj
497                .get("kind")
498                .and_then(Value::as_str)
499                .and_then(|kind| {
500                    serde_json::from_value::<crate::boundary::BoundaryFailureKind>(Value::String(
501                        kind.to_string(),
502                    ))
503                    .ok()
504                })
505                .map(|kind| kind.owner())
506                .unwrap_or("harness");
507            obj.insert("owner".to_string(), Value::String(owner.to_string()));
508        }
509        _ => {}
510    }
511    Ok(())
512}
513
514/// Normalize a bare-string `executor` into the object form
515/// [`super::ToolExecutor`]'s internally-tagged `Deserialize` expects,
516/// preserving the retired match's alias set. Non-string values (absent,
517/// `null`, or an already-structured `mcp_server` object) are left for
518/// serde to handle.
519fn normalize_executor(obj: &mut Map<String, Value>) -> Result<(), VmError> {
520    let raw = match obj.get("executor") {
521        Some(Value::String(value)) => value.clone(),
522        _ => return Ok(()),
523    };
524    let kind = match raw.trim() {
525        "" => {
526            obj.remove("executor");
527            return Ok(());
528        }
529        "harn" | "harn_builtin" => "harn_builtin",
530        "host" | "host_bridge" => "host_bridge",
531        "provider" | "provider_native" => "provider_native",
532        other => {
533            return Err(VmError::Runtime(format!(
534                "{HOST_AGENT_EMIT_EVENT}: invalid tool executor `{other}`"
535            )));
536        }
537    };
538    let mut executor = Map::new();
539    executor.insert("kind".to_string(), Value::String(kind.to_string()));
540    obj.insert("executor".to_string(), Value::Object(executor));
541    Ok(())
542}
543
544fn set_default(obj: &mut Map<String, Value>, key: &str, value: Value) {
545    obj.entry(key).or_insert(value);
546}
547
548fn obj_string(payload: &Value, key: &str) -> String {
549    payload
550        .get(key)
551        .and_then(Value::as_str)
552        .unwrap_or("")
553        .to_string()
554}
555
556fn obj_usize(payload: &Value, key: &str) -> usize {
557    payload.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
558}
559
560fn obj_bool(payload: &Value, key: &str) -> bool {
561    payload.get(key).and_then(Value::as_bool).unwrap_or(false)
562}
563
564fn obj_f64(payload: &Value, key: &str) -> f64 {
565    payload.get(key).and_then(Value::as_f64).unwrap_or(0.0)
566}
567
568/// A route index is `-1` when the router fell through to its default, so this
569/// one cannot borrow [`obj_usize`].
570fn obj_i64(payload: &Value, key: &str) -> i64 {
571    payload.get(key).and_then(Value::as_i64).unwrap_or(0)
572}