sim-lib-agent 0.1.1

Agent runtime surfaces for SIM.
Documentation
use super::support::{
    as_component, eval_cx, flatten_text, install_agent_lib, install_test_codec, request_frame,
    temp_memory_path,
};
use sim_codec_chat::validate_chat_transcript;
use sim_kernel::{Expr, ReadPolicy, Symbol};
use sim_lib_server::EvalSite;
use std::time::Duration;

fn model_request_expr(task: &str, cache: Expr) -> Expr {
    Expr::Map(vec![
        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
        (
            Expr::Symbol(Symbol::new("task")),
            Expr::String(task.to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("messages")),
            Expr::List(Vec::new()),
        ),
        (Expr::Symbol(Symbol::new("cache")), cache),
    ])
}

fn reordered_model_request_expr(task: &str, cache: Expr) -> Expr {
    Expr::Map(vec![
        (Expr::Symbol(Symbol::new("cache")), cache),
        (
            Expr::Symbol(Symbol::new("messages")),
            Expr::List(Vec::new()),
        ),
        (
            Expr::Symbol(Symbol::new("task")),
            Expr::String(task.to_owned()),
        ),
        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
    ])
}

fn cache_policy(key: &str) -> Expr {
    Expr::Map(vec![
        (
            Expr::Symbol(Symbol::new("mode")),
            Expr::Symbol(Symbol::new("read-through")),
        ),
        (
            Expr::Symbol(Symbol::new("semantic-key")),
            Expr::String(key.to_owned()),
        ),
    ])
}

fn cache_policy_with_path(key: &str, path: &str) -> Expr {
    Expr::Map(vec![
        (
            Expr::Symbol(Symbol::new("mode")),
            Expr::Symbol(Symbol::new("read-through")),
        ),
        (
            Expr::Symbol(Symbol::new("semantic-key")),
            Expr::String(key.to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("path")),
            Expr::String(path.to_owned()),
        ),
    ])
}

fn cache_policy_with_ttl(key: &str, ttl: &str) -> Expr {
    Expr::Map(vec![
        (
            Expr::Symbol(Symbol::new("mode")),
            Expr::Symbol(Symbol::new("read-through")),
        ),
        (
            Expr::Symbol(Symbol::new("semantic-key")),
            Expr::String(key.to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("ttl")),
            Expr::String(ttl.to_owned()),
        ),
    ])
}

fn fake_runner(cx: &mut sim_kernel::Cx, model: &str, answer: &str) -> sim_kernel::Value {
    let script = Expr::List(vec![Expr::String(answer.to_owned())]);
    cx.call_function(
        &Symbol::qualified("runner", "fake"),
        sim_kernel::Args::new(vec![
            cx.factory().symbol(Symbol::new(":model")).unwrap(),
            cx.factory().string(model.to_owned()).unwrap(),
            cx.factory().symbol(Symbol::new(":script")).unwrap(),
            cx.factory().expr(script).unwrap(),
        ]),
    )
    .unwrap()
}

fn answer_with_runner(cx: &mut sim_kernel::Cx, runner: &sim_kernel::Value, request: Expr) -> Expr {
    let frame = request_frame(cx, request);
    as_component(runner)
        .answer(cx, frame)
        .unwrap()
        .decode_expr(cx, ReadPolicy::default())
        .unwrap()
        .as_map_field("value")
        .cloned()
        .unwrap()
}

trait MapField {
    fn as_map_field(&self, key: &str) -> Option<&Expr>;
}

impl MapField for Expr {
    fn as_map_field(&self, key: &str) -> Option<&Expr> {
        let Expr::Map(entries) = self else {
            return None;
        };
        entries.iter().find_map(|(field, value)| match field {
            Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
            _ => None,
        })
    }
}

#[test]
fn a6_phase4_cache_key_is_stable_across_map_order_and_includes_model() {
    let mut cx = eval_cx();
    install_test_codec(&mut cx);
    install_agent_lib(&mut cx).unwrap();
    let key = temp_memory_path("cache-key").display().to_string();

    let first = fake_runner(&mut cx, "model-a", "first");
    let first_response = answer_with_runner(
        &mut cx,
        &first,
        model_request_expr("same task", cache_policy(&key)),
    );
    validate_chat_transcript(&first_response).unwrap();
    assert_eq!(
        first_response.as_map_field("cache-hit"),
        Some(&Expr::Bool(false))
    );

    let second = fake_runner(&mut cx, "model-a", "second");
    let second_response = answer_with_runner(
        &mut cx,
        &second,
        reordered_model_request_expr("same task", cache_policy(&key)),
    );
    validate_chat_transcript(&second_response).unwrap();
    assert_eq!(
        second_response.as_map_field("cache-hit"),
        Some(&Expr::Bool(true))
    );
    assert!(flatten_text(&second_response).contains("first"));

    let other_model = fake_runner(&mut cx, "model-b", "model scoped");
    let other_response = answer_with_runner(
        &mut cx,
        &other_model,
        model_request_expr("same task", cache_policy(&key)),
    );
    assert_eq!(
        other_response.as_map_field("cache-hit"),
        Some(&Expr::Bool(false))
    );
    assert!(flatten_text(&other_response).contains("model scoped"));
}

#[test]
fn a6_phase4_cache_key_includes_tools() {
    let mut cx = eval_cx();
    install_test_codec(&mut cx);
    install_agent_lib(&mut cx).unwrap();
    let tools_key = temp_memory_path("cache-tools").display().to_string();
    let with_tools = Expr::Map(vec![
        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
        (
            Expr::Symbol(Symbol::new("task")),
            Expr::String("tool descriptor task".to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("messages")),
            Expr::List(Vec::new()),
        ),
        (
            Expr::Symbol(Symbol::new("tools")),
            Expr::List(vec![Expr::String("sum".to_owned())]),
        ),
        (Expr::Symbol(Symbol::new("cache")), cache_policy(&tools_key)),
    ]);
    let without_tools = model_request_expr("tool descriptor task", cache_policy(&tools_key));
    let runner_three = fake_runner(&mut cx, "tool-model", "with tools");
    answer_with_runner(&mut cx, &runner_three, with_tools);
    let runner_four = fake_runner(&mut cx, "tool-model", "without tools");
    let response = answer_with_runner(&mut cx, &runner_four, without_tools);
    assert_eq!(response.as_map_field("cache-hit"), Some(&Expr::Bool(false)));
    assert!(flatten_text(&response).contains("without tools"));
}

#[test]
fn a6_phase4_cache_ttl_ignores_stale_entries() {
    let mut cx = eval_cx();
    install_test_codec(&mut cx);
    install_agent_lib(&mut cx).unwrap();
    let key = temp_memory_path("cache-ttl").display().to_string();
    let request = model_request_expr("ttl task", cache_policy_with_ttl(&key, "1ms"));

    let first = fake_runner(&mut cx, "ttl-model", "stale answer");
    answer_with_runner(&mut cx, &first, request.clone());
    std::thread::sleep(Duration::from_millis(5));

    let second = fake_runner(&mut cx, "ttl-model", "fresh answer");
    let response = answer_with_runner(&mut cx, &second, request);
    assert_eq!(response.as_map_field("cache-hit"), Some(&Expr::Bool(false)));
    assert!(flatten_text(&response).contains("fresh answer"));
}

#[test]
fn a6_phase4_non_idempotent_tool_continuation_is_not_cached() {
    let mut cx = eval_cx();
    install_test_codec(&mut cx);
    install_agent_lib(&mut cx).unwrap();
    let key = temp_memory_path("cache-tool-continuation")
        .display()
        .to_string();
    let request = Expr::Map(vec![
        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
        (
            Expr::Symbol(Symbol::new("task")),
            Expr::String("continue after tool".to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("messages")),
            Expr::List(Vec::new()),
        ),
        (
            Expr::Symbol(Symbol::new("tool-results")),
            Expr::List(vec![Expr::String("side effect".to_owned())]),
        ),
        (Expr::Symbol(Symbol::new("cache")), cache_policy(&key)),
    ]);

    let first = fake_runner(&mut cx, "tool-continuation", "first continuation");
    answer_with_runner(&mut cx, &first, request.clone());
    let second = fake_runner(&mut cx, "tool-continuation", "second continuation");
    let response = answer_with_runner(&mut cx, &second, request);
    assert!(response.as_map_field("cache-hit").is_none());
    assert!(flatten_text(&response).contains("second continuation"));
}

#[test]
fn a6_phase4_persistent_cache_writes_require_capability() {
    let mut cx = eval_cx();
    install_test_codec(&mut cx);
    install_agent_lib(&mut cx).unwrap();
    let path = temp_memory_path("persistent-model-cache");
    let request = model_request_expr(
        "persistent task",
        cache_policy_with_path("persistent-key", &path.display().to_string()),
    );
    let runner = fake_runner(&mut cx, "persistent-model", "would write");
    let frame = request_frame(&mut cx, request.clone());
    let denied = as_component(&runner).answer(&mut cx, frame).unwrap_err();
    assert!(
        matches!(denied, sim_kernel::Error::CapabilityDenied { capability } if capability == sim_kernel::CapabilityName::new("ai-runner-cache"))
    );

    cx.grant_named("ai-runner-cache");
    let writer = fake_runner(&mut cx, "persistent-model", "persisted");
    let response = answer_with_runner(&mut cx, &writer, request.clone());
    assert_eq!(response.as_map_field("cache-hit"), Some(&Expr::Bool(false)));

    let reader = fake_runner(&mut cx, "persistent-model", "miss");
    let response = answer_with_runner(&mut cx, &reader, request);
    validate_chat_transcript(&response).unwrap();
    assert_eq!(response.as_map_field("cache-hit"), Some(&Expr::Bool(true)));
    assert!(flatten_text(&response).contains("persisted"));

    let _ = std::fs::remove_file(path);
}