sim-lib-agent 0.1.3

Agent runtime surfaces for SIM.
Documentation
use crate::Tool;
use sim_codec_chat::{is_model_request_expr, validate_chat_transcript};
use sim_kernel::{Cx, Error, Expr, Result, Symbol, Value};
use std::collections::{BTreeMap, BTreeSet};

pub(crate) fn is_model_request(expr: &Expr) -> bool {
    is_model_request_expr(expr)
}

pub(crate) fn inject_manifest_tools(cx: &mut Cx, request: Expr, tools: &[Value]) -> Result<Expr> {
    if tools.is_empty() && field(&request, "tools").is_none() {
        return Ok(request);
    }
    if !is_model_request(&request) {
        return Ok(request);
    }
    let manifest_tools = manifest_tool_descriptors(cx, tools)?;
    let allowed = allowed_tools(&request, &manifest_tools)?;
    let mut entries = request_map_entries(request)?;
    let mut tool_items = remove_field(&mut entries, "tools")
        .map(tools_list)
        .transpose()?
        .unwrap_or_default();
    let mut declared = BTreeSet::new();
    for item in &tool_items {
        let symbol = required_descriptor_symbol(item)?;
        if !manifest_tools.contains_key(&symbol) {
            return Err(Error::Eval(format!(
                "tool {symbol} is not declared by this agent"
            )));
        }
        if !allowed.contains(&symbol) {
            return Err(Error::Eval(format!(
                "tool {symbol} is denied by request policy"
            )));
        }
        if let Some(generated) = manifest_tools.get(&symbol) {
            reject_conflicting_descriptor(item, generated, &symbol)?;
        }
        declared.insert(symbol);
    }
    let mut injected = Vec::new();
    for symbol in &allowed {
        if declared.contains(symbol) {
            continue;
        }
        if let Some(descriptor) = manifest_tools.get(symbol) {
            tool_items.push(descriptor.clone());
            injected.push((symbol.clone(), descriptor.clone()));
        }
    }
    if !tool_items.is_empty() {
        entries.push(key_expr("tools", Expr::List(tool_items)));
    }
    if !injected.is_empty() {
        entries.push(key_expr(
            "agent-tool-injection",
            injection_metadata(injected),
        ));
    }
    let out = Expr::Map(entries);
    validate_chat_transcript(&out)?;
    Ok(out)
}

fn manifest_tool_descriptors(cx: &mut Cx, tools: &[Value]) -> Result<BTreeMap<Symbol, Expr>> {
    let mut descriptors = BTreeMap::new();
    for value in tools {
        let tool = value
            .object()
            .downcast_ref::<Tool>()
            .ok_or(Error::TypeMismatch {
                expected: "tool",
                found: "non-tool",
            })?;
        if descriptors
            .insert(tool.symbol.clone(), tool.model_descriptor_expr(cx)?)
            .is_some()
        {
            return Err(Error::Eval(format!(
                "agent manifest contains duplicate tool {}",
                tool.symbol
            )));
        }
    }
    Ok(descriptors)
}

fn allowed_tools(request: &Expr, manifest: &BTreeMap<Symbol, Expr>) -> Result<BTreeSet<Symbol>> {
    let policy = ToolRequestPolicy::from_request(request)?;
    let mut allowed = manifest.keys().cloned().collect::<BTreeSet<_>>();
    if let Some(allow) = policy.allow {
        allowed.retain(|symbol| allow.contains(symbol));
    }
    allowed.retain(|symbol| !policy.deny.contains(symbol));
    Ok(allowed)
}

#[derive(Default)]
struct ToolRequestPolicy {
    allow: Option<BTreeSet<Symbol>>,
    deny: BTreeSet<Symbol>,
}

impl ToolRequestPolicy {
    fn from_request(request: &Expr) -> Result<Self> {
        let mut policy = Self::default();
        if let Some(allow) = field(request, "allowed-tools") {
            policy.allow = Some(symbol_set(allow, "allowed-tools")?);
        }
        if let Some(deny) = field(request, "denied-tools") {
            policy.deny.extend(symbol_set(deny, "denied-tools")?);
        }
        let Some(tool_policy) = field(request, "tool-policy") else {
            return Ok(policy);
        };
        if let Some(allow) = field(tool_policy, "allow").or_else(|| field(tool_policy, "tools")) {
            policy.allow = Some(symbol_set(allow, "tool-policy allow")?);
        }
        if let Some(deny) = field(tool_policy, "deny") {
            policy.deny.extend(symbol_set(deny, "tool-policy deny")?);
        }
        Ok(policy)
    }
}

fn request_map_entries(request: Expr) -> Result<Vec<(Expr, Expr)>> {
    match request {
        Expr::Map(entries) => Ok(entries),
        _ => Err(Error::Eval("model request must be a map".to_owned())),
    }
}

fn remove_field(entries: &mut Vec<(Expr, Expr)>, name: &str) -> Option<Expr> {
    let index = entries.iter().position(|(key, _)| is_field(key, name))?;
    Some(entries.remove(index).1)
}

fn tools_list(expr: Expr) -> Result<Vec<Expr>> {
    match expr {
        Expr::List(items) | Expr::Vector(items) => Ok(items),
        _ => Err(Error::Eval(
            "model request tools field must be a list".to_owned(),
        )),
    }
}

fn reject_conflicting_descriptor(explicit: &Expr, generated: &Expr, symbol: &Symbol) -> Result<()> {
    let Expr::Map(entries) = explicit else {
        return Ok(());
    };
    for (key, value) in entries {
        let Some(name) = field_name(key) else {
            continue;
        };
        if matches!(name, "name" | "tool" | "function") {
            continue;
        }
        if let Some(generated_value) = field(generated, name)
            && generated_value != value
        {
            return Err(Error::Eval(format!(
                "tool descriptor for {symbol} conflicts on field {name}"
            )));
        }
    }
    Ok(())
}

fn injection_metadata(injected: Vec<(Symbol, Expr)>) -> Expr {
    let symbols = injected
        .iter()
        .map(|(symbol, _)| Expr::Symbol(symbol.clone()))
        .collect();
    let descriptors = injected
        .into_iter()
        .map(|(_, descriptor)| descriptor)
        .collect();
    Expr::Map(vec![
        key_expr("source", Expr::Symbol(Symbol::new("agent-manifest"))),
        key_expr("injected-tools", Expr::List(symbols)),
        key_expr("descriptors", Expr::List(descriptors)),
    ])
}

fn required_descriptor_symbol(expr: &Expr) -> Result<Symbol> {
    descriptor_symbol(expr)?.ok_or_else(|| Error::Eval("tool descriptor missing name".to_owned()))
}

fn descriptor_symbol(expr: &Expr) -> Result<Option<Symbol>> {
    match expr {
        Expr::Symbol(symbol) => Ok(Some(symbol.clone())),
        Expr::String(text) => Ok(Some(symbol_from_text(text))),
        Expr::Map(_) => {
            if let Some(name) = field(expr, "name").or_else(|| field(expr, "tool")) {
                return symbol_from_expr(name, "tool descriptor name must be a symbol or string")
                    .map(Some);
            }
            if let Some(function) = field(expr, "function")
                && let Some(name) = field(function, "name")
            {
                return symbol_from_expr(
                    name,
                    "tool descriptor function name must be a symbol or string",
                )
                .map(Some);
            }
            Ok(None)
        }
        _ => Err(Error::Eval(
            "tool descriptor must be a symbol, string, or map".to_owned(),
        )),
    }
}

fn symbol_set(expr: &Expr, label: &str) -> Result<BTreeSet<Symbol>> {
    let items = match expr {
        Expr::Nil => Vec::new(),
        Expr::List(items) | Expr::Vector(items) => items.clone(),
        other => vec![other.clone()],
    };
    items
        .iter()
        .map(|item| symbol_from_expr(item, &format!("{label} expects symbols or strings")))
        .collect()
}

fn symbol_from_expr(expr: &Expr, error: &str) -> Result<Symbol> {
    match expr {
        Expr::Symbol(symbol) => Ok(symbol.clone()),
        Expr::String(text) => Ok(symbol_from_text(text)),
        _ => Err(Error::Eval(error.to_owned())),
    }
}

fn symbol_from_text(text: &str) -> Symbol {
    match text.split_once('/') {
        Some((namespace, name)) => Symbol::qualified(namespace.to_owned(), name.to_owned()),
        None => Symbol::new(text.to_owned()),
    }
}

fn field<'a>(expr: &'a Expr, name: &str) -> Option<&'a Expr> {
    let Expr::Map(entries) = expr else {
        return None;
    };
    entries.iter().find_map(|(key, value)| {
        if is_field(key, name) {
            Some(value)
        } else {
            None
        }
    })
}

fn field_name(expr: &Expr) -> Option<&str> {
    match expr {
        Expr::Symbol(symbol) if symbol.namespace.is_none() => Some(symbol.name.as_ref()),
        _ => None,
    }
}

fn is_field(expr: &Expr, name: &str) -> bool {
    matches!(field_name(expr), Some(found) if found == name)
}

use sim_value::build::entry as key_expr;