sim-lib-skill 0.1.0-rc.1

SIM workspace package for sim lib skill.
Documentation
use std::{sync::Arc, time::Duration};

use sim_kernel::{Args, Cx, DefaultFactory, EagerPolicy, Expr, Result, Symbol, Value};
use sim_lib_agent_runner_core::ModelEvent;
use sim_shape::{
    ExprKind, ExprKindShape, FieldShape, FieldSpec, ListShape, NumberValueShape, shape_value,
};

use super::{
    ProcessSkillProtocol, ProcessSkillSpec, ProcessSkillTransport, skill_process_capability,
};
use crate::{
    SkillCallable, SkillEventSink, SkillRole, SkillTransport, install_skill_lib,
    skill_call_capability, skill_call_symbol, skill_install_capability, skill_install_symbol,
    skill_specific_call_capability,
};

#[test]
fn json_stdio_process_fixture_returns_typed_value() {
    let mut cx = skill_cx();
    grant_call_caps(&mut cx, "proc.answer");
    let transport = install_process_skill(&mut cx, json_transport());
    let target = cx.factory().string("proc.answer".to_owned()).unwrap();
    let request = cx.factory().string("payload".to_owned()).unwrap();

    let result = cx
        .call_function(&skill_call_symbol(), Args::new(vec![target, request]))
        .unwrap();
    let expr = result.object().as_expr(&mut cx).unwrap();

    assert_eq!(map_number(&expr, "answer"), Some("42".to_owned()));
    assert_eq!(map_string(&expr, "status"), Some("ok".to_owned()));
    assert_eq!(transport.call_count(), 1);
}

#[test]
fn process_skill_fails_closed_without_process_capability() {
    let mut cx = skill_cx();
    cx.grant(skill_install_capability());
    cx.grant(skill_call_capability());
    cx.grant(skill_specific_call_capability("proc.answer"));
    let transport = install_process_skill(&mut cx, json_transport());
    let target = cx.factory().string("proc.answer".to_owned()).unwrap();
    let request = cx.factory().string("payload".to_owned()).unwrap();

    let err = cx
        .call_function(&skill_call_symbol(), Args::new(vec![target, request]))
        .unwrap_err();

    assert!(format!("{err:?}").contains("skill.process"));
    assert_eq!(transport.call_count(), 0);
}

#[test]
fn process_errors_and_health_do_not_expose_command_or_inputs() {
    let mut cx = skill_cx();
    grant_call_caps(&mut cx, "proc.secret");
    let transport = install_process_skill(&mut cx, secret_transport());
    let target = cx.factory().string("proc.secret".to_owned()).unwrap();
    let secret_input = "secret-input";
    let request = cx.factory().string(secret_input.to_owned()).unwrap();

    let err = cx
        .call_function(&skill_call_symbol(), Args::new(vec![target, request]))
        .unwrap_err();
    let health = transport.health(&mut cx).unwrap();
    let rendered = format!("{err:?} {:?}", health.object().as_expr(&mut cx).unwrap());

    assert!(!rendered.contains("secret-command"));
    assert!(!rendered.contains(secret_input));
    assert_eq!(transport.call_count(), 1);
}

#[test]
fn line_text_model_role_emits_model_events_through_skill_callable() {
    let mut cx = skill_cx();
    grant_call_caps(&mut cx, "proc.lines");
    let transport = install_process_skill(&mut cx, line_transport());
    let card = transport.discover(&mut cx).unwrap().remove(0);
    let callable = cx
        .resolve_function(&card.symbol)
        .unwrap()
        .object()
        .downcast_ref::<SkillCallable>()
        .unwrap()
        .clone();
    let request = cx.factory().string("payload".to_owned()).unwrap();
    let mut sink = CollectingSink::default();

    let result = callable
        .call_values(&mut cx, vec![request], Some(&mut sink))
        .unwrap();
    let text = result.object().as_expr(&mut cx).unwrap();
    let events = sink.model_events();
    let kinds = events
        .iter()
        .map(|event| event.event.clone())
        .collect::<Vec<_>>();

    assert!(matches!(text, Expr::String(value) if value.contains("one") && value.contains("two")));
    assert_eq!(
        kinds,
        vec![
            Symbol::new("start"),
            Symbol::new("delta"),
            Symbol::new("delta"),
            Symbol::new("usage"),
        ]
    );
    assert!(format!("{:?}", events[1].extra).contains("one"));
    assert!(format!("{:?}", events[2].extra).contains("two"));
    assert_eq!(transport.call_count(), 1);
}

#[derive(Default)]
struct CollectingSink {
    events: Vec<Expr>,
}

impl CollectingSink {
    fn model_events(&self) -> Vec<ModelEvent> {
        self.events
            .iter()
            .cloned()
            .map(ModelEvent::try_from)
            .collect::<Result<Vec<_>>>()
            .unwrap()
    }
}

impl SkillEventSink for CollectingSink {
    fn emit(&mut self, cx: &mut Cx, event: Value) -> Result<()> {
        self.events.push(event.object().as_expr(cx)?);
        Ok(())
    }
}

fn skill_cx() -> Cx {
    let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
    install_skill_lib(&mut cx).unwrap();
    cx
}

fn grant_call_caps(cx: &mut Cx, id: &str) {
    cx.grant(skill_install_capability());
    cx.grant(skill_call_capability());
    cx.grant(skill_specific_call_capability(id));
    cx.grant(skill_process_capability());
}

fn install_process_skill(
    cx: &mut Cx,
    transport: ProcessSkillTransport,
) -> Arc<ProcessSkillTransport> {
    cx.grant(skill_install_capability());
    let transport = Arc::new(transport);
    let transport_value = transport.clone().value(cx).unwrap();
    let cards = transport
        .discover(cx)
        .unwrap()
        .into_iter()
        .map(|card| card.value(cx))
        .collect::<Result<Vec<_>>>()
        .unwrap();
    let mut args = vec![transport_value];
    args.extend(cards);
    cx.call_function(&skill_install_symbol(), Args::new(args))
        .unwrap();
    transport
}

fn json_transport() -> ProcessSkillTransport {
    let mut transport = ProcessSkillTransport::new("proc");
    transport.insert(ProcessSkillSpec {
        id: "proc.answer".to_owned(),
        operation: "answer".to_owned(),
        title: "Process Answer".to_owned(),
        description: "Return a typed JSON result through process JSON stdio.".to_owned(),
        command_template: r#"printf '{"answer":42,"status":"ok"}'"#.to_owned(),
        protocol: ProcessSkillProtocol::JsonStdio,
        input_shape: string_arg_shape("process-json-args"),
        output_shape: answer_shape(),
        roles: vec![SkillRole::Tool],
        timeout: Duration::from_secs(1),
        max_output_bytes: 1024,
    });
    transport
}

fn secret_transport() -> ProcessSkillTransport {
    let mut transport = ProcessSkillTransport::new("proc");
    transport.insert(ProcessSkillSpec {
        id: "proc.secret".to_owned(),
        operation: "secret".to_owned(),
        title: "Secret Process".to_owned(),
        description: "Fail without exposing command or inputs.".to_owned(),
        command_template: "printf ignored >/dev/null; exit 7 # secret-command".to_owned(),
        protocol: ProcessSkillProtocol::JsonStdio,
        input_shape: string_arg_shape("process-secret-args"),
        output_shape: answer_shape(),
        roles: vec![SkillRole::Tool],
        timeout: Duration::from_secs(1),
        max_output_bytes: 1024,
    });
    transport
}

fn line_transport() -> ProcessSkillTransport {
    let mut transport = ProcessSkillTransport::new("proc");
    transport.insert(ProcessSkillSpec {
        id: "proc.lines".to_owned(),
        operation: "lines".to_owned(),
        title: "Line Process".to_owned(),
        description: "Stream line text as model events.".to_owned(),
        command_template: "printf 'one\\ntwo\\n'".to_owned(),
        protocol: ProcessSkillProtocol::LineText,
        input_shape: string_arg_shape("process-line-args"),
        output_shape: string_shape("process-line-result"),
        roles: vec![SkillRole::Tool, SkillRole::Model],
        timeout: Duration::from_secs(1),
        max_output_bytes: 1024,
    });
    transport
}

fn string_arg_shape(name: &str) -> Value {
    shape_value(
        Symbol::qualified("skill-process", name),
        Arc::new(ListShape::new(vec![Arc::new(ExprKindShape::new(
            ExprKind::String,
        ))])),
    )
}

fn string_shape(name: &str) -> Value {
    shape_value(
        Symbol::qualified("skill-process", name),
        Arc::new(ExprKindShape::new(ExprKind::String)),
    )
}

fn answer_shape() -> Value {
    shape_value(
        Symbol::qualified("skill-process", "answer-result"),
        Arc::new(FieldShape::anonymous(vec![
            FieldSpec::required(Symbol::new("answer"), Arc::new(NumberValueShape)),
            FieldSpec::required(
                Symbol::new("status"),
                Arc::new(ExprKindShape::new(ExprKind::String)),
            ),
        ])),
    )
}

fn map_string(expr: &Expr, key: &str) -> Option<String> {
    map_field(expr, key).and_then(|value| match value {
        Expr::String(text) => Some(text.clone()),
        _ => None,
    })
}

fn map_number(expr: &Expr, key: &str) -> Option<String> {
    map_field(expr, key).and_then(|value| match value {
        Expr::Number(number) => Some(number.canonical.clone()),
        _ => None,
    })
}

use sim_value::access::field as map_field;