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