1use super::*;
2
3impl HostInjectionKind {
4 fn as_str(self) -> &'static str {
5 match self {
6 Self::HostToolResult => "host_tool_result",
7 Self::HostAttachment => "host_attachment",
8 }
9 }
10}
11
12#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct HostInjectionRequest {
15 pub kind: HostInjectionKind,
16 #[serde(default)]
17 pub delivery: InjectionDelivery,
18 pub payload: serde_json::Value,
19 pub provenance: HostInjectionProvenance,
20}
21
22#[derive(serde::Deserialize)]
23#[serde(deny_unknown_fields)]
24struct HostToolResultPayload {
25 #[serde(default)]
26 tool_call_id: Option<String>,
27 tool_name: String,
28 #[serde(default)]
29 kind: Option<ToolKind>,
30 #[serde(default)]
31 raw_input: serde_json::Value,
32 #[serde(default = "default_completed_tool_status")]
33 status: ToolCallStatus,
34 #[serde(default)]
35 raw_output: Option<serde_json::Value>,
36 #[serde(default)]
37 result_pointer: Option<String>,
38 #[serde(default)]
39 error: Option<String>,
40 #[serde(default)]
41 duration_ms: Option<u64>,
42}
43
44#[derive(serde::Deserialize)]
45#[serde(deny_unknown_fields)]
46struct HostAttachmentPayload {
47 media_type: String,
48 flavor: AttachmentFlavor,
49 artifact_pointer: String,
50 sha256: String,
51 size_bytes: u64,
52 #[serde(default)]
53 description: Option<String>,
54 #[serde(default)]
55 description_model: Option<String>,
56}
57
58fn default_completed_tool_status() -> ToolCallStatus {
59 ToolCallStatus::Completed
60}
61
62pub fn inject_host_event(id: &str, injection: VmValue) -> Result<serde_json::Value, String> {
63 let injection_json = crate::llm::helpers::vm_value_to_json(&injection);
64 let request: HostInjectionRequest = serde_json::from_value(injection_json)
65 .map_err(|error| format!("agent_inject_host_event: invalid injection: {error}"))?;
66 inject_host_event_request(id, request)
67}
68
69pub fn inject_host_event_request(
74 id: &str,
75 request: HostInjectionRequest,
76) -> Result<serde_json::Value, String> {
77 if !exists(id) {
78 return Err(format!(
79 "agent_inject_host_event: unknown session id '{id}'"
80 ));
81 }
82 validate_host_injection_payload(request.kind, request.payload.clone())?;
83 let injection_id = uuid::Uuid::now_v7().to_string();
84 if request.delivery == InjectionDelivery::Immediate {
85 let sequence = crate::orchestration::agent_inbox::reserve_sequence(id);
86 deliver_host_injection_request(id, injection_id.clone(), sequence, request, "immediate")?;
87 return Ok(serde_json::json!({
88 "injection_id": injection_id,
89 "sequence": sequence,
90 "delivery": InjectionDelivery::Immediate.as_str(),
91 "status": "injected",
92 }));
93 }
94 let queued = serde_json::json!({
95 "injection_id": injection_id,
96 "request": request,
97 });
98 let sequence = crate::orchestration::agent_inbox::push_host_injection(
99 id,
100 request.kind.as_str(),
101 queued,
102 request.delivery,
103 "agent_inject_host_event",
104 );
105 Ok(serde_json::json!({
106 "injection_id": injection_id,
107 "sequence": sequence,
108 "delivery": request.delivery.as_str(),
109 "status": "queued",
110 }))
111}
112
113pub fn drain_queued_host_injections(
114 id: &str,
115 delivery: InjectionDelivery,
116 seam: &str,
117) -> Result<Vec<serde_json::Value>, String> {
118 if !exists(id) {
119 return Err(format!(
120 "agent_inject_host_event: unknown session id '{id}'"
121 ));
122 }
123 let entries = crate::orchestration::agent_inbox::drain_where(id, |entry| {
124 entry.payload.is_some() && entry.delivery == Some(delivery)
125 });
126 let mut delivered = Vec::with_capacity(entries.len());
127 for entry in entries {
128 let payload = entry.payload.ok_or_else(|| {
129 "agent_inject_host_event: queued typed inbox entry missing payload".to_string()
130 })?;
131 let injection_id = payload
132 .get("injection_id")
133 .and_then(serde_json::Value::as_str)
134 .ok_or_else(|| {
135 "agent_inject_host_event: queued injection missing injection_id".to_string()
136 })?
137 .to_string();
138 let request_value = payload.get("request").cloned().ok_or_else(|| {
139 "agent_inject_host_event: queued injection missing request".to_string()
140 })?;
141 let request: HostInjectionRequest =
142 serde_json::from_value(request_value).map_err(|error| {
143 format!("agent_inject_host_event: invalid queued injection: {error}")
144 })?;
145 deliver_host_injection_request(id, injection_id.clone(), entry.sequence, request, seam)?;
146 delivered.push(serde_json::json!({
147 "injection_id": injection_id,
148 "sequence": entry.sequence,
149 "delivery": delivery.as_str(),
150 "delivered_at_seam": seam,
151 "kind": entry.kind,
152 "source": entry.source,
153 "ts_ms": entry.ts_ms,
154 }));
155 }
156 Ok(delivered)
157}
158
159fn validate_host_injection_payload(
160 kind: HostInjectionKind,
161 payload: serde_json::Value,
162) -> Result<(), String> {
163 match kind {
164 HostInjectionKind::HostToolResult => {
165 let _: HostToolResultPayload = serde_json::from_value(payload).map_err(|error| {
166 format!("agent_inject_host_event: invalid host_tool_result payload: {error}")
167 })?;
168 }
169 HostInjectionKind::HostAttachment => {
170 let attachment: HostAttachmentPayload =
171 serde_json::from_value(payload).map_err(|error| {
172 format!("agent_inject_host_event: invalid host_attachment payload: {error}")
173 })?;
174 if attachment.media_type.trim().is_empty() {
175 return Err(
176 "agent_inject_host_event: host_attachment media_type must not be empty".into(),
177 );
178 }
179 if attachment.artifact_pointer.trim().is_empty() {
180 return Err(
181 "agent_inject_host_event: host_attachment artifact_pointer must not be empty"
182 .into(),
183 );
184 }
185 if attachment.sha256.len() != 64
186 || !attachment
187 .sha256
188 .bytes()
189 .all(|byte| byte.is_ascii_hexdigit())
190 {
191 return Err("agent_inject_host_event: host_attachment sha256 must be a 64-character hex digest".into());
192 }
193 if attachment.description_model.is_some() && attachment.description.is_none() {
194 return Err("agent_inject_host_event: host_attachment description_model requires a recorded description".into());
195 }
196 }
197 }
198 Ok(())
199}
200
201fn deliver_host_injection_request(
202 id: &str,
203 injection_id: String,
204 sequence: u64,
205 request: HostInjectionRequest,
206 delivered_at_seam: &str,
207) -> Result<(), String> {
208 let (message, transcript_event, agent_event) = match request.kind {
209 HostInjectionKind::HostToolResult => {
210 let payload: HostToolResultPayload =
211 serde_json::from_value(request.payload).map_err(|error| {
212 format!("agent_inject_host_event: invalid host_tool_result payload: {error}")
213 })?;
214 build_host_tool_result_injection(
215 id,
216 injection_id,
217 sequence,
218 request.delivery,
219 request.provenance,
220 payload,
221 delivered_at_seam,
222 )
223 }
224 HostInjectionKind::HostAttachment => {
225 let payload: HostAttachmentPayload =
226 serde_json::from_value(request.payload).map_err(|error| {
227 format!("agent_inject_host_event: invalid host_attachment payload: {error}")
228 })?;
229 build_host_attachment_injection(
230 id,
231 injection_id,
232 sequence,
233 request.delivery,
234 request.provenance,
235 payload,
236 delivered_at_seam,
237 )
238 }
239 };
240 inject_typed_message(id, message, transcript_event, agent_event)
241}
242
243fn build_host_tool_result_injection(
244 session_id: &str,
245 injection_id: String,
246 sequence: u64,
247 delivery: InjectionDelivery,
248 provenance: HostInjectionProvenance,
249 payload: HostToolResultPayload,
250 delivered_at_seam: &str,
251) -> (VmValue, VmValue, AgentEvent) {
252 let tool_call_id = payload
253 .tool_call_id
254 .as_deref()
255 .filter(|value| !value.trim().is_empty())
256 .map(str::to_owned)
257 .unwrap_or_else(|| format!("hosttc_{}", injection_id.replace('-', "")));
258 let body = host_tool_result_body(&payload);
259 let trust = trust_for_tool(payload.kind, &provenance);
260 let origin = format!("host_injected:{}", payload.tool_name);
261 let ingress = crate::security::sanitize_ingress(&body, &origin, trust);
262 let text = host_injection_envelope(
263 "host_tool_result",
264 &payload.tool_name,
265 &injection_id,
266 &ingress.delivered,
267 );
268 let sanitization = sanitization_verdict(
269 trust,
270 &serde_json::json!({
271 "raw_output": payload.raw_output,
272 "result_pointer": payload.result_pointer,
273 "error": payload.error,
274 }),
275 &text,
276 SanitizationAction::Passed,
277 &ingress,
278 None,
279 );
280 record_host_ingress(session_id, &origin, &tool_call_id, trust, &body, &ingress);
281 let metadata = serde_json::json!({
282 "injection_id": injection_id,
283 "sequence": sequence,
284 "provenance": provenance,
285 "sanitization": sanitization,
286 "tool_call_id": tool_call_id,
287 "tool_name": payload.tool_name,
288 "result_pointer": payload.result_pointer,
289 });
290 let message = host_injection_user_message(&injection_id, &text, &metadata, None);
291 let transcript_event = crate::llm::helpers::transcript_event(
292 "host_tool_result",
293 "user",
294 "public",
295 &text,
296 Some(metadata),
297 );
298 let event = AgentEvent::HostToolResult {
299 session_id: session_id.to_string(),
300 injection_id,
301 tool_call_id,
302 tool_name: payload.tool_name,
303 kind: payload.kind,
304 raw_input: payload.raw_input,
305 status: payload.status,
306 raw_output: payload.raw_output,
307 result_pointer: payload.result_pointer,
308 error: payload.error,
309 duration_ms: payload.duration_ms,
310 delivery,
311 delivered_at_seam: Some(delivered_at_seam.to_string()),
312 sequence,
313 provenance,
314 sanitization,
315 };
316 (message, transcript_event, event)
317}
318
319fn build_host_attachment_injection(
320 session_id: &str,
321 injection_id: String,
322 sequence: u64,
323 delivery: InjectionDelivery,
324 provenance: HostInjectionProvenance,
325 payload: HostAttachmentPayload,
326 delivered_at_seam: &str,
327) -> (VmValue, VmValue, AgentEvent) {
328 let materialized =
329 crate::host_attachments::materialize(&payload.artifact_pointer, &payload.media_type);
330 let (rendered, body, image) = select_attachment_rendering(session_id, &payload, materialized);
331 let trust = trust_for_attachment(payload.flavor, &provenance);
332 let origin = format!("host_attachment:{}", payload.artifact_pointer);
333 let ingress = crate::security::sanitize_ingress(&body, &origin, trust);
334 let text = host_injection_envelope(
335 "host_attachment",
336 &payload.media_type,
337 &injection_id,
338 &ingress.delivered,
339 );
340 let action = match rendered {
341 AttachmentRendering::DescriptionPlusPointer => SanitizationAction::Summarized,
342 AttachmentRendering::PointerOnly => SanitizationAction::Pointerized,
343 AttachmentRendering::ImageBlock | AttachmentRendering::InlineText => {
344 SanitizationAction::Passed
345 }
346 };
347 let sanitization = sanitization_verdict(
348 trust,
349 &serde_json::json!({
350 "artifact_pointer": payload.artifact_pointer,
351 "sha256": payload.sha256,
352 "size_bytes": payload.size_bytes,
353 "description": payload.description,
354 }),
355 &text,
356 action,
357 &ingress,
358 payload.description_model.as_deref(),
359 );
360 record_host_ingress(session_id, &origin, &injection_id, trust, &body, &ingress);
361 let metadata = serde_json::json!({
362 "injection_id": injection_id,
363 "sequence": sequence,
364 "provenance": provenance,
365 "sanitization": sanitization,
366 "artifact_pointer": payload.artifact_pointer,
367 "sha256": payload.sha256,
368 "media_type": payload.media_type,
369 "flavor": payload.flavor,
370 "rendered": rendered,
371 "description": payload.description,
372 "description_model": payload.description_model,
373 });
374 let message = host_injection_user_message(&injection_id, &text, &metadata, image);
375 let transcript_event = crate::llm::helpers::transcript_event(
376 "host_attachment",
377 "user",
378 "public",
379 &text,
380 Some(metadata),
381 );
382 let event = AgentEvent::HostAttachment {
383 session_id: session_id.to_string(),
384 injection_id,
385 media_type: payload.media_type,
386 flavor: payload.flavor,
387 artifact_pointer: payload.artifact_pointer,
388 sha256: payload.sha256,
389 size_bytes: payload.size_bytes,
390 rendered,
391 description: payload.description,
392 description_model: payload.description_model,
393 delivery,
394 delivered_at_seam: Some(delivered_at_seam.to_string()),
395 sequence,
396 provenance,
397 sanitization,
398 };
399 (message, transcript_event, event)
400}
401
402fn inject_typed_message(
403 id: &str,
404 message: VmValue,
405 transcript_event: VmValue,
406 agent_event: AgentEvent,
407) -> Result<(), String> {
408 let Some(msg_dict) = message.as_dict().cloned() else {
409 return Err("agent_inject_host_event: materialized message must be a dict".into());
410 };
411 SESSIONS.with(|s| {
412 let mut map = s.borrow_mut();
413 let Some(state) = map.get_mut(id) else {
414 return Err(format!(
415 "agent_inject_host_event: unknown session id '{id}'"
416 ));
417 };
418 let dict = state
419 .transcript
420 .as_dict()
421 .cloned()
422 .unwrap_or_else(crate::value::DictMap::new);
423 let mut messages: Vec<VmValue> = match dict.get("messages") {
424 Some(VmValue::List(list)) => list.iter().cloned().collect(),
425 _ => Vec::new(),
426 };
427 let mut events: Vec<VmValue> = match dict.get("events") {
428 Some(VmValue::List(list)) => list.iter().cloned().collect(),
429 _ => crate::llm::helpers::transcript_events_from_messages(&messages),
430 };
431 let new_message = VmValue::dict(msg_dict);
432 let message_index = messages.len();
433 let journal_event = transcript_event.clone();
434 events.push(transcript_event);
435 messages.push(new_message);
436 let mut next = dict;
437 next.insert(
438 crate::value::intern_key("events"),
439 VmValue::List(std::sync::Arc::new(events)),
440 );
441 next.insert(
442 crate::value::intern_key("messages"),
443 VmValue::List(std::sync::Arc::new(messages)),
444 );
445 let persisted_message = next
446 .get("messages")
447 .and_then(|value| match value {
448 VmValue::List(list) => list.get(message_index).cloned(),
449 _ => None,
450 })
451 .unwrap_or(VmValue::Nil);
452 apply_transcript_with_budget(state, VmValue::dict(next), "inject_host_event")?;
453 crate::agent_session_journal::enqueue_message(
454 &mut state.transcript_journal,
455 crate::llm::helpers::vm_value_to_json(&journal_event),
456 crate::llm::helpers::vm_value_to_json(&persisted_message),
457 );
458 emit_identified_user_message_event(id, &persisted_message);
459 emit_llm_message_event(id, message_index, &persisted_message);
460 crate::agent_events::emit_event(&agent_event);
461 Ok(())
462 })
463}
464
465fn host_injection_user_message(
466 injection_id: &str,
467 text: &str,
468 metadata: &serde_json::Value,
469 image: Option<crate::host_attachments::MaterializedAttachment>,
470) -> VmValue {
471 let mut message = BTreeMap::new();
472 message.put_str("role", "user");
473 message.put_str(
474 "messageId",
475 format!("hostinj_{}", injection_id.replace('-', "")),
476 );
477 let mut content = vec![serde_json::json!({"type": "text", "text": text})];
478 match image {
479 Some(crate::host_attachments::MaterializedAttachment::ImageUrl(url)) => {
480 content.push(serde_json::json!({"type": "image", "url": url}));
481 }
482 Some(crate::host_attachments::MaterializedAttachment::ImageBase64 { media_type, data }) => {
483 content.push(
484 serde_json::json!({"type": "image", "base64": data, "media_type": media_type}),
485 );
486 }
487 Some(crate::host_attachments::MaterializedAttachment::Text(_)) | None => {}
488 }
489 message.insert(
490 "content".to_string(),
491 crate::stdlib::json_to_vm_value(&serde_json::Value::Array(content)),
492 );
493 message.insert(
494 "metadata".to_string(),
495 crate::stdlib::json_to_vm_value(&serde_json::json!({
496 "host_injection": metadata,
497 })),
498 );
499 VmValue::dict(message)
500}
501
502fn host_tool_result_body(payload: &HostToolResultPayload) -> String {
503 if let Some(output) = payload.raw_output.as_ref() {
504 if let Some(text) = output.as_str() {
505 return text.to_string();
506 }
507 return serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string());
508 }
509 if let Some(pointer) = payload.result_pointer.as_deref() {
510 return format!("Result artifact: {pointer}");
511 }
512 if let Some(error) = payload.error.as_deref() {
513 return format!("Error: {error}");
514 }
515 String::new()
516}
517
518fn select_attachment_rendering(
519 session_id: &str,
520 payload: &HostAttachmentPayload,
521 materialized: Result<crate::host_attachments::MaterializedAttachment, String>,
522) -> (
523 AttachmentRendering,
524 String,
525 Option<crate::host_attachments::MaterializedAttachment>,
526) {
527 if let Ok(crate::host_attachments::MaterializedAttachment::Text(text)) = &materialized {
528 return (AttachmentRendering::InlineText, text.clone(), None);
529 }
530 let vision_capable = pinned_model(session_id)
531 .map(|selector| crate::llm_config::resolve_model_info(&selector))
532 .map(|resolved| {
533 crate::llm::capabilities::lookup(&resolved.provider, &resolved.id).vision_supported
534 })
535 .unwrap_or(false);
536 if vision_capable {
537 if let Ok(
538 image @ (crate::host_attachments::MaterializedAttachment::ImageUrl(_)
539 | crate::host_attachments::MaterializedAttachment::ImageBase64 { .. }),
540 ) = materialized
541 {
542 return (
543 AttachmentRendering::ImageBlock,
544 format!("Attachment: {}", payload.artifact_pointer),
545 Some(image),
546 );
547 }
548 }
549 match payload
550 .description
551 .as_deref()
552 .filter(|text| !text.trim().is_empty())
553 {
554 Some(description) => (
555 AttachmentRendering::DescriptionPlusPointer,
556 format!("{description}\nArtifact: {}", payload.artifact_pointer),
557 None,
558 ),
559 None => (
560 AttachmentRendering::PointerOnly,
561 format!("Attachment: {}", payload.artifact_pointer),
562 None,
563 ),
564 }
565}
566
567fn host_injection_envelope(kind: &str, subject: &str, injection_id: &str, body: &str) -> String {
568 format!(
569 "<{kind} subject=\"{}\" injection_id=\"{}\">\n{}\n</{kind}>",
570 escape_attr(subject),
571 escape_attr(injection_id),
572 body
573 )
574}
575
576fn escape_attr(value: &str) -> String {
577 value
578 .replace('&', "&")
579 .replace('"', """)
580 .replace('<', "<")
581 .replace('>', ">")
582}
583
584fn sanitization_verdict(
585 trust: TrustLevel,
586 original: &serde_json::Value,
587 delivered: &str,
588 action: SanitizationAction,
589 ingress: &crate::security::SanitizedIngress,
590 summary_model: Option<&str>,
591) -> SanitizationVerdict {
592 SanitizationVerdict {
593 trust,
594 detector: ingress.detector.clone(),
595 action,
596 original_bytes: serde_json::to_vec(original)
597 .map(|bytes| bytes.len() as u64)
598 .unwrap_or(0),
599 delivered_bytes: delivered.len() as u64,
600 summary_model: summary_model.map(str::to_owned),
601 labels: ingress.labels.clone(),
602 }
603}
604
605fn record_host_ingress(
606 session_id: &str,
607 origin: &str,
608 introduced_by: &str,
609 trust: TrustLevel,
610 raw: &str,
611 ingress: &crate::security::SanitizedIngress,
612) {
613 if !trust.is_untrusted() || raw.is_empty() {
614 return;
615 }
616 push_session_taint(
617 session_id,
618 crate::security::TaintRecord {
619 origin: origin.to_string(),
620 trust,
621 introduced_by: introduced_by.to_string(),
622 detector: ingress.detector.clone(),
623 labels: ingress.labels.clone(),
624 endpoints: ingress.endpoints.clone(),
625 },
626 );
627}
628
629fn trust_for_tool(kind: Option<ToolKind>, provenance: &HostInjectionProvenance) -> TrustLevel {
630 if provenance.source == "user_attachment" {
631 return TrustLevel::Untrusted;
632 }
633 match kind {
634 Some(ToolKind::Fetch) | Some(ToolKind::Search) => TrustLevel::Untrusted,
635 Some(ToolKind::Read) => TrustLevel::SemiTrusted,
636 Some(ToolKind::Execute)
637 | Some(ToolKind::Edit)
638 | Some(ToolKind::Delete)
639 | Some(ToolKind::Move) => TrustLevel::SemiTrusted,
640 _ => TrustLevel::Trusted,
641 }
642}
643
644fn trust_for_attachment(
645 flavor: AttachmentFlavor,
646 provenance: &HostInjectionProvenance,
647) -> TrustLevel {
648 if provenance.source == "user_attachment" {
649 return TrustLevel::Untrusted;
650 }
651 match flavor {
652 AttachmentFlavor::TextFrame | AttachmentFlavor::FrameRing => TrustLevel::SemiTrusted,
653 AttachmentFlavor::Image | AttachmentFlavor::File => TrustLevel::Untrusted,
654 }
655}
656
657pub(super) fn emit_identified_user_message_event(session_id: &str, message: &VmValue) {
658 let message_json = crate::llm::helpers::vm_value_to_json(message);
659 let role = message_json.get("role").and_then(|value| value.as_str());
660 if role != Some("user") {
661 return;
662 }
663 let Some(message_id) = message_json
664 .get("messageId")
665 .or_else(|| message_json.get("message_id"))
666 .and_then(|value| value.as_str())
667 .filter(|value| !value.trim().is_empty())
668 else {
669 return;
670 };
671 let content = message_json
672 .get("content")
673 .map(user_message_content_blocks)
674 .unwrap_or_default();
675 crate::agent_events::emit_event(&crate::agent_events::AgentEvent::UserMessage {
676 session_id: session_id.to_string(),
677 message_id: message_id.to_string(),
678 content,
679 });
680}
681
682fn user_message_content_blocks(content: &serde_json::Value) -> Vec<serde_json::Value> {
683 match content {
684 serde_json::Value::Array(items) => items.clone(),
685 serde_json::Value::String(text) => vec![serde_json::json!({
686 "type": "text",
687 "text": text,
688 })],
689 other => vec![serde_json::json!({
690 "type": "text",
691 "text": other.to_string(),
692 })],
693 }
694}
695
696pub(super) fn emit_llm_message_event(session_id: &str, message_index: usize, message: &VmValue) {
697 let mut fields = serde_json::Map::new();
698 fields.insert(
699 "session_id".to_string(),
700 serde_json::Value::String(session_id.to_string()),
701 );
702 fields.insert(
703 "message_index".to_string(),
704 serde_json::json!(message_index),
705 );
706 let message_json = crate::llm::helpers::vm_value_to_json(message);
707 if let Some(role) = message_json.get("role").and_then(|value| value.as_str()) {
708 fields.insert(
709 "role".to_string(),
710 serde_json::Value::String(role.to_string()),
711 );
712 }
713 if let Some(content) = message_json.get("content") {
714 fields.insert("content".to_string(), content.clone());
715 }
716 fields.insert("message".to_string(), message_json);
717 crate::llm::append_observability_sidecar_entry("message", fields);
718}