harn-vm 0.10.118

Async bytecode virtual machine for the Harn programming language
Documentation
//! Building provider-neutral tool-result history, and repairing the transcript
//! when a turn's tool calls were never dispatched.
//!
//! The shape a result takes is not the caller's choice: it follows the channel
//! the run is on. On the text channel a result rides back as an ordinary
//! `user` message, because there is no provider tool-result role there. On the
//! native channel it uses Harn's durable `tool_result`/`tool_call_id` contract.
//! Provider adapters project that contract onto their wire protocols.
//!
//! The repair half exists because Anthropic rejects an assistant `tool_use`
//! block that is not immediately followed by its `tool_result` with a
//! non-retryable 400. Any inject site that declines to dispatch a turn's calls
//! has to close them first.

use super::{dict_get, list_items, vm_to_json};
use crate::value::{DictMap, VmDictExt, VmValue};

/// Normalize either structural Harn record representation at this host seam.
/// Public Harn `type` contracts arrive as `StructInstance`; dynamic tools and
/// JSON-backed adapters arrive as `Dict`. Both carry the same named fields.
fn record_fields(value: &VmValue) -> Option<DictMap> {
    match value {
        VmValue::Dict(fields) => Some((**fields).clone()),
        VmValue::StructInstance(_) => value.struct_fields_map(),
        _ => None,
    }
}

/// Preserve typed producer facts on the durable tool-result message. Dispatch
/// keeps mutation facts beside `data` so every consumer can inspect them
/// without parsing rendered text; the transcript owns projecting those facts
/// into the provider-neutral message envelope used by replay and completion
/// policy.
pub(super) fn transcript_tool_result_data(result: &VmValue) -> Option<VmValue> {
    // Tool handlers return their value under the dispatch envelope's `result`
    // field. Built-in hosts may instead attach receipts directly to the
    // envelope. Read both shapes so MCP/dynamic tools do not lose the same
    // typed mutation facts that first-party tools preserve.
    let handler_result = dict_get(result, "result").unwrap_or(result);
    let mut data = dict_get(handler_result, "data")
        .and_then(record_fields)
        .unwrap_or_default();
    if let Some(envelope_data) = dict_get(result, "data").and_then(record_fields) {
        for (key, value) in envelope_data {
            data.insert(key, value);
        }
    }
    let mutation_status =
        dict_get(result, "mutation_status").or_else(|| dict_get(handler_result, "mutation_status"));
    if let Some(VmValue::String(status)) = mutation_status {
        if matches!(status.as_str(), "applied" | "unchanged" | "not_applied") {
            data.insert(
                crate::value::intern_key("mutation_status"),
                VmValue::String(status.clone()),
            );
        }
    }
    let changed_paths =
        dict_get(result, "changed_paths").or_else(|| dict_get(handler_result, "changed_paths"));
    if let Some(VmValue::List(paths)) = changed_paths {
        if !paths.is_empty() {
            data.insert(
                crate::value::intern_key("changed_paths"),
                VmValue::List(paths.clone()),
            );
        }
    }
    (!data.is_empty()).then(|| VmValue::dict(data))
}

/// Test seam over the real record builtin, allowing provider egress regressions
/// to exercise dispatch-result ingestion without a live model turn.
#[cfg(test)]
pub(crate) fn record_tool_results_for_test(session_id: &str, dispatch: VmValue) {
    let args = [VmValue::string(session_id), dispatch];
    super::host_agent_session_record_tool_results_builtin(&args, &mut String::new())
        .expect("record_tool_results builtin succeeds");
}

/// Durable facts for one completed tool call.
///
/// Keeping this as one named input prevents provider projection, transcript
/// identity, and evidence metadata from drifting through positional arguments.
pub(super) struct ToolResultMessageInput<'a> {
    pub(super) channel: crate::llm_config::ToolFormatChannel,
    pub(super) name: &'a str,
    pub(super) tool_call_id: &'a str,
    pub(super) observation: &'a str,
    pub(super) ok: bool,
    pub(super) screenshots: &'a [VmValue],
    pub(super) data: Option<&'a VmValue>,
}

pub(super) fn tool_result_message(input: ToolResultMessageInput<'_>) -> VmValue {
    let mut msg = crate::value::DictMap::new();
    // A text-channel tool_format (`text` or `json`) carries tool results back
    // as an ordinary `user` message — there is no provider tool-result role on
    // the text channel. `native` uses Harn's canonical tool-result role.
    let is_text_channel = input.channel == crate::llm_config::ToolFormatChannel::Text;
    if is_text_channel {
        msg.put_str("role", "user");
    } else {
        msg.put_str("role", "tool_result");
        msg.put_str("name", input.name);
        if !input.tool_call_id.is_empty() {
            msg.put_str("tool_call_id", input.tool_call_id);
        }
    }
    // A tool that returned a screenshot (the computer tool) carries the image
    // back to the model as a `[text, screenshot]` content list on EVERY channel,
    // including the text channel (`role:"user"`). Computer use is only meaningful
    // for a vision-capable model — the surface never offers the tool to a
    // non-vision route — and every provider accepts an image content part in the
    // message role this builds (Anthropic `tool_result`/`user`, OpenAI `user`
    // after relocation, text-channel `user`). The provider content mappers
    // (`anthropic_content`/`openai_content`) project the neutral screenshot dict
    // into the provider's image block; Anthropic's egress additionally wraps both
    // `role:"tool"` and `role:"tool_result"` messages into a `tool_result` block,
    // so delivery does not depend on the session's last provider/model being set
    // at record time (it often is not). `is_text_channel` only decides the ROLE
    // above, not whether the image rides along.
    //
    // The text part is a SHORT summary, never the raw observation: a screenshot
    // observation is ~800 KB of base64 (the display-string of the ScreenImage),
    // which would bloat every subsequent turn and is redundant once the image
    // itself rides along. A result with no screenshot is byte-identical to before.
    if input.screenshots.is_empty() {
        msg.put_str("content", input.observation);
    } else {
        // `[text, image, image, ...]` — a tool that returns more than one frame
        // (rare today; one-per-action) delivers every image, not just the first.
        let summary = screenshot_result_summary(input.observation);
        let mut text_block = crate::value::DictMap::new();
        text_block.put_str("type", "text");
        text_block.put_str("text", &summary);
        let mut content = Vec::with_capacity(1 + input.screenshots.len());
        content.push(VmValue::dict(text_block));
        content.extend(input.screenshots.iter().cloned());
        msg.put("content", VmValue::List(std::sync::Arc::new(content)));
    }
    // Native provider adapters consume this exact outcome when their wire
    // protocol supports error-marked tool results. Other adapters strip it.
    msg.insert(
        crate::value::intern_key("is_error"),
        VmValue::Bool(!input.ok),
    );
    // Provider adapters strip the storage-only `_harn` envelope before egress.
    // Keep result identity, outcome, and declared producer facts together in
    // that one typed contract instead of adding parallel top-level metadata.
    crate::llm::pairing_receipts::attach_tool_result_facts(
        VmValue::dict(msg),
        input.tool_call_id,
        input.name,
        input.ok,
        input.data,
    )
}

/// A short text summary for a screenshot tool result, stripped of the giant
/// base64 payload that the display-stringified `ScreenImage` carries. Keeps any
/// leading `[result of ...]` framing and the `text` field's human summary if one
/// is recoverable, else a generic note; the image itself rides alongside as a
/// content block, so the text only needs to orient the model.
#[expect(
    clippy::string_slice,
    reason = "offsets come from find() of ASCII markers on the sliced string"
)]
fn screenshot_result_summary(observation: &str) -> String {
    // Pull the handler's short `text: "..."` summary out of the display string
    // when present (e.g. `text: Clicked left at (100, 200). Captured ...`).
    if let Some(idx) = observation.find("text: ") {
        let rest = &observation[idx + "text: ".len()..];
        let end = rest.find(", screenshot:").unwrap_or(rest.len());
        let candidate = rest[..end].trim();
        if !candidate.is_empty() && candidate.len() < 400 {
            return format!("{candidate} (screenshot attached below)");
        }
    }
    "Screenshot captured (attached below).".to_string()
}

/// The neutral screenshot dict a computer-use tool returns (`ScreenImage`:
/// `{base64, media_type, width, height, scale_factor}`), pulled off a raw tool
/// result so it can ride back to the model as an image content block. Searches
/// the WHOLE result tree for a dict that actually carries a non-empty `base64`
/// plus `scale_factor` (the distinctive ScreenImage signature). A recursive
/// search — not a fixed `result.screenshot` path — because the live dispatch
/// wraps the handler's return through the tool-caller middleware stack
/// (redaction/summary layers nest it under `result`/`value`), so the screenshot
/// sits at a path the caller cannot assume. The `scale_factor` + non-empty
/// `base64` pair is distinctive enough not to misfire on an unrelated field, and
/// the base64 the model needs is elided to a marker string in the rendered TEXT,
/// so this never matches that. Returns EVERY screenshot found (in tree order) as
/// unconverted `VmValue` dicts — the provider content mappers do the image-block
/// projection at egress. A tool that returns one frame yields one; a
/// multi-frame result delivers them all rather than dropping the extras.
pub(super) fn screenshots_from_tool_result(result: &VmValue) -> Vec<VmValue> {
    fn collect(value: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
        if crate::llm::content::is_screenshot_dict(value) {
            out.push(value.clone());
            // A screenshot dict has no nested screenshots — don't recurse in.
            return;
        }
        match value {
            serde_json::Value::Object(map) => {
                for nested in map.values() {
                    collect(nested, out);
                }
            }
            serde_json::Value::Array(items) => {
                for item in items {
                    collect(item, out);
                }
            }
            _ => {}
        }
    }
    let json = vm_to_json(result);
    let mut found = Vec::new();
    collect(&json, &mut found);
    found.iter().map(crate::stdlib::json_to_vm_value).collect()
}

/// The `(id, name)` of one provider-native tool-call block carried on an
/// assistant message, recovered across the three wire shapes the transcript
/// builder emits (`build_assistant_tool_message`):
///
///   - Anthropic: `content` is a list of blocks; `{type: "tool_use", id, name}`.
///   - OpenAI / Ollama: a top-level `tool_calls` list of
///     `{id, function: {name}}`.
///   - Gemini: `content` list of `{functionCall: {name, id?}}` (id optional).
pub(super) struct AssistantToolUse {
    id: String,
    name: String,
}

/// Extract every provider-native tool-call block declared on an assistant
/// message, regardless of the provider wire shape it was persisted in. Text-
/// channel turns keep their calls inline in `content` (a plain string), so they
/// carry no structured blocks and yield an empty list — which is exactly why the
/// repair below is a no-op for homogeneous text-format runs.
pub(super) fn assistant_tool_use_blocks(message: &VmValue) -> Vec<AssistantToolUse> {
    let mut blocks = Vec::new();
    // OpenAI / Ollama: top-level `tool_calls`.
    for call in list_items(
        &dict_get(message, "tool_calls")
            .cloned()
            .unwrap_or(VmValue::Nil),
    ) {
        let id = dict_get(&call, "id")
            .map(|v| v.display())
            .unwrap_or_default();
        let name = dict_get(&call, "name")
            .map(|v| v.display())
            .or_else(|| {
                dict_get(&call, "function").and_then(|f| dict_get(f, "name").map(|v| v.display()))
            })
            .unwrap_or_default();
        blocks.push(AssistantToolUse { id, name });
    }
    // Anthropic / Gemini: structured `content` blocks.
    if let Some(content) = dict_get(message, "content") {
        for block in list_items(content) {
            // Anthropic `tool_use`.
            let block_type = dict_get(&block, "type")
                .map(|v| v.display())
                .unwrap_or_default();
            if block_type == "tool_use" {
                let id = dict_get(&block, "id")
                    .map(|v| v.display())
                    .unwrap_or_default();
                let name = dict_get(&block, "name")
                    .map(|v| v.display())
                    .unwrap_or_default();
                blocks.push(AssistantToolUse { id, name });
                continue;
            }
            // Gemini `functionCall` part (id is optional on this wire).
            if let Some(function_call) = dict_get(&block, "functionCall") {
                let id = dict_get(function_call, "id")
                    .map(|v| v.display())
                    .unwrap_or_default();
                let name = dict_get(function_call, "name")
                    .map(|v| v.display())
                    .unwrap_or_default();
                blocks.push(AssistantToolUse { id, name });
            }
        }
    }
    blocks
}

/// The set of native call ids that already have a paired tool-result message.
/// Provider aliases are accepted at this ingestion boundary; production
/// history writes the canonical `tool_result`/`tool_call_id` shape.
pub(super) fn paired_tool_result_ids(messages: &[VmValue]) -> std::collections::BTreeSet<String> {
    let mut ids = std::collections::BTreeSet::new();
    for message in messages {
        let role = dict_get(message, "role")
            .map(|v| v.display())
            .unwrap_or_default();
        if role != "tool_result" && role != "tool" {
            continue;
        }
        let id = dict_get(message, "tool_call_id")
            .or_else(|| dict_get(message, "tool_use_id"))
            .or_else(|| dict_get(message, "call_id"))
            .map(|v| v.display())
            .unwrap_or_default();
        if !id.is_empty() {
            ids.insert(id);
        }
    }
    ids
}

/// Synthesize a canonical tool-result message for each orphaned native call on
/// `last_assistant`, carrying `feedback` as the
/// observation. Blocks whose id already has a paired result (`already_paired`)
/// are skipped. Returns the messages to append, in block order. Pure over its
/// inputs so the invariant is unit-testable without a live session.
///
/// `assistant_tool_use_blocks` yields only structured calls; text/json channels
/// keep calls inline in `content`. An orphan here is therefore native by
/// definition. Its result must stay native even when the primary session's
/// format lock is text; the active provider lowers the canonical pair later.
pub(super) fn synthesize_orphan_tool_results(
    last_assistant: &VmValue,
    feedback: &str,
    already_paired: &std::collections::BTreeSet<String>,
) -> Vec<VmValue> {
    let mut out = Vec::new();
    for block in assistant_tool_use_blocks(last_assistant) {
        if !block.id.is_empty() && already_paired.contains(&block.id) {
            continue;
        }
        out.push(tool_result_message(ToolResultMessageInput {
            // The orphaned block is a structured native tool_use; its result
            // must ride the provider's native tool-result role, not the
            // session-locked (possibly `text`) channel.
            channel: crate::llm_config::ToolFormatChannel::Native,
            name: &block.name,
            tool_call_id: &block.id,
            observation: feedback,
            ok: false,
            // A synthesized orphan-repair result is plain feedback text — never
            // an image.
            screenshots: &[],
            data: None,
        }));
    }
    out
}