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
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub(crate) enum HostTranscriptRole {
50 Assistant,
51 Tool,
52}
53
54impl HostTranscriptRole {
55 pub(crate) const fn as_str(self) -> &'static str {
56 match self {
57 Self::Assistant => "assistant",
58 Self::Tool => "tool",
59 }
60 }
61}
62
63#[derive(Clone, Copy, Debug)]
64struct HostEventPolicy {
65 event_type: &'static str,
66 transcript_role: Option<HostTranscriptRole>,
67}
68
69const fn host_event(
70 event_type: &'static str,
71 transcript_role: Option<HostTranscriptRole>,
72) -> HostEventPolicy {
73 HostEventPolicy {
74 event_type,
75 transcript_role,
76 }
77}
78
79const ASSISTANT: Option<HostTranscriptRole> = Some(HostTranscriptRole::Assistant);
80const TOOL: Option<HostTranscriptRole> = Some(HostTranscriptRole::Tool);
81
82const HOST_EVENT_POLICIES: &[HostEventPolicy] = &[
90 host_event("tool_call", ASSISTANT),
91 host_event("tool_call_update", ASSISTANT),
92 host_event("iteration_start", None),
93 host_event("iteration_end", None),
94 host_event("judge_decision", None),
95 host_event("step_judge_decision", None),
96 host_event("structural_validator_decision", None),
97 host_event("scope_classifier_verdict", None),
98 host_event("input_guardrail_verdict", None),
99 host_event("missing_tool_call_verdict", None),
100 host_event("require_successful_tools_violation", ASSISTANT),
101 host_event("final_wrapup", ASSISTANT),
102 host_event("pack_thinking_stripped", ASSISTANT),
103 host_event("self_consistency_tie", ASSISTANT),
104 host_event("code_librarian_query_nl_fallback", ASSISTANT),
105 host_event("budget_exhausted", ASSISTANT),
106 host_event("budget_circuit_breaker", ASSISTANT),
107 host_event("progress_reported", None),
108 host_event("tool_search_query", ASSISTANT),
109 host_event("tool_search_result", TOOL),
110 host_event("skill_narrow", ASSISTANT),
111 host_event("loop_control_decision", None),
112 host_event("capability_gap", None),
113 host_event("tool_format_override", ASSISTANT),
114 host_event("tool_call_audit", TOOL),
115 host_event("tool_batch_disposition", TOOL),
116 host_event("loop_checkpoint", ASSISTANT),
117 host_event("boundary_failure", None),
120 host_event("typed_checkpoint", ASSISTANT),
121 host_event("model_job", TOOL),
122 host_event("loop_stuck", ASSISTANT),
123 host_event("reserved_terminal_verify", ASSISTANT),
124 host_event("agent_loop_stall_warning", ASSISTANT),
125 host_event("cache_hit", None),
126 host_event("cache_miss", None),
127 host_event("llm_call_log", None),
132 host_event("llm_routing_decision", None),
133 host_event("llm_fallback_attempt", None),
134 host_event("llm_shadow_diff", None),
135 host_event("semantic_cache_hit", None),
136 host_event("semantic_cache_miss", None),
137 host_event("agent_scratchpad_reorganization", None),
138 host_event("stance_armed", None),
139 host_event("stance_write_access_granted", None),
140 host_event("stance_write_access_denied", None),
141 host_event("stance_disarmed", None),
142 host_event("completion_confirmation_nudge", None),
143 host_event("fenced_call_attempt_nudge", None),
144 host_event("missing_tool_call_nudge", None),
145 host_event("no_progress_streak_nudge", None),
146 host_event("tool_call_blank_name_dropped", None),
147 host_event("llm_auto_continue", None),
148 host_event("context_overflow_recovery", ASSISTANT),
149];
150
151#[cfg(test)]
154pub(super) fn registered_host_event_types() -> impl Iterator<Item = &'static str> {
155 HOST_EVENT_POLICIES.iter().map(|policy| policy.event_type)
156}
157
158fn host_event_policy(event_type: &str) -> Option<&'static HostEventPolicy> {
159 HOST_EVENT_POLICIES
160 .iter()
161 .find(|policy| policy.event_type == event_type)
162}
163
164impl AgentEvent {
165 pub fn from_host_payload(
171 session_id: &str,
172 event_type: &str,
173 payload: &Value,
174 ) -> Result<AgentEvent, VmError> {
175 if host_event_policy(event_type).is_none() {
176 return Err(reject(
177 session_id,
178 format!("unsupported event type `{event_type}`"),
179 payload,
180 ));
181 }
182 if let Some(event) = from_host_special(session_id, event_type, payload) {
183 return Ok(event);
184 }
185 from_host_generic(session_id, event_type, payload)
186 }
187
188 pub(crate) fn host_transcript_role(event_type: &str) -> Option<HostTranscriptRole> {
189 host_event_policy(event_type).and_then(|policy| policy.transcript_role)
190 }
191}
192
193fn from_host_special(session_id: &str, event_type: &str, payload: &Value) -> Option<AgentEvent> {
199 let sid = || session_id.to_string();
200 let feedback = |kind: &str, content: String| AgentEvent::FeedbackInjected {
201 session_id: sid(),
202 kind: kind.to_string(),
203 content,
204 streak: None,
205 };
206 let feedback_with_streak =
207 |kind: &str, content: String, streak: Option<usize>| AgentEvent::FeedbackInjected {
208 session_id: sid(),
209 kind: kind.to_string(),
210 content,
211 streak,
212 };
213 let feedback_content = |fallback: String| {
214 first_non_empty_string(payload, &["content", "message", "text"]).unwrap_or(fallback)
215 };
216 let event = match event_type {
217 "typed_checkpoint" => AgentEvent::TypedCheckpoint {
218 session_id: sid(),
219 checkpoint: payload.clone(),
220 },
221 "model_job" => AgentEvent::ModelJob {
222 session_id: sid(),
223 event: payload.clone(),
224 },
225 "loop_stuck" => AgentEvent::LoopStuckSignal {
226 session_id: sid(),
227 payload: payload.clone(),
228 },
229 "reserved_terminal_verify" => AgentEvent::ReservedTerminalVerify {
230 session_id: sid(),
231 payload: payload.clone(),
232 },
233 "agent_loop_stall_warning" => AgentEvent::AgentLoopStallWarning {
234 session_id: sid(),
235 warning: payload.clone(),
236 },
237 "cache_hit" => AgentEvent::CacheHit {
238 session_id: sid(),
239 key: obj_string(payload, "key"),
240 backend: obj_string(payload, "backend"),
241 namespace: obj_string(payload, "namespace"),
242 payload: payload.clone(),
243 },
244 "cache_miss" => AgentEvent::CacheMiss {
245 session_id: sid(),
246 key: obj_string(payload, "key"),
247 backend: obj_string(payload, "backend"),
248 namespace: obj_string(payload, "namespace"),
249 payload: payload.clone(),
250 },
251 "llm_call_log" => AgentEvent::LlmCallLog {
252 session_id: sid(),
253 model: obj_string(payload, "model"),
254 provider: obj_string(payload, "provider"),
255 status: obj_string(payload, "status"),
256 latency_ms: obj_usize(payload, "latency_ms"),
257 iteration: obj_usize(payload, "iteration"),
258 attempt: obj_usize(payload, "attempt"),
259 payload: payload.clone(),
260 },
261 "llm_routing_decision" => AgentEvent::LlmRoutingDecision {
262 session_id: sid(),
263 route_index: obj_i64(payload, "route_index"),
264 route_name: obj_string(payload, "route_name"),
265 used_default: obj_bool(payload, "used_default"),
266 payload: payload.clone(),
267 },
268 "llm_fallback_attempt" => AgentEvent::LlmFallbackAttempt {
269 session_id: sid(),
270 fallback_index: obj_usize(payload, "fallback_index"),
271 fallback_total: obj_usize(payload, "fallback_total"),
272 ok: obj_bool(payload, "ok"),
273 status: obj_string(payload, "status"),
274 payload: payload.clone(),
275 },
276 "llm_shadow_diff" => AgentEvent::LlmShadowDiff {
277 session_id: sid(),
278 primary_ok: obj_bool(payload, "primary_ok"),
279 shadow_ok: obj_bool(payload, "shadow_ok"),
280 primary_status: obj_string(payload, "primary_status"),
281 shadow_status: obj_string(payload, "shadow_status"),
282 primary_len: obj_usize(payload, "primary_len"),
283 shadow_len: obj_usize(payload, "shadow_len"),
284 payload: payload.clone(),
285 },
286 "semantic_cache_hit" => AgentEvent::SemanticCacheHit {
287 session_id: sid(),
288 similarity: obj_f64(payload, "similarity"),
289 provider: obj_string(payload, "provider"),
290 model: obj_string(payload, "model"),
291 payload: payload.clone(),
292 },
293 "semantic_cache_miss" => AgentEvent::SemanticCacheMiss {
294 session_id: sid(),
295 nearest_similarity: obj_f64(payload, "nearest_similarity"),
296 payload: payload.clone(),
297 },
298 "agent_scratchpad_reorganization" => {
299 let mut details = payload.clone();
300 if let Some(object) = details.as_object_mut() {
301 object.remove("iteration");
302 object.remove("status");
303 }
304 AgentEvent::AgentScratchpadReorganization {
305 session_id: sid(),
306 iteration: obj_usize(payload, "iteration"),
307 status: obj_string(payload, "status"),
308 details,
309 }
310 }
311 "stance_armed"
315 | "stance_write_access_granted"
316 | "stance_write_access_denied"
317 | "stance_disarmed" => {
318 let allowed_tools = payload
319 .get("allowed_tools")
320 .and_then(Value::as_array)
321 .map(|values| {
322 values
323 .iter()
324 .filter_map(|value| value.as_str().map(str::to_string))
325 .collect()
326 })
327 .unwrap_or_default();
328 AgentEvent::StanceTransition {
329 session_id: sid(),
330 phase: event_type
331 .strip_prefix("stance_")
332 .unwrap_or(event_type)
333 .to_string(),
334 escape_tool: obj_string(payload, "escape_tool"),
335 allowed_tools,
336 justification: obj_string(payload, "justification"),
337 consent: obj_string(payload, "consent"),
338 reason: obj_string(payload, "reason"),
339 }
340 }
341 "completion_confirmation_nudge" => feedback(
345 "completion_confirmation_nudge",
346 feedback_content(obj_string(payload, "visible_text_prefix")),
347 ),
348 "fenced_call_attempt_nudge" => feedback(
349 "fenced_call_attempt_nudge",
350 feedback_content(obj_string(payload, "fence")),
351 ),
352 "missing_tool_call_nudge" => feedback(
353 "missing_tool_call_nudge",
354 feedback_content(obj_string(payload, "tool")),
355 ),
356 "no_progress_streak_nudge" => feedback_with_streak(
357 "no_progress_streak_nudge",
358 feedback_content(NO_PROGRESS_STREAK_NUDGE_FALLBACK.to_string()),
359 feedback_streak(payload),
360 ),
361 "tool_call_blank_name_dropped" => feedback(
362 "tool_call_blank_name_dropped",
363 feedback_content(obj_usize(payload, "dropped_count").to_string()),
364 ),
365 "llm_auto_continue" => feedback(
366 "llm_auto_continue",
367 feedback_content(format!(
368 "{}->{} (attempt {}/{})",
369 obj_usize(payload, "previous_max_tokens"),
370 obj_usize(payload, "raised_max_tokens"),
371 obj_usize(payload, "attempt"),
372 obj_usize(payload, "max_continuations"),
373 )),
374 ),
375 "context_overflow_recovery" => feedback(
376 "context_overflow_recovery",
377 feedback_content(format!(
378 "attempt {}/{} archived {} messages",
379 obj_usize(payload, "attempt"),
380 obj_usize(payload, "max_recoveries"),
381 obj_usize(payload, "archived_messages"),
382 )),
383 ),
384 _ => return None,
385 };
386 Some(event)
387}
388
389fn first_non_empty_string(payload: &Value, keys: &[&str]) -> Option<String> {
390 keys.iter().find_map(|key| {
391 payload
392 .get(*key)
393 .and_then(Value::as_str)
394 .filter(|value| !value.trim().is_empty())
395 .map(str::to_string)
396 })
397}
398
399fn feedback_streak(payload: &Value) -> Option<usize> {
400 let streak = obj_usize(payload, "streak").max(obj_usize(payload, "turns_since_progress"));
401 (streak > 0).then_some(streak)
402}
403
404fn from_host_generic(
408 session_id: &str,
409 event_type: &str,
410 payload: &Value,
411) -> Result<AgentEvent, VmError> {
412 let mut obj = match payload {
413 Value::Object(map) => map.clone(),
414 _ => Map::new(),
415 };
416 apply_host_payload_defaults(event_type, &mut obj)?;
417 obj.insert("type".to_string(), Value::String(event_type.to_string()));
418 obj.insert(
419 "session_id".to_string(),
420 Value::String(session_id.to_string()),
421 );
422 let mut event: AgentEvent = serde_json::from_value(Value::Object(obj)).map_err(|error| {
423 reject(
424 session_id,
425 format!("invalid `{event_type}` payload: {error}"),
426 payload,
427 )
428 })?;
429 if let AgentEvent::ToolCall { audit, .. } | AgentEvent::ToolCallUpdate { audit, .. } =
432 &mut event
433 {
434 *audit = crate::orchestration::current_mutation_session();
435 }
436 Ok(event)
437}
438
439fn reject(session_id: &str, detail: String, payload: &Value) -> VmError {
449 crate::boundary::BoundaryFailure::new(
450 crate::boundary::BoundaryId::HostEventIngest,
451 crate::boundary::BoundaryFailureKind::Unrecognized,
452 detail.clone(),
453 )
454 .in_session(session_id)
455 .with_excerpt(&payload.to_string())
456 .report();
457 VmError::Runtime(format!("{HOST_AGENT_EMIT_EVENT}: {detail}"))
458}
459
460fn apply_host_payload_defaults(
465 event_type: &str,
466 obj: &mut Map<String, Value>,
467) -> Result<(), VmError> {
468 match event_type {
469 "tool_call" => {
470 obj.remove("audit"); set_default(obj, "status", Value::String("pending".to_string()));
472 set_default(obj, "raw_input", Value::Null);
473 }
474 "tool_call_update" => {
475 obj.remove("audit"); set_default(obj, "status", Value::String("in_progress".to_string()));
477 normalize_executor(obj)?;
478 }
479 "iteration_end" => set_default(obj, "iteration_info", Value::Null),
480 "progress_reported" => {
481 set_default(obj, "entries", Value::Array(Vec::new()));
482 set_default(obj, "replace", Value::Bool(true));
483 set_default(obj, "metadata", Value::Object(Map::new()));
484 }
485 "tool_search_query" => set_default(obj, "query", Value::Null),
486 "tool_search_result" => set_default(obj, "promoted", Value::Array(Vec::new())),
487 "skill_narrow" => {
488 set_default(obj, "removed_tools", Value::Array(Vec::new()));
489 set_default(obj, "remaining_tools", Value::Array(Vec::new()));
490 }
491 "tool_call_audit" => set_default(obj, "audit", Value::Null),
492 "boundary_failure" => {
496 let owner = obj
497 .get("kind")
498 .and_then(Value::as_str)
499 .and_then(|kind| {
500 serde_json::from_value::<crate::boundary::BoundaryFailureKind>(Value::String(
501 kind.to_string(),
502 ))
503 .ok()
504 })
505 .map(|kind| kind.owner())
506 .unwrap_or("harness");
507 obj.insert("owner".to_string(), Value::String(owner.to_string()));
508 }
509 _ => {}
510 }
511 Ok(())
512}
513
514fn normalize_executor(obj: &mut Map<String, Value>) -> Result<(), VmError> {
520 let raw = match obj.get("executor") {
521 Some(Value::String(value)) => value.clone(),
522 _ => return Ok(()),
523 };
524 let kind = match raw.trim() {
525 "" => {
526 obj.remove("executor");
527 return Ok(());
528 }
529 "harn" | "harn_builtin" => "harn_builtin",
530 "host" | "host_bridge" => "host_bridge",
531 "provider" | "provider_native" => "provider_native",
532 other => {
533 return Err(VmError::Runtime(format!(
534 "{HOST_AGENT_EMIT_EVENT}: invalid tool executor `{other}`"
535 )));
536 }
537 };
538 let mut executor = Map::new();
539 executor.insert("kind".to_string(), Value::String(kind.to_string()));
540 obj.insert("executor".to_string(), Value::Object(executor));
541 Ok(())
542}
543
544fn set_default(obj: &mut Map<String, Value>, key: &str, value: Value) {
545 obj.entry(key).or_insert(value);
546}
547
548fn obj_string(payload: &Value, key: &str) -> String {
549 payload
550 .get(key)
551 .and_then(Value::as_str)
552 .unwrap_or("")
553 .to_string()
554}
555
556fn obj_usize(payload: &Value, key: &str) -> usize {
557 payload.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
558}
559
560fn obj_bool(payload: &Value, key: &str) -> bool {
561 payload.get(key).and_then(Value::as_bool).unwrap_or(false)
562}
563
564fn obj_f64(payload: &Value, key: &str) -> f64 {
565 payload.get(key).and_then(Value::as_f64).unwrap_or(0.0)
566}
567
568fn obj_i64(payload: &Value, key: &str) -> i64 {
571 payload.get(key).and_then(Value::as_i64).unwrap_or(0)
572}