atman-runtime 1.9.1

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use atman_runtime::event::TurnId;
use atman_runtime::message::{Message, MessageOrigin, MessagePart, MessageRole};
use atman_runtime::provider::LlmRequest;
use atman_runtime::providers::openai::OpenAiProvider;
use atman_runtime::value::Value;
use uuid::Uuid;

fn provider() -> OpenAiProvider {
    OpenAiProvider::new("openai", "test-key").with_base_url("http://irrelevant".to_string())
}

fn tid() -> TurnId {
    TurnId(Uuid::nil())
}

fn user(text: &str) -> Message {
    Message {
        role: MessageRole::User,
        parts: vec![MessagePart::Text {
            text: text.to_string(),
        }],
        turn_id: tid(),
        origin: MessageOrigin::User,
    }
}

fn tool_result(id: &str, content: &str) -> Message {
    Message {
        role: MessageRole::Tool,
        parts: vec![MessagePart::ToolResult {
            tool_use_id: id.to_string(),
            content: content.to_string(),
            is_error: false,
        }],
        turn_id: tid(),
        origin: MessageOrigin::User,
    }
}

fn assistant_with_tool_use(text: &str, id: &str, name: &str, input: serde_json::Value) -> Message {
    Message {
        role: MessageRole::Assistant,
        parts: vec![
            MessagePart::Text {
                text: text.to_string(),
            },
            MessagePart::ToolUse {
                id: id.to_string(),
                name: name.to_string(),
                input,
            },
        ],
        turn_id: tid(),
        origin: MessageOrigin::User,
    }
}

fn fixed_request() -> LlmRequest {
    LlmRequest {
        model: "gpt-test".to_string(),
        messages: vec![
            user("what is the weather in tokyo?"),
            assistant_with_tool_use(
                "let me check",
                "call_1",
                "get_weather",
                serde_json::json!({"city": "tokyo", "unit": "c"}),
            ),
            tool_result("call_1", "{\"temp_c\":22}"),
            user("thanks — now paris?"),
        ],
        system: Some("you are a helpful assistant.".to_string()),
        input: Value::Unit,
        schema: None,
        cache_prompt: false,
        tools: Vec::new(),
        thinking_enabled: false,
        stall_timeout_secs: 0,
    }
}

#[test]
fn openai_wire_body_is_byte_stable_across_100_serializations() {
    let p = provider();
    let req = fixed_request();
    let first = p.wire_body_bytes(&req, false);
    for i in 0..100 {
        let again = p.wire_body_bytes(&req, false);
        assert_eq!(
            again, first,
            "wire body drift at iteration {i} — OpenAI prompt cache requires byte-stable serialization"
        );
    }
}

#[test]
fn openai_wire_body_tool_use_input_key_order_is_stable() {
    let p = provider();
    let req = LlmRequest {
        model: "m".to_string(),
        messages: vec![assistant_with_tool_use(
            "",
            "call_x",
            "f",
            serde_json::json!({"zeta": 1, "alpha": 2, "middle": 3}),
        )],
        system: None,
        input: Value::Unit,
        schema: None,
        cache_prompt: false,
        tools: Vec::new(),
        thinking_enabled: false,
        stall_timeout_secs: 0,
    };
    let body = p.wire_body_bytes(&req, false);
    let s = std::str::from_utf8(&body).unwrap();
    let z = s.find("zeta").expect("zeta present");
    let a = s.find("alpha").expect("alpha present");
    let m = s.find("middle").expect("middle present");
    assert!(
        z < a && a < m,
        "tool_use input JSON keys reordered — serde_json preserve_order broken? body={s}"
    );
}

#[test]
fn openai_wire_body_carries_tool_specs_as_function_calls() {
    let p = provider();
    let mut req = fixed_request();
    req.tools = vec![
        atman_runtime::tool::ToolSpec {
            name: "fs.read".to_string(),
            description: Some("read a file".into()),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {"path": {"type": "string"}},
                "required": ["path"]
            }),
        },
        atman_runtime::tool::ToolSpec {
            name: "bash.exec".to_string(),
            description: None,
            input_schema: serde_json::json!({"type": "object"}),
        },
    ];
    let body: serde_json::Value = serde_json::from_slice(&p.wire_body_bytes(&req, false)).unwrap();
    let tools = body["tools"].as_array().expect("tools array present");
    assert_eq!(tools.len(), 2);
    let first = &tools[0];
    assert_eq!(first["type"].as_str(), Some("function"));
    assert_eq!(first["function"]["name"].as_str(), Some("fs_read"));
    assert_eq!(
        first["function"]["description"].as_str(),
        Some("read a file")
    );
    assert!(first["function"]["parameters"]["properties"]["path"].is_object());
    let second = &tools[1];
    assert_eq!(second["function"]["name"].as_str(), Some("bash_exec"));
    assert!(second["function"].get("description").is_none());
}

#[test]
fn openai_wire_body_omits_tools_field_when_list_empty() {
    let p = provider();
    let req = fixed_request();
    let body: serde_json::Value = serde_json::from_slice(&p.wire_body_bytes(&req, false)).unwrap();
    assert!(body.get("tools").is_none(), "body: {body}");
}

#[test]
fn openai_wire_body_prefix_stable_when_new_message_appended() {
    let p = provider();
    let base = fixed_request();
    let mut extended = fixed_request();
    extended.messages.push(user("and london?"));

    let base_body: serde_json::Value =
        serde_json::from_slice(&p.wire_body_bytes(&base, false)).unwrap();
    let ext_body: serde_json::Value =
        serde_json::from_slice(&p.wire_body_bytes(&extended, false)).unwrap();

    let base_msgs = base_body["messages"].as_array().unwrap();
    let ext_msgs = ext_body["messages"].as_array().unwrap();
    assert_eq!(ext_msgs.len(), base_msgs.len() + 1);
    for (i, base_msg) in base_msgs.iter().enumerate() {
        assert_eq!(
            &ext_msgs[i], base_msg,
            "message {i} drifted when we appended a new turn — breaks OpenAI prefix cache"
        );
    }
}