malvin 0.2.5

Non-interactive research and coding agent
use pi::sdk::{ContentBlock, Message};
use serde_json::{Value, json};

use crate::bridge_protocol::{BridgeEvent, RunDoneStatus};

use super::usage_cost::aggregate_cost_usd;

#[must_use]
pub(crate) fn map_agent_end(messages: &[Message], error: Option<&str>) -> BridgeEvent {
    let status = if error.is_some() {
        RunDoneStatus::Error
    } else {
        RunDoneStatus::Finished
    };
    BridgeEvent::RunDone {
        status,
        result: last_assistant_text(messages),
        usage: aggregate_usage(messages),
        error: error.map(str::to_string),
        duration_ms: None,
    }
}

fn last_assistant_text(messages: &[Message]) -> Option<String> {
    messages.iter().rev().find_map(|msg| match msg {
        Message::Assistant(assistant) => text_from_blocks(&assistant.content),
        _ => None,
    })
}

fn text_from_blocks(blocks: &[ContentBlock]) -> Option<String> {
    let mut text = String::new();
    for block in blocks {
        if let ContentBlock::Text(part) = block {
            text.push_str(&part.text);
        }
    }
    (!text.is_empty()).then_some(text)
}

fn aggregate_usage(messages: &[Message]) -> Option<Value> {
    let mut input = 0_u64;
    let mut output = 0_u64;
    let mut cache_read = 0_u64;
    let mut cache_write = 0_u64;
    let mut seen = false;
    for msg in messages {
        let Message::Assistant(assistant) = msg else {
            continue;
        };
        let assistant = assistant.as_ref();
        seen = true;
        input = input.saturating_add(assistant.usage.input);
        output = output.saturating_add(assistant.usage.output);
        cache_read = cache_read.saturating_add(assistant.usage.cache_read);
        cache_write = cache_write.saturating_add(assistant.usage.cache_write);
    }
    if !seen {
        return None;
    }
    let mut usage = serde_json::Map::from_iter([
        ("inputTokens".into(), json!(input)),
        ("outputTokens".into(), json!(output)),
        ("cacheReadTokens".into(), json!(cache_read)),
        ("cacheWriteTokens".into(), json!(cache_write)),
    ]);
    let cost = aggregate_cost_usd(messages);
    if cost.is_present() {
        usage.insert(
            "costUsd".into(),
            json!({
                "input": cost.input,
                "output": cost.output,
                "cacheRead": cost.cache_read,
                "cacheWrite": cost.cache_write,
                "total": cost.total,
            }),
        );
    }
    Some(Value::Object(usage))
}