tiny-agent 0.3.0

一个小而完整的 Rust LLM Agent 运行时:可中断、可恢复、可观测、可插拔的 agent loop / A small but complete LLM agent runtime in Rust — an interruptible, resumable, observable, pluggable agent loop.
Documentation
use crate::{
    providers::{LLMRequest, TokenUsage},
    shared::{ContentBlock, Message},
};
use serde_json::Value;
use tracing::Span;

/// 把若干内容块序列化成 JSON 字符串(全量,不截断),用于在 span attribute 上记录原文。
fn blocks_json(blocks: &[ContentBlock]) -> String {
    serde_json::to_string(blocks).unwrap_or_else(|_| "[]".to_string())
}

/// 把消息列表序列化成 JSON 字符串(全量),用于记录发给模型的完整历史。
fn messages_json(messages: &[Message]) -> String {
    serde_json::to_string(messages).unwrap_or_else(|_| "[]".to_string())
}

pub(crate) fn user_message(session_id: &str, text: &str) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "user_message",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.user.input_bytes" = text.len(),
        "tiny_agent.session.id" = %session_id,
        "user message submitted"
    );
}

pub(crate) fn reasoning_started(session_id: &str, req: &LLMRequest, provider: &str) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "reasoning_started",
        "gen_ai.operation.name" = "chat",
        "gen_ai.conversation.id" = %session_id,
        "gen_ai.provider.name" = %provider,
        "gen_ai.request.model" = %req.model,
        "gen_ai.request.temperature" = ?req.temperature,
        "gen_ai.request.max_tokens" = ?req.max_tokens,
        "gen_ai.request.stream" = true,
        "tiny_agent.request.has_system" = req.system.is_some(),
        "tiny_agent.request.message_count" = req.messages.len(),
        "tiny_agent.request.tool_count" = req.tools.len(),
        "tiny_agent.session.id" = %session_id,
        "reasoning started"
    );
}

pub(crate) fn thinking(session_id: &str, text: &str) {
    tracing::debug!(
        target: "tiny_agent",
        event_type = "thinking",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.thinking.bytes" = text.len(),
        "tiny_agent.session.id" = %session_id,
        "thinking content received"
    );
}

pub(crate) fn assistant_message(session_id: &str, blocks: &[ContentBlock]) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "assistant_message",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.response.block_count" = blocks.len(),
        "tiny_agent.session.id" = %session_id,
        "assistant message received"
    );
}

pub(crate) fn tool_started(session_id: &str, call_id: &str, name: &str, arguments: &Value) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "tool_started",
        "gen_ai.operation.name" = "execute_tool",
        "gen_ai.conversation.id" = %session_id,
        "gen_ai.tool.call.id" = %call_id,
        "gen_ai.tool.name" = %name,
        "gen_ai.tool.type" = "function",
        "tiny_agent.tool.argument_kind" = %value_kind(arguments),
        "tiny_agent.session.id" = %session_id,
        "tool started"
    );
}

pub(crate) fn tool_completed(
    session_id: &str,
    call_id: &str,
    name: &str,
    output: &[ContentBlock],
    is_error: bool,
) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "tool_completed",
        "gen_ai.operation.name" = "execute_tool",
        "gen_ai.conversation.id" = %session_id,
        "gen_ai.tool.call.id" = %call_id,
        "gen_ai.tool.name" = %name,
        "gen_ai.tool.type" = "function",
        "tiny_agent.tool.result_block_count" = output.len(),
        "tiny_agent.session.id" = %session_id,
        error = is_error,
        "tool completed"
    );
}

pub(crate) fn interrupted(session_id: &str) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "interrupted",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.session.id" = %session_id,
        "agent interrupted"
    );
}

pub(crate) fn waiting_for_user(session_id: &str, kind: &str) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "waiting_for_user",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.user_interaction.kind" = %kind,
        "tiny_agent.session.id" = %session_id,
        "waiting for user"
    );
}

pub(crate) fn failed(session_id: &str, kind: &str, message: &str) {
    tracing::warn!(
        target: "tiny_agent",
        event_type = "failed",
        "gen_ai.conversation.id" = %session_id,
        "error.type" = %kind,
        "error.message" = %message,
        "tiny_agent.session.id" = %session_id,
        "agent failure"
    );
}

pub(crate) fn succeeded(session_id: &str, final_resp: Option<&str>) {
    tracing::info!(
        target: "tiny_agent",
        event_type = "succeeded",
        "gen_ai.conversation.id" = %session_id,
        "tiny_agent.final_response.bytes" = final_resp.map(str::len).unwrap_or(0),
        "tiny_agent.session.id" = %session_id,
        "agent succeeded"
    );
}

/// 把本轮发给模型的**完整输入**(系统提示 + 历史 + 本轮消息)以及请求参数记录到
/// 当前 `gen_ai.chat` span 上。需在 [`AgentRunTime::handle_reasoning`] 建的 span 作用域内调用。
///
/// 系统提示单独成 `gen_ai.request.system`,历史/本轮成 `gen_ai.input.messages`(全量 JSON,不截断)。
pub(crate) fn record_chat_input(req: &LLMRequest) {
    let span = Span::current();
    if let Some(temperature) = req.temperature {
        span.record("gen_ai.request.temperature", temperature as f64);
    }
    if let Some(max_tokens) = req.max_tokens {
        span.record("gen_ai.request.max_tokens", max_tokens as i64);
    }
    if let Some(system) = req.system.as_deref() {
        span.record("gen_ai.request.system", system);
    }
    span.record(
        "gen_ai.input.messages",
        messages_json(&req.messages).as_str(),
    );
}

/// 把本轮模型的**输出**(thinking / 回答 / 工具调用块)、结束原因与 token 用量记录到
/// 当前 `gen_ai.chat` span 上。
pub(crate) fn record_chat_output(
    blocks: &[ContentBlock],
    finish_reason: &str,
    usage: Option<&TokenUsage>,
) {
    let span = Span::current();
    span.record("gen_ai.output.messages", blocks_json(blocks).as_str());
    span.record("gen_ai.response.finish_reasons", finish_reason);
    if let Some(usage) = usage {
        if let Some(input_tokens) = usage.input_tokens {
            span.record("gen_ai.usage.input_tokens", input_tokens as i64);
        }
        if let Some(output_tokens) = usage.output_tokens {
            span.record("gen_ai.usage.output_tokens", output_tokens as i64);
        }
        if let Some(total_tokens) = usage.total_tokens {
            span.record("gen_ai.usage.total_tokens", total_tokens as i64);
        }
    }
}

/// 把工具调用的**入参**记录到当前 `tool.call` span 上(全量 JSON)。
pub(crate) fn record_tool_input(arguments: &Value) {
    Span::current().record("gen_ai.tool.call.arguments", arguments.to_string().as_str());
}

/// 把工具调用的**结果**与出错标记记录到当前 `tool.call` span 上(全量 JSON)。
pub(crate) fn record_tool_output(output: &[ContentBlock], is_error: bool) {
    let span = Span::current();
    span.record("gen_ai.tool.call.result", blocks_json(output).as_str());
    span.record("error", is_error);
}

fn value_kind(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}