atman-runtime 1.9.1

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
mod common;

use std::sync::Arc;
use std::time::Duration;

use atman_dsl::parse::parse_file;
use atman_runtime::providers::mock::MockProvider;
use atman_runtime::stream::StreamFrame;
use atman_runtime::{Executor, Session, Value};

#[tokio::test]
async fn llm_chunks_flow_from_provider_to_session_stream() {
    let _registry = common::ModelRegistryGuard::mock("mock").await;
    let tmp = tempfile::tempdir().unwrap();
    let session = std::sync::Arc::new(Session::open(tmp.path()).unwrap());
    let mut rx = session.stream_subscribe();

    let ex = Executor::new();
    ex.providers.register(Arc::new(
        MockProvider::new("mock")
            .with_fallback(Value::Str("hi from mock".into()))
            .with_chunk_delay(Duration::from_millis(1)),
    ));

    let src = r#"flow t() -> string {
    return llm.call(model: "mock", prompt: "irrelevant", context: "session")
}
"#;
    let file = parse_file(src).unwrap();
    let user_msg =
        atman_runtime::message::Message::user_text(atman_runtime::event::TurnId::now(), "run");
    session.begin_turn(user_msg);
    ex.run_in_turn(&file, "t", vec![], None, Some(session.clone()))
        .await
        .expect("flow ok");
    session.end_turn();

    let mut got_chunk = false;
    let mut got_done = false;
    while let Ok(frame) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
        match frame {
            Ok(StreamFrame::LlmChunk { text, model, .. }) => {
                assert!(!text.is_empty(), "chunk text should not be empty");
                assert_eq!(model, "mock");
                got_chunk = true;
            }
            Ok(StreamFrame::LlmDone { .. }) => {
                got_done = true;
                break;
            }
            _ => {}
        }
    }
    assert!(got_chunk, "want at least one LlmChunk frame");
    assert!(got_done, "want LlmDone frame after stream ends");
}

#[tokio::test]
async fn tool_use_frames_wrap_dispatch() {
    let tmp = tempfile::tempdir().unwrap();
    let session = std::sync::Arc::new(Session::open(tmp.path()).unwrap());
    let mut rx = session.stream_subscribe();

    let ex = Executor::new();

    let src = r#"flow t() -> int {
    n = len([1, 2, 3])
    return n
}
"#;
    let file = parse_file(src).unwrap();
    let user_msg =
        atman_runtime::message::Message::user_text(atman_runtime::event::TurnId::now(), "run");
    session.begin_turn(user_msg);
    ex.run_in_turn(&file, "t", vec![], None, Some(session.clone()))
        .await
        .expect("flow ok");
    session.end_turn();

    let mut started = 0;
    let mut done = 0;
    while let Ok(frame) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
        match frame {
            Ok(StreamFrame::ToolUseStart { tool, .. }) if tool == "len" => started += 1,
            Ok(StreamFrame::ToolUseDone { tool, ok, .. }) if tool == "len" => {
                assert!(ok, "len should succeed");
                done += 1;
            }
            _ => {}
        }
    }
    assert_eq!(started, 1, "want one ToolUseStart for len");
    assert_eq!(done, 1, "want one ToolUseDone for len");
}

#[tokio::test]
async fn zero_subscribers_makes_stream_send_a_noop() {
    let tmp = tempfile::tempdir().unwrap();
    let session = std::sync::Arc::new(Session::open(tmp.path()).unwrap());

    let ex = Executor::new();

    let src = r#"flow t() -> int {
    return len([1, 2, 3])
}
"#;
    let file = parse_file(src).unwrap();
    let user_msg =
        atman_runtime::message::Message::user_text(atman_runtime::event::TurnId::now(), "run");
    session.begin_turn(user_msg);
    let out = ex
        .run_in_turn(&file, "t", vec![], None, Some(session.clone()))
        .await
        .expect("flow ok");
    session.end_turn();

    match out {
        Value::Int(n) => assert_eq!(n, 3),
        other => panic!("want int, got {other:?}"),
    }
}