1use 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
48const 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 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
94fn 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 "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 "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
258fn 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 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
296fn 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"); 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"); 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
333fn 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}