sim-lib-agent 0.1.6

Agent runtime surfaces for SIM.
Documentation
use super::support::{eval_cx, install_agent_lib};
use sim_kernel::{Args, Error, Expr, Result, Symbol, Value};
use std::{
    io::{ErrorKind, Read, Write},
    net::TcpListener,
    thread,
    time::Duration,
};

#[test]
fn provider_profiles_returns_profile_table() {
    let mut cx = eval_cx();
    install_agent_lib(&mut cx).unwrap();

    let value = cx
        .call_function(
            &Symbol::qualified("provider", "profiles"),
            Args::new(Vec::new()),
        )
        .unwrap();
    let expr = value.object().as_expr(&mut cx).unwrap();

    let openai = map_field(&expr, "openai");
    assert_eq!(
        map_field(openai, "models-path"),
        &Expr::String("/models".to_owned())
    );
    assert_eq!(
        map_field(openai, "runner"),
        &Expr::Symbol(Symbol::qualified("runner", "openai"))
    );
    assert_eq!(
        map_field(map_field(openai, "auth"), "kind"),
        &Expr::Symbol(Symbol::new("bearer-env"))
    );
    let lm_studio = map_field(&expr, "lm-studio");
    assert_eq!(
        map_field(map_field(lm_studio, "auth"), "kind"),
        &Expr::Symbol(Symbol::new("optional-bearer-env"))
    );
    assert_eq!(
        map_field(map_field(lm_studio, "auth"), "env"),
        &Expr::String("LM_STUDIO_API_KEY".to_owned())
    );
    assert!(map_field(&expr, "ollama").canonical_eq(&Expr::Map(vec![
        (
            Expr::Symbol(Symbol::new("provider")),
            Expr::Symbol(Symbol::new("ollama"))
        ),
        (
            Expr::Symbol(Symbol::new("runner")),
            Expr::Symbol(Symbol::qualified("runner", "ollama"))
        ),
        (
            Expr::Symbol(Symbol::new("codec")),
            Expr::Symbol(Symbol::qualified("codec", "ollama"))
        ),
        (
            Expr::Symbol(Symbol::new("endpoint")),
            Expr::String("http://127.0.0.1:11434".to_owned())
        ),
        (
            Expr::Symbol(Symbol::new("models-path")),
            Expr::String("/api/tags".to_owned())
        ),
        (
            Expr::Symbol(Symbol::new("chat-path")),
            Expr::String("/api/chat".to_owned())
        ),
        (
            Expr::Symbol(Symbol::new("auth")),
            Expr::Map(vec![(
                Expr::Symbol(Symbol::new("kind")),
                Expr::Symbol(Symbol::new("none"))
            )])
        ),
        (
            Expr::Symbol(Symbol::new("locality")),
            Expr::Symbol(Symbol::new("local"))
        ),
        (
            Expr::Symbol(Symbol::new("model")),
            Expr::String("qwen3.5:4b".to_owned())
        ),
        (
            Expr::Symbol(Symbol::new("timeout-ms")),
            number_expr("120000")
        ),
        (Expr::Symbol(Symbol::new("stream")), Expr::Bool(true)),
        (Expr::Symbol(Symbol::new("tools")), Expr::Bool(false)),
        (
            Expr::Symbol(Symbol::new("max-output-bytes")),
            number_expr("1048576")
        ),
    ])));
}

#[test]
fn provider_probe_loopback_requires_local_capability() {
    let mut cx = eval_cx();
    install_agent_lib(&mut cx).unwrap();
    cx.grant_named("ai-runner");

    let error = call_probe(
        &mut cx,
        vec![
            ("provider", Expr::Symbol(Symbol::new("lm-studio"))),
            (
                "endpoint",
                Expr::String("http://127.0.0.1:1234/v1".to_owned()),
            ),
        ],
    )
    .unwrap_err();

    assert!(matches!(
        error,
        Error::CapabilityDenied { capability }
            if capability == sim_kernel::CapabilityName::new("ai-runner-local")
    ));
}

#[test]
fn provider_probe_non_loopback_requires_network_capability() {
    let mut cx = eval_cx();
    install_agent_lib(&mut cx).unwrap();
    cx.grant_named("ai-runner");

    let error = call_probe(
        &mut cx,
        vec![
            ("provider", Expr::Symbol(Symbol::new("openai-compatible"))),
            (
                "endpoint",
                Expr::String("http://models.example/v1".to_owned()),
            ),
            ("api-key-env", Expr::Nil),
        ],
    )
    .unwrap_err();

    assert!(matches!(
        error,
        Error::CapabilityDenied { capability }
            if capability == sim_kernel::CapabilityName::new("ai-runner-network")
    ));
}

#[test]
fn provider_probe_secret_env_is_required_before_missing_secret_is_reported() {
    let mut cx = eval_cx();
    install_agent_lib(&mut cx).unwrap();
    cx.grant_named("ai-runner");
    cx.grant_named("ai-runner-network");

    let denied = call_probe(
        &mut cx,
        vec![
            ("provider", Expr::Symbol(Symbol::new("openai"))),
            (
                "api-key-env",
                Expr::String("SIM_AGENT_NET_PROVIDER_PROBE_MISSING_SECRET".to_owned()),
            ),
        ],
    )
    .unwrap_err();
    assert!(matches!(
        denied,
        Error::CapabilityDenied { capability }
            if capability == sim_kernel::CapabilityName::new("ai-runner-secret")
    ));

    cx.grant_named("ai-runner-secret");
    let report = call_probe(
        &mut cx,
        vec![
            ("provider", Expr::Symbol(Symbol::new("openai"))),
            (
                "api-key-env",
                Expr::String("SIM_AGENT_NET_PROVIDER_PROBE_MISSING_SECRET".to_owned()),
            ),
        ],
    )
    .unwrap();

    assert_eq!(
        map_field(&report, "status"),
        &Expr::Symbol(Symbol::new("skipped"))
    );
    assert_eq!(map_field(&report, "redacted"), &Expr::Bool(true));
    assert_eq!(
        map_field(&report, "reason"),
        &Expr::String("missing secret env SIM_AGENT_NET_PROVIDER_PROBE_MISSING_SECRET".to_owned())
    );
}

#[test]
fn provider_probe_loopback_discovers_models_without_live_provider() {
    let mut cx = eval_cx();
    install_agent_lib(&mut cx).unwrap();
    cx.grant_named("ai-runner");
    cx.grant_named("ai-runner-local");

    let Some(listener) = bind_loopback_listener() else {
        return;
    };
    let port = listener.local_addr().unwrap().port();
    let server = thread::spawn(move || {
        let (mut stream, _) = listener.accept().unwrap();
        stream
            .set_read_timeout(Some(Duration::from_secs(1)))
            .unwrap();
        let mut request = Vec::new();
        let mut chunk = [0u8; 1024];
        loop {
            let read = stream.read(&mut chunk).unwrap();
            if read == 0 {
                break;
            }
            request.extend_from_slice(&chunk[..read]);
            if request.windows(4).any(|window| window == b"\r\n\r\n") {
                break;
            }
        }
        let head = String::from_utf8_lossy(&request);
        assert!(head.starts_with("GET /v1/models HTTP/1.1"), "{head}");
        let body = r#"{"data":[{"id":"local-a"},{"id":"local-b"}]}"#;
        stream
            .write_all(
                format!(
                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
                    body.len(),
                    body
                )
                .as_bytes(),
            )
            .unwrap();
    });

    let report = call_probe(
        &mut cx,
        vec![
            ("provider", Expr::Symbol(Symbol::new("lm-studio"))),
            (
                "endpoint",
                Expr::String(format!("http://127.0.0.1:{port}/v1")),
            ),
        ],
    )
    .unwrap();

    assert_eq!(
        map_field(&report, "status"),
        &Expr::Symbol(Symbol::new("available"))
    );
    assert_eq!(
        list_strings(map_field(&report, "models")),
        vec!["local-a".to_owned(), "local-b".to_owned()]
    );
    assert_eq!(map_field(&report, "redacted"), &Expr::Bool(false));
    server.join().unwrap();
}

fn call_probe(cx: &mut sim_kernel::Cx, options: Vec<(&str, Expr)>) -> Result<Expr> {
    let args = Args::new(option_values(cx, options));
    let value = cx.call_function(&Symbol::qualified("provider", "probe"), args)?;
    value.object().as_expr(cx)
}

fn option_values(cx: &mut sim_kernel::Cx, options: Vec<(&str, Expr)>) -> Vec<Value> {
    let mut values = Vec::new();
    for (key, expr) in options {
        values.push(cx.factory().symbol(Symbol::new(format!(":{key}"))).unwrap());
        values.push(cx.factory().expr(expr).unwrap());
    }
    values
}

fn map_field<'a>(expr: &'a Expr, key: &str) -> &'a Expr {
    let Expr::Map(entries) = expr else {
        panic!("expected map expr, found {expr:?}");
    };
    entries
        .iter()
        .find_map(|(field, value)| match field {
            Expr::Symbol(symbol) if symbol.to_string() == key => Some(value),
            _ => None,
        })
        .unwrap_or_else(|| panic!("missing map field {key} in {expr:?}"))
}

fn list_strings(expr: &Expr) -> Vec<String> {
    let Expr::List(items) = expr else {
        panic!("expected list expr, found {expr:?}");
    };
    items
        .iter()
        .map(|item| match item {
            Expr::String(text) => text.clone(),
            other => panic!("expected string model, found {other:?}"),
        })
        .collect()
}

fn number_expr(canonical: &str) -> Expr {
    Expr::Number(sim_kernel::NumberLiteral {
        domain: Symbol::qualified("numbers", "f64"),
        canonical: canonical.to_owned(),
    })
}

fn bind_loopback_listener() -> Option<TcpListener> {
    for _ in 0..3 {
        match TcpListener::bind(("127.0.0.1", 0)) {
            Ok(listener) => return Some(listener),
            Err(error) if error.kind() == ErrorKind::PermissionDenied => {
                thread::sleep(Duration::from_millis(25));
            }
            Err(error) => panic!("failed to bind loopback listener: {error}"),
        }
    }
    None
}