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("loop_stuck", ASSISTANT),
122    host_event("reserved_terminal_verify", ASSISTANT),
123    host_event("agent_loop_stall_warning", ASSISTANT),
124    host_event("cache_hit", None),
125    host_event("cache_miss", None),
126    host_event("agent_scratchpad_reorganization", None),
127    host_event("stance_armed", None),
128    host_event("stance_write_access_granted", None),
129    host_event("stance_write_access_denied", None),
130    host_event("stance_disarmed", None),
131    host_event("completion_confirmation_nudge", None),
132    host_event("fenced_call_attempt_nudge", None),
133    host_event("missing_tool_call_nudge", None),
134    host_event("no_progress_streak_nudge", None),
135    host_event("tool_call_blank_name_dropped", None),
136    host_event("llm_auto_continue", None),
137    host_event("context_overflow_recovery", ASSISTANT),
138];
139
140fn host_event_policy(event_type: &str) -> Option<&'static HostEventPolicy> {
141    HOST_EVENT_POLICIES
142        .iter()
143        .find(|policy| policy.event_type == event_type)
144}
145
146impl AgentEvent {
147    /// Build a typed [`AgentEvent`] from a host `emit_event` call.
148    ///
149    /// Mirrors the accept/reject boundary and per-field defaults of the
150    /// retired `build_agent_event` hand-match exactly; unsupported
151    /// `event_type` values return a `Runtime` error.
152    pub fn from_host_payload(
153        session_id: &str,
154        event_type: &str,
155        payload: &Value,
156    ) -> Result<AgentEvent, VmError> {
157        if host_event_policy(event_type).is_none() {
158            return Err(reject(
159                session_id,
160                format!("unsupported event type `{event_type}`"),
161                payload,
162            ));
163        }
164        if let Some(event) = from_host_special(session_id, event_type, payload) {
165            return Ok(event);
166        }
167        from_host_generic(session_id, event_type, payload)
168    }
169
170    pub(crate) fn host_transcript_role(event_type: &str) -> Option<HostTranscriptRole> {
171        host_event_policy(event_type).and_then(|policy| policy.transcript_role)
172    }
173}
174
175/// Arms whose host payload is not a 1:1 field mapping onto the variant:
176/// the whole payload becomes a single field, a couple of fields are
177/// derived, or a nudge `event_type` collapses onto `FeedbackInjected`.
178/// Returns `None` for everything else so the caller falls through to the
179/// generic deserialize path.
180fn from_host_special(session_id: &str, event_type: &str, payload: &Value) -> Option<AgentEvent> {
181    let sid = || session_id.to_string();
182    let feedback = |kind: &str, content: String| AgentEvent::FeedbackInjected {
183        session_id: sid(),
184        kind: kind.to_string(),
185        content,
186        streak: None,
187    };
188    let feedback_with_streak =
189        |kind: &str, content: String, streak: Option<usize>| AgentEvent::FeedbackInjected {
190            session_id: sid(),
191            kind: kind.to_string(),
192            content,
193            streak,
194        };
195    let feedback_content = |fallback: String| {
196        first_non_empty_string(payload, &["content", "message", "text"]).unwrap_or(fallback)
197    };
198    let event = match event_type {
199        "typed_checkpoint" => AgentEvent::TypedCheckpoint {
200            session_id: sid(),
201            checkpoint: payload.clone(),
202        },
203        "loop_stuck" => AgentEvent::LoopStuckSignal {
204            session_id: sid(),
205            payload: payload.clone(),
206        },
207        "reserved_terminal_verify" => AgentEvent::ReservedTerminalVerify {
208            session_id: sid(),
209            payload: payload.clone(),
210        },
211        "agent_loop_stall_warning" => AgentEvent::AgentLoopStallWarning {
212            session_id: sid(),
213            warning: payload.clone(),
214        },
215        "cache_hit" => AgentEvent::CacheHit {
216            session_id: sid(),
217            key: obj_string(payload, "key"),
218            backend: obj_string(payload, "backend"),
219            namespace: obj_string(payload, "namespace"),
220            payload: payload.clone(),
221        },
222        "cache_miss" => AgentEvent::CacheMiss {
223            session_id: sid(),
224            key: obj_string(payload, "key"),
225            backend: obj_string(payload, "backend"),
226            namespace: obj_string(payload, "namespace"),
227            payload: payload.clone(),
228        },
229        "agent_scratchpad_reorganization" => {
230            let mut details = payload.clone();
231            if let Some(object) = details.as_object_mut() {
232                object.remove("iteration");
233                object.remove("status");
234            }
235            AgentEvent::AgentScratchpadReorganization {
236                session_id: sid(),
237                iteration: obj_usize(payload, "iteration"),
238                status: obj_string(payload, "status"),
239                details,
240            }
241        }
242        // Read-only stance lifecycle (std/agent/stance). The four stdlib
243        // event names map onto one typed variant distinguished by `phase`
244        // so trace consumers match on a single event type.
245        "stance_armed"
246        | "stance_write_access_granted"
247        | "stance_write_access_denied"
248        | "stance_disarmed" => {
249            let allowed_tools = payload
250                .get("allowed_tools")
251                .and_then(Value::as_array)
252                .map(|values| {
253                    values
254                        .iter()
255                        .filter_map(|value| value.as_str().map(str::to_string))
256                        .collect()
257                })
258                .unwrap_or_default();
259            AgentEvent::StanceTransition {
260                session_id: sid(),
261                phase: event_type
262                    .strip_prefix("stance_")
263                    .unwrap_or(event_type)
264                    .to_string(),
265                escape_tool: obj_string(payload, "escape_tool"),
266                allowed_tools,
267                justification: obj_string(payload, "justification"),
268                consent: obj_string(payload, "consent"),
269                reason: obj_string(payload, "reason"),
270            }
271        }
272        // Engine-side corrective nudges (see the retired match's doc
273        // comments): each surfaces to operators on the FeedbackInjected
274        // stream with a synthesized `kind` and a derived `content`.
275        "completion_confirmation_nudge" => feedback(
276            "completion_confirmation_nudge",
277            feedback_content(obj_string(payload, "visible_text_prefix")),
278        ),
279        "fenced_call_attempt_nudge" => feedback(
280            "fenced_call_attempt_nudge",
281            feedback_content(obj_string(payload, "fence")),
282        ),
283        "missing_tool_call_nudge" => feedback(
284            "missing_tool_call_nudge",
285            feedback_content(obj_string(payload, "tool")),
286        ),
287        "no_progress_streak_nudge" => feedback_with_streak(
288            "no_progress_streak_nudge",
289            feedback_content(NO_PROGRESS_STREAK_NUDGE_FALLBACK.to_string()),
290            feedback_streak(payload),
291        ),
292        "tool_call_blank_name_dropped" => feedback(
293            "tool_call_blank_name_dropped",
294            feedback_content(obj_usize(payload, "dropped_count").to_string()),
295        ),
296        "llm_auto_continue" => feedback(
297            "llm_auto_continue",
298            feedback_content(format!(
299                "{}->{} (attempt {}/{})",
300                obj_usize(payload, "previous_max_tokens"),
301                obj_usize(payload, "raised_max_tokens"),
302                obj_usize(payload, "attempt"),
303                obj_usize(payload, "max_continuations"),
304            )),
305        ),
306        "context_overflow_recovery" => feedback(
307            "context_overflow_recovery",
308            feedback_content(format!(
309                "attempt {}/{} archived {} messages",
310                obj_usize(payload, "attempt"),
311                obj_usize(payload, "max_recoveries"),
312                obj_usize(payload, "archived_messages"),
313            )),
314        ),
315        _ => return None,
316    };
317    Some(event)
318}
319
320fn first_non_empty_string(payload: &Value, keys: &[&str]) -> Option<String> {
321    keys.iter().find_map(|key| {
322        payload
323            .get(*key)
324            .and_then(Value::as_str)
325            .filter(|value| !value.trim().is_empty())
326            .map(str::to_string)
327    })
328}
329
330fn feedback_streak(payload: &Value) -> Option<usize> {
331    let streak = obj_usize(payload, "streak").max(obj_usize(payload, "turns_since_progress"));
332    (streak > 0).then_some(streak)
333}
334
335/// Generic path: allowlist-check, normalize the payload to match the
336/// enum's serde shape, deserialize, then override the ambient `audit`
337/// for the two tool-call variants.
338fn from_host_generic(
339    session_id: &str,
340    event_type: &str,
341    payload: &Value,
342) -> Result<AgentEvent, VmError> {
343    let mut obj = match payload {
344        Value::Object(map) => map.clone(),
345        _ => Map::new(),
346    };
347    apply_host_payload_defaults(event_type, &mut obj)?;
348    obj.insert("type".to_string(), Value::String(event_type.to_string()));
349    obj.insert(
350        "session_id".to_string(),
351        Value::String(session_id.to_string()),
352    );
353    let mut event: AgentEvent = serde_json::from_value(Value::Object(obj)).map_err(|error| {
354        reject(
355            session_id,
356            format!("invalid `{event_type}` payload: {error}"),
357            payload,
358        )
359    })?;
360    // `tool_call` / `tool_call_update` carry the mutation-session audit
361    // context active at emit time, never a payload-supplied value.
362    if let AgentEvent::ToolCall { audit, .. } | AgentEvent::ToolCallUpdate { audit, .. } =
363        &mut event
364    {
365        *audit = crate::orchestration::current_mutation_session();
366    }
367    Ok(event)
368}
369
370/// Refuse a host event, loudly.
371///
372/// The `VmError` alone was not enough: every stdlib emit site wraps
373/// `agent_emit_event` in `try { }` and discards the result, so a rejected
374/// event type or a malformed payload used to vanish without a trace — the
375/// runtime's own event bus had a silent boundary in it. The rejection now also
376/// goes out through the loud-boundary funnel (harn#5142), which is not
377/// swallowable by a caller's `try`, so a `.harn` boundary reporting through a
378/// name nobody registered is visible instead of merely ineffective.
379fn reject(session_id: &str, detail: String, payload: &Value) -> VmError {
380    crate::boundary::BoundaryFailure::new(
381        crate::boundary::BoundaryId::HostEventIngest,
382        crate::boundary::BoundaryFailureKind::Unrecognized,
383        detail.clone(),
384    )
385    .in_session(session_id)
386    .with_excerpt(&payload.to_string())
387    .report();
388    VmError::Runtime(format!("{HOST_AGENT_EMIT_EVENT}: {detail}"))
389}
390
391/// Fill in the required-field defaults the retired hand-match applied that
392/// differ from serde's own missing-field behavior (serde already defaults
393/// missing `Option<T>` fields to `None`, so only non-`Option` required
394/// fields with a non-zero/non-empty default need help here).
395fn apply_host_payload_defaults(
396    event_type: &str,
397    obj: &mut Map<String, Value>,
398) -> Result<(), VmError> {
399    match event_type {
400        "tool_call" => {
401            obj.remove("audit"); // sourced from the ambient mutation session
402            set_default(obj, "status", Value::String("pending".to_string()));
403            set_default(obj, "raw_input", Value::Null);
404        }
405        "tool_call_update" => {
406            obj.remove("audit"); // sourced from the ambient mutation session
407            set_default(obj, "status", Value::String("in_progress".to_string()));
408            normalize_executor(obj)?;
409        }
410        "iteration_end" => set_default(obj, "iteration_info", Value::Null),
411        "progress_reported" => {
412            set_default(obj, "entries", Value::Array(Vec::new()));
413            set_default(obj, "replace", Value::Bool(true));
414            set_default(obj, "metadata", Value::Object(Map::new()));
415        }
416        "tool_search_query" => set_default(obj, "query", Value::Null),
417        "tool_search_result" => set_default(obj, "promoted", Value::Array(Vec::new())),
418        "skill_narrow" => {
419            set_default(obj, "removed_tools", Value::Array(Vec::new()));
420            set_default(obj, "remaining_tools", Value::Array(Vec::new()));
421        }
422        "tool_call_audit" => set_default(obj, "audit", Value::Null),
423        // `owner` is derived from `kind`, never supplied: one attribution rule
424        // for the Rust funnel and the `.harn` boundaries alike. A payload that
425        // tries to set it is overruled rather than trusted.
426        "boundary_failure" => {
427            let owner = obj
428                .get("kind")
429                .and_then(Value::as_str)
430                .and_then(|kind| {
431                    serde_json::from_value::<crate::boundary::BoundaryFailureKind>(Value::String(
432                        kind.to_string(),
433                    ))
434                    .ok()
435                })
436                .map(|kind| kind.owner())
437                .unwrap_or("harness");
438            obj.insert("owner".to_string(), Value::String(owner.to_string()));
439        }
440        _ => {}
441    }
442    Ok(())
443}
444
445/// Normalize a bare-string `executor` into the object form
446/// [`super::ToolExecutor`]'s internally-tagged `Deserialize` expects,
447/// preserving the retired match's alias set. Non-string values (absent,
448/// `null`, or an already-structured `mcp_server` object) are left for
449/// serde to handle.
450fn normalize_executor(obj: &mut Map<String, Value>) -> Result<(), VmError> {
451    let raw = match obj.get("executor") {
452        Some(Value::String(value)) => value.clone(),
453        _ => return Ok(()),
454    };
455    let kind = match raw.trim() {
456        "" => {
457            obj.remove("executor");
458            return Ok(());
459        }
460        "harn" | "harn_builtin" => "harn_builtin",
461        "host" | "host_bridge" => "host_bridge",
462        "provider" | "provider_native" => "provider_native",
463        other => {
464            return Err(VmError::Runtime(format!(
465                "{HOST_AGENT_EMIT_EVENT}: invalid tool executor `{other}`"
466            )));
467        }
468    };
469    let mut executor = Map::new();
470    executor.insert("kind".to_string(), Value::String(kind.to_string()));
471    obj.insert("executor".to_string(), Value::Object(executor));
472    Ok(())
473}
474
475fn set_default(obj: &mut Map<String, Value>, key: &str, value: Value) {
476    obj.entry(key).or_insert(value);
477}
478
479fn obj_string(payload: &Value, key: &str) -> String {
480    payload
481        .get(key)
482        .and_then(Value::as_str)
483        .unwrap_or("")
484        .to_string()
485}
486
487fn obj_usize(payload: &Value, key: &str) -> usize {
488    payload.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
489}