crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! `crabmate_tool` 信封与工具输出的**单一 normalize 入口**:SSE、`role: tool` 写入、CLI 展示共用同一套字段语义。
//!
//! - **载荷版本** `crabmate_tool.v`(当前仅 **1**):与整条 SSE 控制面的 **`SseMessage.v`**(`SSE_PROTOCOL_VERSION`)不同;SSE `tool_result` 另带 **`result_version`** 与之对齐。
//! - 未知 `v`:仍尝试读取 v1 同名字段;未来 bump 时可在此集中做迁移。

use serde_json::{Map, Value};

use super::tool_error::failure_category_for_error_code;
use super::{
    ParsedLegacyOutput, ToolEnvelopeContext, parse_legacy_output, tool_error_retryable_heuristic,
};

/// 当前实现的 `crabmate_tool.v` 值;与 `encode_tool_message_envelope_v1` 写入一致。
pub const CRABMATE_TOOL_ENVELOPE_VERSION_V1: u32 = 1;

/// 归一化后的工具结果(信封 v1 形状的逻辑视图)。
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedToolEnvelope {
    pub envelope_version: u32,
    pub name: String,
    pub summary: String,
    pub output: String,
    pub ok: bool,
    pub exit_code: Option<i32>,
    pub error_code: Option<String>,
    /// 与 `tool_error::ToolFailureCategory::as_str` 一致;失败时由 `error_code` 推导,供客户端粗粒度分支。
    pub failure_category: Option<String>,
    pub retryable: Option<bool>,
    pub tool_call_id: Option<String>,
    pub execution_mode: Option<String>,
    pub parallel_batch_id: Option<String>,
    pub output_truncated: bool,
    pub output_original_chars: Option<u64>,
    pub output_kept_head_chars: Option<u64>,
    pub output_kept_tail_chars: Option<u64>,
    /// 可选:机器可读摘要(如 **`run_command`** 的 **`run_command_exit_v1`**),与 **`output`** 正文并存。
    pub structured_payload: Option<Value>,
}

impl NormalizedToolEnvelope {
    /// 由一次工具执行产物构造(与历史 `encode_tool_message_envelope_v1` 输入一致)。
    pub fn from_tool_run(
        tool_name: &str,
        summary: String,
        parsed: &ParsedLegacyOutput,
        raw_output: &str,
        envelope_ctx: Option<&ToolEnvelopeContext<'_>>,
        structured_payload: Option<Value>,
    ) -> Self {
        let retryable = if parsed.ok {
            None
        } else {
            Some(tool_error_retryable_heuristic(parsed.error_code.as_deref()))
        };
        let (tool_call_id, execution_mode, parallel_batch_id) = match envelope_ctx {
            Some(c) => (
                Some(c.tool_call_id.to_string()),
                Some(c.execution_mode.to_string()),
                c.parallel_batch_id.map(|s| s.to_string()),
            ),
            None => (None, None, None),
        };
        let failure_category = if parsed.ok {
            None
        } else {
            parsed
                .error_code
                .as_deref()
                .map(failure_category_for_error_code)
                .map(|c| c.as_str().to_string())
        };
        Self {
            envelope_version: CRABMATE_TOOL_ENVELOPE_VERSION_V1,
            name: tool_name.to_string(),
            summary,
            output: raw_output.to_string(),
            ok: parsed.ok,
            exit_code: parsed.exit_code,
            error_code: parsed.error_code.clone(),
            failure_category,
            retryable,
            tool_call_id,
            execution_mode,
            parallel_batch_id,
            output_truncated: false,
            output_original_chars: None,
            output_kept_head_chars: None,
            output_kept_tail_chars: None,
            structured_payload,
        }
    }

    fn from_crabmate_object(ct: &Map<String, Value>) -> Option<Self> {
        let envelope_version = ct
            .get("v")
            .and_then(|x| x.as_u64())
            .map(|u| u as u32)
            .unwrap_or(CRABMATE_TOOL_ENVELOPE_VERSION_V1);
        let name = ct.get("name")?.as_str()?.to_string();
        let summary = ct
            .get("summary")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string();
        let output = ct.get("output")?.as_str()?.to_string();
        let ok = ct
            .get("ok")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(|| parse_legacy_output(name.as_str(), output.as_str()).ok);
        let exit_code = ct
            .get("exit_code")
            .and_then(|x| x.as_i64())
            .map(|i| i as i32);
        let error_code = ct
            .get("error_code")
            .and_then(|x| x.as_str())
            .map(String::from);
        let failure_category_stored = ct
            .get("failure_category")
            .and_then(|x| x.as_str())
            .map(String::from);
        let retryable = ct.get("retryable").and_then(|x| x.as_bool());
        let tool_call_id = ct
            .get("tool_call_id")
            .and_then(|x| x.as_str())
            .map(String::from);
        let execution_mode = ct
            .get("execution_mode")
            .and_then(|x| x.as_str())
            .map(String::from);
        let parallel_batch_id = ct
            .get("parallel_batch_id")
            .and_then(|x| x.as_str())
            .map(String::from);
        let output_truncated = ct.get("output_truncated").and_then(|x| x.as_bool()) == Some(true);
        let output_original_chars = ct.get("output_original_chars").and_then(|x| x.as_u64());
        let output_kept_head_chars = ct.get("output_kept_head_chars").and_then(|x| x.as_u64());
        let output_kept_tail_chars = ct.get("output_kept_tail_chars").and_then(|x| x.as_u64());
        let structured_payload = ct.get("structured_payload").cloned();
        let failure_category = failure_category_stored.or_else(|| {
            if ok {
                return None;
            }
            error_code
                .as_deref()
                .map(failure_category_for_error_code)
                .map(|c| c.as_str().to_string())
        });
        Some(Self {
            envelope_version,
            name,
            summary,
            output,
            ok,
            exit_code,
            error_code,
            failure_category,
            retryable,
            tool_call_id,
            execution_mode,
            parallel_batch_id,
            output_truncated,
            output_original_chars,
            output_kept_head_chars,
            output_kept_tail_chars,
            structured_payload,
        })
    }

    /// 若 `content` 为 `{"crabmate_tool":{...}}` 则解析为归一化视图;否则 `None`(调用方走 legacy)。
    pub fn parse_tool_message_content(content: &str) -> Option<Self> {
        let t = content.trim();
        let v: Value = serde_json::from_str(t).ok()?;
        let ct = v.get("crabmate_tool")?.as_object()?;
        Self::from_crabmate_object(ct)
    }

    pub fn to_crabmate_tool_map(&self) -> Map<String, Value> {
        let mut ct = Map::new();
        ct.insert("v".into(), Value::from(self.envelope_version));
        ct.insert("name".into(), Value::String(self.name.clone()));
        ct.insert("summary".into(), Value::String(self.summary.clone()));
        ct.insert("ok".into(), Value::Bool(self.ok));
        ct.insert("output".into(), Value::String(self.output.clone()));
        insert_opt(&mut ct, "exit_code", &self.exit_code);
        insert_opt(&mut ct, "error_code", &self.error_code);
        insert_opt(&mut ct, "failure_category", &self.failure_category);
        insert_opt(&mut ct, "retryable", &self.retryable);
        insert_opt(&mut ct, "tool_call_id", &self.tool_call_id);
        insert_opt(&mut ct, "execution_mode", &self.execution_mode);
        insert_opt(&mut ct, "parallel_batch_id", &self.parallel_batch_id);
        if self.output_truncated {
            ct.insert("output_truncated".into(), Value::Bool(true));
        }
        insert_opt(&mut ct, "output_original_chars", &self.output_original_chars);
        insert_opt(&mut ct, "output_kept_head_chars", &self.output_kept_head_chars);
        insert_opt(&mut ct, "output_kept_tail_chars", &self.output_kept_tail_chars);
        insert_opt(&mut ct, "structured_payload", &self.structured_payload);
        ct
    }

    pub fn encode_to_message_line(&self) -> String {
        let mut root = Map::new();
        root.insert(
            "crabmate_tool".into(),
            Value::Object(self.to_crabmate_tool_map()),
        );
        serde_json::to_string(&Value::Object(root)).unwrap_or_else(|_| self.output.clone())
    }
}

/// 若 `content` 为 `{"crabmate_tool":{...}}` 则解析为归一化视图;否则 `None`。
pub fn normalize_tool_message_content(content: &str) -> Option<NormalizedToolEnvelope> {
    NormalizedToolEnvelope::parse_tool_message_content(content)
}

/// `Option` 有值时插入信封字段;无值时跳过(保持 `crabmate_tool` 不写空字段)。
fn insert_opt<T: Clone + Into<Value>>(ct: &mut Map<String, Value>, key: &str, v: &Option<T>) {
    if let Some(v) = v.as_ref() {
        ct.insert(key.to_string(), (*v).clone().into());
    }
}