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("loop_stuck", ASSISTANT),
122 host_event("reserved_terminal_verify", ASSISTANT),
123 host_event("agent_loop_stall_warning", ASSISTANT),
124 host_event("cache_hit", None),
125 host_event("cache_miss", None),
126 host_event("agent_scratchpad_reorganization", None),
127 host_event("stance_armed", None),
128 host_event("stance_write_access_granted", None),
129 host_event("stance_write_access_denied", None),
130 host_event("stance_disarmed", None),
131 host_event("completion_confirmation_nudge", None),
132 host_event("fenced_call_attempt_nudge", None),
133 host_event("missing_tool_call_nudge", None),
134 host_event("no_progress_streak_nudge", None),
135 host_event("tool_call_blank_name_dropped", None),
136 host_event("llm_auto_continue", None),
137 host_event("context_overflow_recovery", ASSISTANT),
138];
139
140fn host_event_policy(event_type: &str) -> Option<&'static HostEventPolicy> {
141 HOST_EVENT_POLICIES
142 .iter()
143 .find(|policy| policy.event_type == event_type)
144}
145
146impl AgentEvent {
147 pub fn from_host_payload(
153 session_id: &str,
154 event_type: &str,
155 payload: &Value,
156 ) -> Result<AgentEvent, VmError> {
157 if host_event_policy(event_type).is_none() {
158 return Err(reject(
159 session_id,
160 format!("unsupported event type `{event_type}`"),
161 payload,
162 ));
163 }
164 if let Some(event) = from_host_special(session_id, event_type, payload) {
165 return Ok(event);
166 }
167 from_host_generic(session_id, event_type, payload)
168 }
169
170 pub(crate) fn host_transcript_role(event_type: &str) -> Option<HostTranscriptRole> {
171 host_event_policy(event_type).and_then(|policy| policy.transcript_role)
172 }
173}
174
175fn from_host_special(session_id: &str, event_type: &str, payload: &Value) -> Option<AgentEvent> {
181 let sid = || session_id.to_string();
182 let feedback = |kind: &str, content: String| AgentEvent::FeedbackInjected {
183 session_id: sid(),
184 kind: kind.to_string(),
185 content,
186 streak: None,
187 };
188 let feedback_with_streak =
189 |kind: &str, content: String, streak: Option<usize>| AgentEvent::FeedbackInjected {
190 session_id: sid(),
191 kind: kind.to_string(),
192 content,
193 streak,
194 };
195 let feedback_content = |fallback: String| {
196 first_non_empty_string(payload, &["content", "message", "text"]).unwrap_or(fallback)
197 };
198 let event = match event_type {
199 "typed_checkpoint" => AgentEvent::TypedCheckpoint {
200 session_id: sid(),
201 checkpoint: payload.clone(),
202 },
203 "loop_stuck" => AgentEvent::LoopStuckSignal {
204 session_id: sid(),
205 payload: payload.clone(),
206 },
207 "reserved_terminal_verify" => AgentEvent::ReservedTerminalVerify {
208 session_id: sid(),
209 payload: payload.clone(),
210 },
211 "agent_loop_stall_warning" => AgentEvent::AgentLoopStallWarning {
212 session_id: sid(),
213 warning: payload.clone(),
214 },
215 "cache_hit" => AgentEvent::CacheHit {
216 session_id: sid(),
217 key: obj_string(payload, "key"),
218 backend: obj_string(payload, "backend"),
219 namespace: obj_string(payload, "namespace"),
220 payload: payload.clone(),
221 },
222 "cache_miss" => AgentEvent::CacheMiss {
223 session_id: sid(),
224 key: obj_string(payload, "key"),
225 backend: obj_string(payload, "backend"),
226 namespace: obj_string(payload, "namespace"),
227 payload: payload.clone(),
228 },
229 "agent_scratchpad_reorganization" => {
230 let mut details = payload.clone();
231 if let Some(object) = details.as_object_mut() {
232 object.remove("iteration");
233 object.remove("status");
234 }
235 AgentEvent::AgentScratchpadReorganization {
236 session_id: sid(),
237 iteration: obj_usize(payload, "iteration"),
238 status: obj_string(payload, "status"),
239 details,
240 }
241 }
242 "stance_armed"
246 | "stance_write_access_granted"
247 | "stance_write_access_denied"
248 | "stance_disarmed" => {
249 let allowed_tools = payload
250 .get("allowed_tools")
251 .and_then(Value::as_array)
252 .map(|values| {
253 values
254 .iter()
255 .filter_map(|value| value.as_str().map(str::to_string))
256 .collect()
257 })
258 .unwrap_or_default();
259 AgentEvent::StanceTransition {
260 session_id: sid(),
261 phase: event_type
262 .strip_prefix("stance_")
263 .unwrap_or(event_type)
264 .to_string(),
265 escape_tool: obj_string(payload, "escape_tool"),
266 allowed_tools,
267 justification: obj_string(payload, "justification"),
268 consent: obj_string(payload, "consent"),
269 reason: obj_string(payload, "reason"),
270 }
271 }
272 "completion_confirmation_nudge" => feedback(
276 "completion_confirmation_nudge",
277 feedback_content(obj_string(payload, "visible_text_prefix")),
278 ),
279 "fenced_call_attempt_nudge" => feedback(
280 "fenced_call_attempt_nudge",
281 feedback_content(obj_string(payload, "fence")),
282 ),
283 "missing_tool_call_nudge" => feedback(
284 "missing_tool_call_nudge",
285 feedback_content(obj_string(payload, "tool")),
286 ),
287 "no_progress_streak_nudge" => feedback_with_streak(
288 "no_progress_streak_nudge",
289 feedback_content(NO_PROGRESS_STREAK_NUDGE_FALLBACK.to_string()),
290 feedback_streak(payload),
291 ),
292 "tool_call_blank_name_dropped" => feedback(
293 "tool_call_blank_name_dropped",
294 feedback_content(obj_usize(payload, "dropped_count").to_string()),
295 ),
296 "llm_auto_continue" => feedback(
297 "llm_auto_continue",
298 feedback_content(format!(
299 "{}->{} (attempt {}/{})",
300 obj_usize(payload, "previous_max_tokens"),
301 obj_usize(payload, "raised_max_tokens"),
302 obj_usize(payload, "attempt"),
303 obj_usize(payload, "max_continuations"),
304 )),
305 ),
306 "context_overflow_recovery" => feedback(
307 "context_overflow_recovery",
308 feedback_content(format!(
309 "attempt {}/{} archived {} messages",
310 obj_usize(payload, "attempt"),
311 obj_usize(payload, "max_recoveries"),
312 obj_usize(payload, "archived_messages"),
313 )),
314 ),
315 _ => return None,
316 };
317 Some(event)
318}
319
320fn first_non_empty_string(payload: &Value, keys: &[&str]) -> Option<String> {
321 keys.iter().find_map(|key| {
322 payload
323 .get(*key)
324 .and_then(Value::as_str)
325 .filter(|value| !value.trim().is_empty())
326 .map(str::to_string)
327 })
328}
329
330fn feedback_streak(payload: &Value) -> Option<usize> {
331 let streak = obj_usize(payload, "streak").max(obj_usize(payload, "turns_since_progress"));
332 (streak > 0).then_some(streak)
333}
334
335fn from_host_generic(
339 session_id: &str,
340 event_type: &str,
341 payload: &Value,
342) -> Result<AgentEvent, VmError> {
343 let mut obj = match payload {
344 Value::Object(map) => map.clone(),
345 _ => Map::new(),
346 };
347 apply_host_payload_defaults(event_type, &mut obj)?;
348 obj.insert("type".to_string(), Value::String(event_type.to_string()));
349 obj.insert(
350 "session_id".to_string(),
351 Value::String(session_id.to_string()),
352 );
353 let mut event: AgentEvent = serde_json::from_value(Value::Object(obj)).map_err(|error| {
354 reject(
355 session_id,
356 format!("invalid `{event_type}` payload: {error}"),
357 payload,
358 )
359 })?;
360 if let AgentEvent::ToolCall { audit, .. } | AgentEvent::ToolCallUpdate { audit, .. } =
363 &mut event
364 {
365 *audit = crate::orchestration::current_mutation_session();
366 }
367 Ok(event)
368}
369
370fn reject(session_id: &str, detail: String, payload: &Value) -> VmError {
380 crate::boundary::BoundaryFailure::new(
381 crate::boundary::BoundaryId::HostEventIngest,
382 crate::boundary::BoundaryFailureKind::Unrecognized,
383 detail.clone(),
384 )
385 .in_session(session_id)
386 .with_excerpt(&payload.to_string())
387 .report();
388 VmError::Runtime(format!("{HOST_AGENT_EMIT_EVENT}: {detail}"))
389}
390
391fn apply_host_payload_defaults(
396 event_type: &str,
397 obj: &mut Map<String, Value>,
398) -> Result<(), VmError> {
399 match event_type {
400 "tool_call" => {
401 obj.remove("audit"); set_default(obj, "status", Value::String("pending".to_string()));
403 set_default(obj, "raw_input", Value::Null);
404 }
405 "tool_call_update" => {
406 obj.remove("audit"); set_default(obj, "status", Value::String("in_progress".to_string()));
408 normalize_executor(obj)?;
409 }
410 "iteration_end" => set_default(obj, "iteration_info", Value::Null),
411 "progress_reported" => {
412 set_default(obj, "entries", Value::Array(Vec::new()));
413 set_default(obj, "replace", Value::Bool(true));
414 set_default(obj, "metadata", Value::Object(Map::new()));
415 }
416 "tool_search_query" => set_default(obj, "query", Value::Null),
417 "tool_search_result" => set_default(obj, "promoted", Value::Array(Vec::new())),
418 "skill_narrow" => {
419 set_default(obj, "removed_tools", Value::Array(Vec::new()));
420 set_default(obj, "remaining_tools", Value::Array(Vec::new()));
421 }
422 "tool_call_audit" => set_default(obj, "audit", Value::Null),
423 "boundary_failure" => {
427 let owner = obj
428 .get("kind")
429 .and_then(Value::as_str)
430 .and_then(|kind| {
431 serde_json::from_value::<crate::boundary::BoundaryFailureKind>(Value::String(
432 kind.to_string(),
433 ))
434 .ok()
435 })
436 .map(|kind| kind.owner())
437 .unwrap_or("harness");
438 obj.insert("owner".to_string(), Value::String(owner.to_string()));
439 }
440 _ => {}
441 }
442 Ok(())
443}
444
445fn normalize_executor(obj: &mut Map<String, Value>) -> Result<(), VmError> {
451 let raw = match obj.get("executor") {
452 Some(Value::String(value)) => value.clone(),
453 _ => return Ok(()),
454 };
455 let kind = match raw.trim() {
456 "" => {
457 obj.remove("executor");
458 return Ok(());
459 }
460 "harn" | "harn_builtin" => "harn_builtin",
461 "host" | "host_bridge" => "host_bridge",
462 "provider" | "provider_native" => "provider_native",
463 other => {
464 return Err(VmError::Runtime(format!(
465 "{HOST_AGENT_EMIT_EVENT}: invalid tool executor `{other}`"
466 )));
467 }
468 };
469 let mut executor = Map::new();
470 executor.insert("kind".to_string(), Value::String(kind.to_string()));
471 obj.insert("executor".to_string(), Value::Object(executor));
472 Ok(())
473}
474
475fn set_default(obj: &mut Map<String, Value>, key: &str, value: Value) {
476 obj.entry(key).or_insert(value);
477}
478
479fn obj_string(payload: &Value, key: &str) -> String {
480 payload
481 .get(key)
482 .and_then(Value::as_str)
483 .unwrap_or("")
484 .to_string()
485}
486
487fn obj_usize(payload: &Value, key: &str) -> usize {
488 payload.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
489}