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