atman-runtime 1.2.0

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use atman_runtime::message::MessagePart;
use atman_runtime::provider::{LlmRequest, Provider};
use atman_runtime::providers::openai::OpenAiProvider;
use atman_runtime::value::Value;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn tool_call_response() -> serde_json::Value {
    serde_json::json!({
        "id": "chatcmpl-tooluse-1",
        "object": "chat.completion",
        "model": "gpt-test",
        "choices": [{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": null,
                "tool_calls": [{
                    "id": "call_abc",
                    "type": "function",
                    "function": {
                        "name": "fs.read",
                        "arguments": "{\"path\": \"foo.txt\"}"
                    }
                }]
            },
            "finish_reason": "tool_calls"
        }],
        "usage": {"prompt_tokens": 10, "completion_tokens": 3, "total_tokens": 13}
    })
}

#[tokio::test]
async fn openai_non_streaming_returns_tool_use_parts_from_tool_calls() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(tool_call_response()))
        .mount(&server)
        .await;

    let provider = OpenAiProvider::new("openai", "test-key").with_base_url(server.uri());
    let am = provider
        .call(LlmRequest {
            model: "gpt-test".to_string(),
            messages: vec![atman_runtime::provider::user_text_message("edit foo.txt")],
            system: None,
            input: Value::Unit,
            schema: None,
            cache_prompt: false,
            tools: Vec::new(),
            thinking_enabled: false,
            stall_timeout_secs: 0,
        })
        .await
        .expect("call ok");

    let tool_use = am
        .message
        .parts
        .iter()
        .find_map(|p| match p {
            MessagePart::ToolUse { id, name, input } => {
                Some((id.clone(), name.clone(), input.clone()))
            }
            _ => None,
        })
        .expect("expected a ToolUse part parsed from tool_calls");
    assert_eq!(tool_use.0, "call_abc");
    assert_eq!(tool_use.1, "fs.read");
    assert_eq!(tool_use.2["path"], serde_json::json!("foo.txt"));
    assert_eq!(am.token_usage.input, 10);
    assert_eq!(am.token_usage.output, 3);
}

const STREAM_TOOL_CALLS: &str = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_zz\",\"type\":\"function\",\"function\":{\"name\":\"fs.write\",\"arguments\":\"\"}}]}}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\": \\\"a.txt\\\", \\\"content\\\": \\\"hi\\\"}\"}}]}}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":8,\"total_tokens\":28}}\n\n\
data: [DONE]\n\n";

#[tokio::test]
async fn openai_streaming_accumulates_tool_calls_across_chunks() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "text/event-stream")
                .set_body_raw(STREAM_TOOL_CALLS, "text/event-stream"),
        )
        .mount(&server)
        .await;

    let provider = OpenAiProvider::new("openai", "test-key").with_base_url(server.uri());
    let obs = provider.call_streaming(LlmRequest {
        model: "gpt-test".to_string(),
        messages: vec![atman_runtime::provider::user_text_message("write a.txt")],
        system: None,
        input: Value::Unit,
        schema: None,
        cache_prompt: false,
        tools: Vec::new(),
        thinking_enabled: false,
        stall_timeout_secs: 0,
    });
    let am = obs.output.await.expect("streaming call ok");
    let tool_use = am
        .message
        .parts
        .iter()
        .find_map(|p| match p {
            MessagePart::ToolUse { id, name, input } => {
                Some((id.clone(), name.clone(), input.clone()))
            }
            _ => None,
        })
        .expect("expected accumulated ToolUse across streamed chunks");
    assert_eq!(tool_use.0, "call_zz");
    assert_eq!(tool_use.1, "fs.write");
    assert_eq!(tool_use.2["path"], serde_json::json!("a.txt"));
    assert_eq!(tool_use.2["content"], serde_json::json!("hi"));
}