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