Skip to main content

harn_vm/agent_sessions/
host_injection.rs

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
69/// Inject a validated host event without crossing the dynamic VM-value boundary.
70///
71/// Protocol adapters and Rust embedders should use this entry point. Harn code
72/// continues to use [`inject_host_event`], which parses into the same contract.
73pub 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('&', "&amp;")
579        .replace('"', "&quot;")
580        .replace('<', "&lt;")
581        .replace('>', "&gt;")
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}