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//! The set of accepted `event_type` strings is an explicit allowlist
33//! ([`from_host_special`] + [`HOST_DESERIALIZE_EVENT_TYPES`]) so the
34//! accept/reject boundary is byte-identical to the retired match — 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";
45
46/// `event_type` strings that deserialize directly into an [`AgentEvent`]
47/// variant once normalized. Kept as an explicit allowlist so this host
48/// path accepts exactly the set the retired hand-match accepted and
49/// rejects every other variant (which is constructed on non-host paths).
50const HOST_DESERIALIZE_EVENT_TYPES: &[&str] = &[
51    "tool_call",
52    "tool_call_update",
53    "iteration_start",
54    "iteration_end",
55    "judge_decision",
56    "step_judge_decision",
57    "structural_validator_decision",
58    "scope_classifier_verdict",
59    "input_guardrail_verdict",
60    "missing_tool_call_verdict",
61    "budget_exhausted",
62    "budget_circuit_breaker",
63    "progress_reported",
64    "tool_search_query",
65    "tool_search_result",
66    "skill_narrow",
67    "loop_control_decision",
68    "capability_gap",
69    "tool_format_override",
70    "tool_call_audit",
71    "loop_checkpoint",
72];
73
74impl AgentEvent {
75    /// Build a typed [`AgentEvent`] from a host `emit_event` call.
76    ///
77    /// Mirrors the accept/reject boundary and per-field defaults of the
78    /// retired `build_agent_event` hand-match exactly; unsupported
79    /// `event_type` values return a `Runtime` error.
80    pub fn from_host_payload(
81        session_id: &str,
82        event_type: &str,
83        payload: &Value,
84    ) -> Result<AgentEvent, VmError> {
85        if let Some(event) = from_host_special(session_id, event_type, payload) {
86            return Ok(event);
87        }
88        from_host_generic(session_id, event_type, payload)
89    }
90}
91
92/// Arms whose host payload is not a 1:1 field mapping onto the variant:
93/// the whole payload becomes a single field, a couple of fields are
94/// derived, or a nudge `event_type` collapses onto `FeedbackInjected`.
95/// Returns `None` for everything else so the caller falls through to the
96/// generic deserialize path.
97fn from_host_special(session_id: &str, event_type: &str, payload: &Value) -> Option<AgentEvent> {
98    let sid = || session_id.to_string();
99    let feedback = |kind: &str, content: String| AgentEvent::FeedbackInjected {
100        session_id: sid(),
101        kind: kind.to_string(),
102        content,
103    };
104    let event = match event_type {
105        "typed_checkpoint" => AgentEvent::TypedCheckpoint {
106            session_id: sid(),
107            checkpoint: payload.clone(),
108        },
109        "loop_stuck" => AgentEvent::LoopStuckSignal {
110            session_id: sid(),
111            payload: payload.clone(),
112        },
113        "reserved_terminal_verify" => AgentEvent::ReservedTerminalVerify {
114            session_id: sid(),
115            payload: payload.clone(),
116        },
117        "agent_loop_stall_warning" => AgentEvent::AgentLoopStallWarning {
118            session_id: sid(),
119            warning: payload.clone(),
120        },
121        "cache_hit" => AgentEvent::CacheHit {
122            session_id: sid(),
123            key: obj_string(payload, "key"),
124            backend: obj_string(payload, "backend"),
125            namespace: obj_string(payload, "namespace"),
126            payload: payload.clone(),
127        },
128        "cache_miss" => AgentEvent::CacheMiss {
129            session_id: sid(),
130            key: obj_string(payload, "key"),
131            backend: obj_string(payload, "backend"),
132            namespace: obj_string(payload, "namespace"),
133            payload: payload.clone(),
134        },
135        "agent_scratchpad_reorganization" => {
136            let mut details = payload.clone();
137            if let Some(object) = details.as_object_mut() {
138                object.remove("iteration");
139                object.remove("status");
140            }
141            AgentEvent::AgentScratchpadReorganization {
142                session_id: sid(),
143                iteration: obj_usize(payload, "iteration"),
144                status: obj_string(payload, "status"),
145                details,
146            }
147        }
148        // Read-only stance lifecycle (std/agent/stance). The four stdlib
149        // event names map onto one typed variant distinguished by `phase`
150        // so trace consumers match on a single event type.
151        "stance_armed"
152        | "stance_write_access_granted"
153        | "stance_write_access_denied"
154        | "stance_disarmed" => {
155            let allowed_tools = payload
156                .get("allowed_tools")
157                .and_then(Value::as_array)
158                .map(|values| {
159                    values
160                        .iter()
161                        .filter_map(|value| value.as_str().map(str::to_string))
162                        .collect()
163                })
164                .unwrap_or_default();
165            AgentEvent::StanceTransition {
166                session_id: sid(),
167                phase: event_type
168                    .strip_prefix("stance_")
169                    .unwrap_or(event_type)
170                    .to_string(),
171                escape_tool: obj_string(payload, "escape_tool"),
172                allowed_tools,
173                justification: obj_string(payload, "justification"),
174                consent: obj_string(payload, "consent"),
175                reason: obj_string(payload, "reason"),
176            }
177        }
178        // Engine-side corrective nudges (see the retired match's doc
179        // comments): each surfaces to operators on the FeedbackInjected
180        // stream with a synthesized `kind` and a derived `content`.
181        "completion_confirmation_nudge" => feedback(
182            "completion_confirmation_nudge",
183            obj_string(payload, "visible_text_prefix"),
184        ),
185        "fenced_call_attempt_nudge" => {
186            feedback("fenced_call_attempt_nudge", obj_string(payload, "fence"))
187        }
188        "missing_tool_call_nudge" => {
189            feedback("missing_tool_call_nudge", obj_string(payload, "tool"))
190        }
191        "no_progress_streak_nudge" => feedback(
192            "no_progress_streak_nudge",
193            obj_usize(payload, "turns_since_progress").to_string(),
194        ),
195        "tool_parse_error_feedback" => feedback(
196            "tool_parse_error_feedback",
197            obj_string(payload, "error_summary"),
198        ),
199        "tool_call_blank_name_dropped" => feedback(
200            "tool_call_blank_name_dropped",
201            obj_usize(payload, "dropped_count").to_string(),
202        ),
203        "llm_auto_continue" => feedback(
204            "llm_auto_continue",
205            format!(
206                "{}->{} (attempt {}/{})",
207                obj_usize(payload, "previous_max_tokens"),
208                obj_usize(payload, "raised_max_tokens"),
209                obj_usize(payload, "attempt"),
210                obj_usize(payload, "max_continuations"),
211            ),
212        ),
213        "context_overflow_recovery" => feedback(
214            "context_overflow_recovery",
215            format!(
216                "attempt {}/{} archived {} messages",
217                obj_usize(payload, "attempt"),
218                obj_usize(payload, "max_recoveries"),
219                obj_usize(payload, "archived_messages"),
220            ),
221        ),
222        _ => return None,
223    };
224    Some(event)
225}
226
227/// Generic path: allowlist-check, normalize the payload to match the
228/// enum's serde shape, deserialize, then override the ambient `audit`
229/// for the two tool-call variants.
230fn from_host_generic(
231    session_id: &str,
232    event_type: &str,
233    payload: &Value,
234) -> Result<AgentEvent, VmError> {
235    if !HOST_DESERIALIZE_EVENT_TYPES.contains(&event_type) {
236        return Err(VmError::Runtime(format!(
237            "{HOST_AGENT_EMIT_EVENT}: unsupported event type `{event_type}`"
238        )));
239    }
240    let mut obj = match payload {
241        Value::Object(map) => map.clone(),
242        _ => Map::new(),
243    };
244    apply_host_payload_defaults(event_type, &mut obj)?;
245    obj.insert("type".to_string(), Value::String(event_type.to_string()));
246    obj.insert(
247        "session_id".to_string(),
248        Value::String(session_id.to_string()),
249    );
250    let mut event: AgentEvent = serde_json::from_value(Value::Object(obj)).map_err(|error| {
251        VmError::Runtime(format!(
252            "{HOST_AGENT_EMIT_EVENT}: invalid `{event_type}` payload: {error}"
253        ))
254    })?;
255    // `tool_call` / `tool_call_update` carry the mutation-session audit
256    // context active at emit time, never a payload-supplied value.
257    if let AgentEvent::ToolCall { audit, .. } | AgentEvent::ToolCallUpdate { audit, .. } =
258        &mut event
259    {
260        *audit = crate::orchestration::current_mutation_session();
261    }
262    Ok(event)
263}
264
265/// Fill in the required-field defaults the retired hand-match applied that
266/// differ from serde's own missing-field behavior (serde already defaults
267/// missing `Option<T>` fields to `None`, so only non-`Option` required
268/// fields with a non-zero/non-empty default need help here).
269fn apply_host_payload_defaults(
270    event_type: &str,
271    obj: &mut Map<String, Value>,
272) -> Result<(), VmError> {
273    match event_type {
274        "tool_call" => {
275            obj.remove("audit"); // sourced from the ambient mutation session
276            set_default(obj, "status", Value::String("pending".to_string()));
277            set_default(obj, "raw_input", Value::Null);
278        }
279        "tool_call_update" => {
280            obj.remove("audit"); // sourced from the ambient mutation session
281            set_default(obj, "status", Value::String("in_progress".to_string()));
282            normalize_executor(obj)?;
283        }
284        "iteration_end" => set_default(obj, "iteration_info", Value::Null),
285        "progress_reported" => {
286            set_default(obj, "entries", Value::Array(Vec::new()));
287            set_default(obj, "replace", Value::Bool(true));
288            set_default(obj, "metadata", Value::Object(Map::new()));
289        }
290        "tool_search_query" => set_default(obj, "query", Value::Null),
291        "tool_search_result" => set_default(obj, "promoted", Value::Array(Vec::new())),
292        "skill_narrow" => {
293            set_default(obj, "removed_tools", Value::Array(Vec::new()));
294            set_default(obj, "remaining_tools", Value::Array(Vec::new()));
295        }
296        "tool_call_audit" => set_default(obj, "audit", Value::Null),
297        _ => {}
298    }
299    Ok(())
300}
301
302/// Normalize a bare-string `executor` into the object form
303/// [`super::ToolExecutor`]'s internally-tagged `Deserialize` expects,
304/// preserving the retired match's alias set. Non-string values (absent,
305/// `null`, or an already-structured `mcp_server` object) are left for
306/// serde to handle.
307fn normalize_executor(obj: &mut Map<String, Value>) -> Result<(), VmError> {
308    let raw = match obj.get("executor") {
309        Some(Value::String(value)) => value.clone(),
310        _ => return Ok(()),
311    };
312    let kind = match raw.trim() {
313        "" => {
314            obj.remove("executor");
315            return Ok(());
316        }
317        "harn" | "harn_builtin" => "harn_builtin",
318        "host" | "host_bridge" => "host_bridge",
319        "provider" | "provider_native" => "provider_native",
320        other => {
321            return Err(VmError::Runtime(format!(
322                "{HOST_AGENT_EMIT_EVENT}: invalid tool executor `{other}`"
323            )));
324        }
325    };
326    let mut executor = Map::new();
327    executor.insert("kind".to_string(), Value::String(kind.to_string()));
328    obj.insert("executor".to_string(), Value::Object(executor));
329    Ok(())
330}
331
332fn set_default(obj: &mut Map<String, Value>, key: &str, value: Value) {
333    obj.entry(key).or_insert(value);
334}
335
336fn obj_string(payload: &Value, key: &str) -> String {
337    payload
338        .get(key)
339        .and_then(Value::as_str)
340        .unwrap_or("")
341        .to_string()
342}
343
344fn obj_usize(payload: &Value, key: &str) -> usize {
345    payload.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
346}