crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! PER 终答规划:可选的极短二次 LLM 校验(计划 vs 最近工具结果摘要),默认关闭。

use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use tokio::sync::mpsc;

use crate::agent::plan_artifact;
use crate::config::AgentConfig;
use crate::llm::{
    ChatCompletionsBackend, CompleteChatRetryingParams, LlmRetryingTransportOpts,
    complete_chat_retrying, no_tools_chat_request,
};
use crate::types::{LlmSeedOverride, Message};

use crate::cm_agent::PlanSemanticLlmOutcome;

fn truncate_unicode(s: &str, max_chars: usize) -> String {
    let n = s.chars().count();
    if n <= max_chars {
        return s.to_string();
    }
    let mut out = s.chars().take(max_chars).collect::<String>();
    out.push_str("…(truncated)");
    out
}

const SIDE_SYSTEM_JSON_ONLY: &str = "你是 CrabMate 的**规划一致性审计员**。只根据给定的「最近工具结果摘要」与「模型输出的 agent_reply_plan JSON」判断是否明显矛盾(例如计划声称成功但工具报错、计划步骤与工具输出冲突)。\n\
你必须**只输出一个 JSON 对象**(不要 Markdown 代码围栏、不要前后缀说明)。字段要求:\n\
- 若**无法判断**或**无明显矛盾**:{\"consistent\":true}\n\
- 若**明确矛盾**:{\"consistent\":false,\"violation_codes\":[\"\"],\"rationale\":\"不超过 80 字的中文理由\"}\n\
其中 **violation_codes** 为字符串数组:1–8 个元素,每项仅含小写字母、数字、下划线,长度 1–64。建议码:`tool_outcome_contradiction`(与工具成功/失败状态冲突)、`plan_step_tool_mismatch`(步骤与工具输出明显不符)、`claim_not_supported_by_tools`(断言缺乏工具证据)、`semantic_mismatch_other`(其它明确矛盾)。";

const SIDE_SYSTEM_LEGACY_APPENDIX: &str = "\n**兼容**:若你只能输出纯文本,可在一行内写 INCONSISTENT 或 CONSISTENT(将按旧规则解析,但优先使用 JSON)。";

fn side_system_prompt(accept_legacy_text: bool) -> String {
    if accept_legacy_text {
        format!("{SIDE_SYSTEM_JSON_ONLY}{SIDE_SYSTEM_LEGACY_APPENDIX}")
    } else {
        SIDE_SYSTEM_JSON_ONLY.to_string()
    }
}

const MAX_VIOLATION_CODES: usize = 8;
const MAX_CODE_LEN: usize = 64;
const MAX_RATIONALE_CHARS: usize = 120;

fn is_valid_violation_code_token(s: &str) -> bool {
    let mut chars = s.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
        return false;
    }
    s.len() <= MAX_CODE_LEN
        && s.chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}

fn normalize_violation_codes(raw: &[serde_json::Value]) -> Vec<String> {
    let mut out = Vec::new();
    for v in raw {
        let Some(s) = v.as_str() else {
            continue;
        };
        let t = s.trim();
        if is_valid_violation_code_token(t) {
            out.push(t.to_string());
        }
        if out.len() >= MAX_VIOLATION_CODES {
            break;
        }
    }
    out.sort();
    out.dedup();
    out
}

fn truncate_rationale(s: &str) -> String {
    let mut t = s.trim().to_string();
    let n = t.chars().count();
    if n > MAX_RATIONALE_CHARS {
        t = t.chars().take(MAX_RATIONALE_CHARS).collect::<String>();
        t.push('');
    }
    t
}

fn outcome_from_json_value(v: &serde_json::Value) -> Option<PlanSemanticLlmOutcome> {
    let consistent = v.get("consistent")?.as_bool()?;
    let mut codes = v
        .get("violation_codes")
        .and_then(|x| x.as_array())
        .map(|a| normalize_violation_codes(a.as_slice()))
        .unwrap_or_default();
    let rationale = v
        .get("rationale")
        .and_then(|x| x.as_str())
        .map(truncate_rationale)
        .filter(|s| !s.is_empty());
    if !consistent && codes.is_empty() {
        codes.push("semantic_mismatch_unspecified".to_string());
    }
    Some(PlanSemanticLlmOutcome {
        consistent,
        violation_codes: codes,
        rationale,
        user_cancelled: false,
    })
}

fn extract_json_object_slice(s: &str) -> Option<&str> {
    let start = s.find('{')?;
    let end = s.rfind('}')?;
    (end > start).then_some(&s[start..=end])
}

/// 从侧向模型正文中解析 [`PlanSemanticLlmOutcome`];无法识别时 **fail-open**(视为一致)。
///
/// `accept_legacy_text`:为真时接受旧式单行 `CONSISTENT`/`INCONSISTENT`;默认配置为假(仅 JSON)。
pub(crate) fn parse_plan_semantic_side_reply(
    text: &str,
    accept_legacy_text: bool,
) -> PlanSemanticLlmOutcome {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return PlanSemanticLlmOutcome::consistent_ok();
    }

    if let Some(slice) = extract_json_object_slice(trimmed)
        && let Ok(v) = serde_json::from_str::<serde_json::Value>(slice)
        && let Some(o) = outcome_from_json_value(&v)
    {
        return o;
    }

    if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
        && let Some(o) = outcome_from_json_value(&v)
    {
        return o;
    }

    if accept_legacy_text {
        let upper = trimmed.to_uppercase();
        if upper.contains("INCONSISTENT") {
            log::debug!(
                target: "crabmate::per",
                "final_plan_semantic_check parse_path=legacy_text outcome=inconsistent"
            );
            return PlanSemanticLlmOutcome {
                consistent: false,
                violation_codes: vec!["semantic_mismatch_legacy".to_string()],
                rationale: None,
                user_cancelled: false,
            };
        }
        if upper.contains("CONSISTENT") {
            log::debug!(
                target: "crabmate::per",
                "final_plan_semantic_check parse_path=legacy_text outcome=consistent"
            );
            return PlanSemanticLlmOutcome::consistent_ok();
        }
    }

    PlanSemanticLlmOutcome::consistent_ok()
}

/// 侧向 `complete_chat_retrying` 的入参(避免单函数参数过多)。
pub(crate) struct PlanSemanticLlmCtx<'a> {
    pub llm_backend: &'a (dyn ChatCompletionsBackend + Send + Sync),
    pub client: &'a reqwest::Client,
    pub api_key: &'a str,
    pub cfg: &'a AgentConfig,
    pub out: Option<&'a mpsc::Sender<String>>,
    pub no_stream: bool,
    pub cancel: Option<&'a AtomicBool>,
    pub request_chrome_trace: Option<Arc<crate::request_chrome_trace::RequestTurnTrace>>,
    pub temperature_override: Option<f32>,
    pub model_override: Option<String>,
    pub seed_override: LlmSeedOverride,
    pub max_tokens: u32,
    pub turn_budget: Option<&'a std::sync::Arc<crate::agent::turn_budget::TurnBudgetCounter>>,
}

/// 对 `plan_json` 与 `tool_digest` 做一次无工具侧向调用;`tool_digest` 为空时跳过并视为通过。
/// 解析失败或 API 失败时 **fail-open**(返回 `consistent: true`),避免阻断主循环。
pub(crate) async fn evaluate_plan_consistency_with_recent_tools_llm(
    ctx: PlanSemanticLlmCtx<'_>,
    plan_json: &str,
    tool_digest: Option<&str>,
) -> PlanSemanticLlmOutcome {
    let Some(digest) = tool_digest.map(str::trim).filter(|s| !s.is_empty()) else {
        return PlanSemanticLlmOutcome::consistent_ok();
    };
    let plan_trim = plan_json.trim();
    if plan_trim.is_empty() {
        return PlanSemanticLlmOutcome::consistent_ok();
    }

    let accept_legacy = ctx
        .cfg
        .per_plan_policy
        .final_plan_semantic_check_accept_legacy_text;
    let user_body = format!(
        "### 最近工具结果摘要(截断)\n{}\n\n### agent_reply_plan JSON\n{}",
        truncate_unicode(digest, 6000),
        truncate_unicode(plan_trim, 8000)
    );
    let side_messages = vec![
        Message::system_only(side_system_prompt(accept_legacy)),
        Message::user_only(user_body),
    ];
    let model_override = ctx
        .model_override
        .as_deref()
        .or(ctx.cfg.llm.planner_model.as_deref());
    let llm_cfg = crate::cm_types::llm_config::LlmConfig {
        llm: ctx.cfg.llm.clone(),
        sampling: ctx.cfg.llm_sampling.clone(),
        vendor_flags: ctx.cfg.llm_vendor_flags.clone(),
        http_retry: ctx.cfg.llm_http_retry.clone(),
    };
    let mut req = no_tools_chat_request(
        &llm_cfg,
        &side_messages,
        ctx.temperature_override,
        model_override,
        ctx.seed_override,
    );
    req.max_tokens = ctx.max_tokens.clamp(32, 1024);

    let cc = CompleteChatRetryingParams::new(
        ctx.llm_backend,
        ctx.client,
        ctx.api_key,
        ctx.cfg,
        LlmRetryingTransportOpts {
            out: ctx.out,
            no_stream: ctx.no_stream,
            cancel: ctx.cancel,
        },
        ctx.request_chrome_trace,
        model_override,
    )
    .with_turn_budget(ctx.turn_budget);

    let (reply, finish) = match complete_chat_retrying(&cc, &req).await {
        Ok(x) => x,
        Err(e) => {
            log::warn!(
                target: "crabmate::per",
                "final_plan_semantic_check llm_call_failed error={} (fail-open)",
                e
            );
            return PlanSemanticLlmOutcome::consistent_ok();
        }
    };
    if finish == crate::types::USER_CANCELLED_FINISH_REASON {
        return PlanSemanticLlmOutcome::user_cancelled();
    }
    let text = crate::types::message_content_as_str(&reply.content)
        .unwrap_or("")
        .trim();
    let outcome = parse_plan_semantic_side_reply(text, accept_legacy);
    if outcome.consistent {
        log::debug!(target: "crabmate::per", "final_plan_semantic_check outcome=consistent");
    } else {
        log::info!(
            target: "crabmate::per",
            "final_plan_semantic_check outcome=inconsistent codes={:?} preview={}",
            outcome.violation_codes,
            crate::redact::preview_chars(text, 120)
        );
    }
    outcome
}

/// 将合法 v1 规划压成单行 JSON,供侧向调用与日志(失败时返回 `{}`)。
pub(crate) fn agent_reply_plan_json_compact(plan: &plan_artifact::AgentReplyPlanV1) -> String {
    plan_artifact::agent_reply_plan_v1_to_json_string(plan).unwrap_or_else(|_| "{}".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn user_cancelled_outcome_flag() {
        let o = PlanSemanticLlmOutcome::user_cancelled();
        assert!(o.user_cancelled);
        assert!(!o.consistent);
    }

    #[test]
    fn parse_json_consistent_minimal() {
        let o = parse_plan_semantic_side_reply(r#"{"consistent":true}"#, false);
        assert!(o.consistent);
        assert!(o.violation_codes.is_empty());
        assert!(o.rationale.is_none());
    }

    #[test]
    fn parse_json_inconsistent_with_codes() {
        let o = parse_plan_semantic_side_reply(
            r#"{"consistent":false,"violation_codes":["tool_outcome_contradiction"],"rationale":"工具报错但计划写成功"}"#,
            false,
        );
        assert!(!o.consistent);
        assert_eq!(
            o.violation_codes,
            vec!["tool_outcome_contradiction".to_string()]
        );
        assert!(o.rationale.is_some());
    }

    #[test]
    fn parse_json_inconsistent_empty_codes_gets_fallback() {
        let o = parse_plan_semantic_side_reply(r#"{"consistent":false}"#, false);
        assert!(!o.consistent);
        assert_eq!(
            o.violation_codes,
            vec!["semantic_mismatch_unspecified".to_string()]
        );
    }

    #[test]
    fn parse_embedded_json_object() {
        let o = parse_plan_semantic_side_reply(
            "here is {\"consistent\":false,\"violation_codes\":[\"plan_step_tool_mismatch\"]}",
            false,
        );
        assert!(!o.consistent);
        assert_eq!(
            o.violation_codes,
            vec!["plan_step_tool_mismatch".to_string()]
        );
    }

    #[test]
    fn parse_legacy_inconsistent_line_when_enabled() {
        let o = parse_plan_semantic_side_reply("INCONSISTENT 与工具结果矛盾", true);
        assert!(!o.consistent);
        assert_eq!(
            o.violation_codes,
            vec!["semantic_mismatch_legacy".to_string()]
        );
    }

    #[test]
    fn parse_legacy_consistent_line_when_enabled() {
        let o = parse_plan_semantic_side_reply("CONSISTENT", true);
        assert!(o.consistent);
    }

    #[test]
    fn parse_legacy_text_ignored_by_default() {
        let o = parse_plan_semantic_side_reply("INCONSISTENT 与工具结果矛盾", false);
        assert!(
            o.consistent,
            "default JSON-only: legacy INCONSISTENT must fail-open"
        );
        let o2 = parse_plan_semantic_side_reply("CONSISTENT", false);
        assert!(o2.consistent);
    }

    #[test]
    fn parse_invalid_code_tokens_dropped() {
        let o = parse_plan_semantic_side_reply(
            r#"{"consistent":false,"violation_codes":["ok_code","Bad-Code","also bad"]}"#,
            false,
        );
        assert!(!o.consistent);
        assert_eq!(o.violation_codes, vec!["ok_code".to_string()]);
    }
}