harn-vm 0.8.32

Async bytecode virtual machine for the Harn programming language
Documentation
//! Token estimation, microcompaction, and transcript auto-compact builtins.

use std::rc::Rc;

use crate::orchestration::ArtifactRecord;
use crate::value::{VmError, VmValue};

use super::super::{parse_artifact_list, parse_context_policy};
use super::convert::to_vm;

pub(super) fn select_artifacts_adaptive_builtin(
    args: &[VmValue],
    _out: &mut String,
) -> Result<VmValue, VmError> {
    let artifacts_val = args.first().cloned().unwrap_or(VmValue::Nil);
    let policy_val = args.get(1).cloned().unwrap_or(VmValue::Nil);
    let artifacts: Vec<ArtifactRecord> = parse_artifact_list(Some(&artifacts_val))?;
    let policy: crate::orchestration::ContextPolicy = parse_context_policy(Some(&policy_val))?;
    let selected = crate::orchestration::select_artifacts_adaptive(artifacts, &policy);
    to_vm(&selected)
}

pub(super) fn estimate_tokens_builtin(
    args: &[VmValue],
    _out: &mut String,
) -> Result<VmValue, VmError> {
    let messages: Vec<serde_json::Value> = args
        .first()
        .and_then(|a| match a {
            VmValue::List(list) => Some(
                list.iter()
                    .map(crate::llm::helpers::vm_value_to_json)
                    .collect(),
            ),
            _ => None,
        })
        .unwrap_or_default();
    let tokens = crate::orchestration::estimate_message_tokens(&messages);
    Ok(VmValue::Int(tokens as i64))
}

pub(super) fn microcompact_builtin(
    args: &[VmValue],
    _out: &mut String,
) -> Result<VmValue, VmError> {
    let text = args.first().map(|a| a.display()).unwrap_or_default();
    let max_chars = args
        .get(1)
        .map(|v| non_negative_usize(v, "microcompact", "max_chars"))
        .transpose()?
        .unwrap_or(20_000);
    Ok(VmValue::String(Rc::from(
        crate::orchestration::microcompact_tool_output(&text, max_chars),
    )))
}

fn non_negative_usize(value: &VmValue, builtin: &str, key: &str) -> Result<usize, VmError> {
    match value {
        VmValue::Int(n) if *n >= 0 => Ok(*n as usize),
        VmValue::Int(_) => Err(VmError::Runtime(format!("{builtin}: `{key}` must be >= 0"))),
        other => Err(VmError::Runtime(format!(
            "{builtin}: `{key}` must be an int, got {}",
            other.type_name()
        ))),
    }
}

pub(super) async fn transcript_auto_compact_builtin(
    args: Vec<VmValue>,
) -> Result<VmValue, VmError> {
    let mut messages: Vec<serde_json::Value> = match args.first() {
        Some(VmValue::List(list)) => list
            .iter()
            .map(crate::llm::helpers::vm_value_to_json)
            .collect(),
        _ => {
            return Err(VmError::Runtime(
                "transcript_auto_compact: first argument must be a message list".to_string(),
            ))
        }
    };
    let options = args.get(1).and_then(|v| v.as_dict()).cloned();
    let mut config = crate::orchestration::AutoCompactConfig::default();
    if let Some(v) = options
        .as_ref()
        .and_then(|o| o.get("keep_first"))
        .map(|v| non_negative_usize(v, "transcript_auto_compact", "keep_first"))
        .transpose()?
    {
        config.keep_first = v;
    }
    let threshold = options.as_ref().and_then(|o| {
        o.get("token_threshold")
            .map(|v| ("token_threshold", v))
            .or_else(|| o.get("compact_threshold").map(|v| ("compact_threshold", v)))
    });
    if let Some((key, v)) = threshold {
        config.token_threshold = non_negative_usize(v, "transcript_auto_compact", key)?;
    }
    if let Some(v) = options
        .as_ref()
        .and_then(|o| o.get("tool_output_max_chars"))
        .map(|v| non_negative_usize(v, "transcript_auto_compact", "tool_output_max_chars"))
        .transpose()?
    {
        config.tool_output_max_chars = v;
    }
    if let Some(v) = options
        .as_ref()
        .and_then(|o| o.get("keep_last"))
        .map(|v| non_negative_usize(v, "transcript_auto_compact", "keep_last"))
        .transpose()?
    {
        config.keep_last = v;
    }
    if let Some(v) = options
        .as_ref()
        .and_then(|o| o.get("hard_limit_tokens"))
        .map(|v| non_negative_usize(v, "transcript_auto_compact", "hard_limit_tokens"))
        .transpose()?
    {
        config.hard_limit_tokens = Some(v);
    }
    if let Some(strategy) = options
        .as_ref()
        .and_then(|o| o.get("compact_strategy"))
        .map(|v| v.display())
    {
        config.compact_strategy = crate::orchestration::parse_compact_strategy(&strategy)?;
    }
    if let Some(strategy) = options
        .as_ref()
        .and_then(|o| o.get("hard_limit_strategy"))
        .map(|v| v.display())
    {
        config.hard_limit_strategy = crate::orchestration::parse_compact_strategy(&strategy)?;
    }
    if let Some(prompt) = options
        .as_ref()
        .and_then(|o| o.get("summarize_prompt"))
        .map(|v| v.display())
    {
        if !prompt.is_empty() {
            config.summarize_prompt = Some(prompt);
        }
    }
    if let Some(callback) = options.as_ref().and_then(|o| o.get("compact_callback")) {
        config.custom_compactor = Some(callback.clone());
        if !options
            .as_ref()
            .is_some_and(|o| o.contains_key("compact_strategy"))
        {
            config.compact_strategy = crate::orchestration::CompactStrategy::Custom;
        }
    }
    let llm_opts = if config.compact_strategy == crate::orchestration::CompactStrategy::Llm {
        Some(crate::llm::extract_llm_options(&[
            VmValue::String(Rc::from("")),
            VmValue::Nil,
            args.get(1).cloned().unwrap_or(VmValue::Nil),
        ])?)
    } else {
        None
    };
    crate::orchestration::auto_compact_messages(&mut messages, &config, llm_opts.as_ref()).await?;
    Ok(VmValue::List(Rc::new(
        messages
            .iter()
            .map(crate::stdlib::json_to_vm_value)
            .collect(),
    )))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn microcompact_rejects_negative_limit() {
        let mut out = String::new();
        let err = microcompact_builtin(
            &[VmValue::String(Rc::from("hello")), VmValue::Int(-1)],
            &mut out,
        )
        .expect_err("negative limits must fail");
        assert!(err.to_string().contains("max_chars"));
    }
}