sim-lib-agent 0.1.2

Agent runtime surfaces for SIM.
Documentation
use super::market_policy::{expr_label, expr_symbols, key_bool, key_expr};
use super::model::{AgentComponent, ComponentBackend, RunnerBackend};
use crate::ComponentKind;
use sim_kernel::{Cx, Error, Expr, Result, Symbol, Value};
use sim_lib_agent_runner_core::{ModelBid, ModelCard, ModelRequest, ModelResponse};

pub(crate) fn runner_card(_cx: &mut Cx, runner: &Value) -> Result<ModelCard> {
    let component = runner_component(runner)?;
    if component.kind != ComponentKind::Runner {
        return Err(Error::Eval(format!("{} is not a runner", component.symbol)));
    }
    match &component.backend {
        ComponentBackend::Runner(RunnerBackend::Echo { model }) => {
            builtin_card(component, model.as_str(), "echo")
        }
        ComponentBackend::Runner(RunnerBackend::Cassette { model, .. }) => {
            builtin_card(component, model.as_str(), "cassette")
        }
        ComponentBackend::Runner(RunnerBackend::Fake { model, .. }) => {
            builtin_card(component, model.as_str(), "fake")
        }
        ComponentBackend::Runner(RunnerBackend::External { runner }) => {
            let mut card = runner.card();
            card.extra
                .extend(component.spec.iter().filter_map(card_extra));
            if card.runner != component.symbol {
                card.extra.push(key_expr(
                    "reported-runner",
                    Expr::Symbol(card.runner.clone()),
                ));
                card.runner = component.symbol.clone();
            }
            Ok(card)
        }
        _ => Err(Error::Eval(format!("{} is not a runner", component.symbol))),
    }
}

pub(crate) fn runner_bid(runner: &Value, request: &ModelRequest, card: &ModelCard) -> ModelBid {
    let external_bid =
        runner_component(runner)
            .ok()
            .and_then(|component| match &component.backend {
                ComponentBackend::Runner(RunnerBackend::External { runner }) => {
                    runner.bid(request).ok()
                }
                _ => None,
            });
    match external_bid {
        Some(bid) if bid.available => bid,
        Some(bid) if bid.reason.as_deref() != Some("runner did not implement bidding") => bid,
        _ => derived_bid(card),
    }
}

pub(crate) fn health_expr(card: &ModelCard) -> Expr {
    let mut entries = vec![
        key_expr("runner", Expr::Symbol(card.runner.clone())),
        key_bool("healthy", card_is_healthy(card)),
        key_expr("health", health_status(card)),
        key_expr("card", Expr::from(card.clone())),
    ];
    if let Some(reason) = card_reason(card) {
        entries.push(key_expr("reason", Expr::String(reason)));
    }
    Expr::Map(entries)
}

pub(crate) fn card_is_healthy(card: &ModelCard) -> bool {
    !matches!(extra_field(card, "healthy"), Some(Expr::Bool(false)))
        && !matches!(
            extra_field(card, "health"),
            Some(Expr::Symbol(symbol)) if symbol.name.as_ref() == "unhealthy"
        )
}

pub(crate) fn health_status(card: &ModelCard) -> Expr {
    extra_field(card, "health").cloned().unwrap_or_else(|| {
        Expr::Symbol(Symbol::new(if card_is_healthy(card) {
            "healthy"
        } else {
            "unhealthy"
        }))
    })
}

pub(crate) fn card_cost(card: &ModelCard) -> Option<f64> {
    extra_field(card, "cost-usd").and_then(expr_f64)
}

pub(crate) fn card_context_tokens(card: &ModelCard) -> Option<u64> {
    extra_field(card, "context-tokens").and_then(expr_u64)
}

pub(crate) fn card_supports(card: &ModelCard, requirement: &Symbol) -> bool {
    match requirement.name.as_ref() {
        "text" => true,
        "tools" => matches!(extra_field(card, "supports-tools"), Some(Expr::Bool(true))),
        "stream" => matches!(extra_field(card, "supports-stream"), Some(Expr::Bool(true))),
        "json" => matches!(extra_field(card, "supports-json"), Some(Expr::Bool(true))),
        "shape" => matches!(extra_field(card, "supports-shape"), Some(Expr::Bool(true))),
        _ => {
            list_field_contains(card, "requires", requirement)
                || list_field_contains(card, "modalities", requirement)
        }
    }
}

pub(crate) fn is_retryable_error(response: &ModelResponse) -> bool {
    response.stop_reason.name.as_ref() == "error"
        && response.extra.iter().any(|(key, value)| {
            matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "retryable")
                && matches!(value, Expr::Bool(true))
        })
}

pub(crate) fn runner_name_expr(value: &Value) -> Expr {
    runner_component(value)
        .map(|component| Expr::Symbol(component.symbol.clone()))
        .unwrap_or(Expr::Nil)
}

pub(crate) fn runner_symbol(value: &Value) -> Option<Symbol> {
    runner_component(value)
        .ok()
        .map(|component| component.symbol.clone())
}

pub(crate) fn expr_f64(expr: &Expr) -> Option<f64> {
    match expr {
        Expr::Number(number) => number.canonical.parse::<f64>().ok(),
        _ => None,
    }
}

fn runner_component(value: &Value) -> Result<&AgentComponent> {
    value
        .object()
        .downcast_ref::<AgentComponent>()
        .ok_or_else(|| Error::Eval("expected runner component".to_owned()))
}

fn builtin_card(component: &AgentComponent, model: &str, provider: &str) -> Result<ModelCard> {
    let mut card = ModelCard::new(
        component.symbol.clone(),
        model,
        Symbol::new(provider),
        Symbol::new("local"),
    );
    card.extra
        .extend(component.spec.iter().filter_map(card_extra));
    Ok(card)
}

fn derived_bid(card: &ModelCard) -> ModelBid {
    let healthy = card_is_healthy(card);
    ModelBid {
        available: healthy,
        reason: (!healthy)
            .then(|| card_reason(card).unwrap_or_else(|| "runner reported unhealthy".to_owned())),
        score: card_cost(card),
        model: Some(card.model.clone()),
        extra: Vec::new(),
    }
}

fn card_extra((key, value): &(Symbol, Expr)) -> Option<(Expr, Expr)> {
    matches!(
        key.name.as_ref(),
        "cost-usd"
            | "context-tokens"
            | "healthy"
            | "health"
            | "health-reason"
            | "requires"
            | "modalities"
            | "modalities-in"
            | "modalities-out"
            | "privacy"
            | "quality"
            | "latency-ms"
            | "delay-ms"
            | "supports-stream"
            | "supports-tools"
            | "supports-json"
            | "supports-shape"
            | "agent"
            | "timeout"
    )
    .then(|| (Expr::Symbol(key.clone()), value.clone()))
}

fn card_reason(card: &ModelCard) -> Option<String> {
    extra_field(card, "health-reason").and_then(|expr| expr_label(expr).ok())
}

fn list_field_contains(card: &ModelCard, key: &str, wanted: &Symbol) -> bool {
    extra_field(card, key).is_some_and(|expr| {
        expr_symbols(expr).is_ok_and(|items| items.iter().any(|item| item.name == wanted.name))
    })
}

fn extra_field<'a>(card: &'a ModelCard, name: &str) -> Option<&'a Expr> {
    card.extra.iter().find_map(|(key, value)| match key {
        Expr::Symbol(symbol) if symbol.name.as_ref() == name => Some(value),
        _ => None,
    })
}

fn expr_u64(expr: &Expr) -> Option<u64> {
    match expr {
        Expr::Number(number) => number.canonical.parse::<u64>().ok(),
        Expr::String(text) => text.parse::<u64>().ok(),
        _ => None,
    }
}