Files
agcore/design/pdd/30-builtin-tools-injection-fix.md
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

11 KiB
Raw Permalink Blame History

Builtin Tools 注入修复方案

背景与目标

问题描述

agcore 的 OpenaiResponseProvider 在通过 extra 逃生舱注入内置工具(web_search / file_search)时存在两层缺陷,导致 builtin_tools 完全不生效:

  1. 分支逻辑错位convert_request,第 615-640 行):builtin_tools 注入代码被嵌套在 tools_defs 非空的 else 分支内。当调用方只提供 builtin_tools 而不提供 tools_defs 时,分支走 if tools_defs.is_empty() { None },注入代码完全不执行。

  2. 枚举不完整ResponseTool,第 136-146 行):枚举只有 Function 一种变体,非 function 类型的 builtin 工具(如 type: "web_search")反序列化失败,退化为 name: "" 的空函数定义,API 层面被拒绝。

目标

  • 修复 builtin_tools 注入逻辑,使纯内置工具、混用场景均正常工作
  • 不破坏现有 tools_defs 功能
  • 添加回归测试

需求分析

功能需求

# 需求 优先级
F1 tools_defs 为空、builtin_tools 非空时,正确注入内置工具 P0
F2 tools_defsbuiltin_tools 同时非空时,合并注入 P0
F3 两端均为空时,tools 字段为 None(回归保底) P0
F4 无效的 builtin_tools 值不导致崩溃,跳过并告警 P1

非功能需求

  • 不做底层架构改造(extra 逃生舱机制不变)
  • 不改 openai.rsChat Completions API 不支持内置工具)

方案设计

总体架构

修复分三步,对应三层独立但不相互依赖的改动:

┌─────────────────────────────────────────────────┐
│               convert_request()                  │
│                                                   │
│  [改动二] 重构分支逻辑                           │
│  ┌─────────────────────────────────────────┐     │
│  │ tools_defs ──→ 生成 Vec<ResponseTool>   │     │
│  │ builtin_tools ──→ 追加到同一 Vec        │     │
│  │ 两者都空 ──→ None; 否则 ──→ Some(items) │     │
│  └─────────────────────────────────────────┘     │
│                                                   │
│  [改动三] 错误处理                              │
│  unwrap_or_else ──→ match + warn!                │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ResponseTool 枚举                               │
│                                                   │
│  [改动一] 添加 Builtin(Value) 变体              │
│  自定义 Serialize/Deserialize 避免信息丢失       │
└─────────────────────────────────────────────────┘

改动一:扩展 ResponseTool 枚举

位置:第 136-146 行

现状

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
    #[serde(rename = "function")]
    Function {
        name: String,
        description: String,
        parameters: Value,
    },
}

改后(需要自定义 Serialize/Deserialize):

/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
#[derive(Debug, Clone)]
pub(crate) enum ResponseTool {
    Function {
        name: String,
        description: String,
        parameters: Value,
    },
    /// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
    /// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
    Builtin(Value),
}

impl Serialize for ResponseTool {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        match self {
            ResponseTool::Function { name, description, parameters } => {
                let mut map = serde_json::Map::new();
                map.insert("type".into(), Value::String("function".into()));
                map.insert("name".into(), Value::String(name.clone()));
                map.insert("description".into(), Value::String(description.clone()));
                map.insert("parameters".into(), parameters.clone());
                map.serialize(serializer)
            }
            ResponseTool::Builtin(value) => value.serialize(serializer),
        }
    }
}

impl<'de> Deserialize<'de> for ResponseTool {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let value = Value::deserialize(deserializer)?;
        match value.get("type").and_then(|t| t.as_str()) {
            Some("function") => {
                let name = value.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
                let description = value.get("description").and_then(|d| d.as_str()).unwrap_or_default().to_string();
                let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
                Ok(ResponseTool::Function { name, description, parameters })
            }
            _ => Ok(ResponseTool::Builtin(value)),
        }
    }
}

关键点:

  • 自定义 SerializeBuiltin 变体直接输出原始 Value(不包裹额外标记)
  • 自定义 Deserialize:非 "function" 类型自动走 Builtin(Value) 分支
  • 保留原始 JSON 结构,避免信息丢失(如 search_context_sizeuser_location 等字段)

改动二:修复 convert_request 分支逻辑

位置:第 615-640 行

现状(伪代码):

if tools_defs.is_empty() {
    None                              // ← builtin_tools 被完全跳过
} else {
    从 tools_defs 生成 Vec<ResponseTool>
    if let Some(builtin_tools) {
        for v in extra {
            items.push(from_value(v)) // ← 只有进了 else 才执行
        }
    }
    Some(items)
}

改后(伪代码):

let mut items: Vec<ResponseTool> = Vec::new();

// 1. 始终处理 tools_defs
items.extend(tools_defs.into_iter().map(|t| ResponseTool::Function { ... }));

// 2. 始终处理 builtin_tools(与 tools_defs 解耦)
if let Some(builtin_tools) = builtin_tools {
    for v in extra {
        // 见改动三
        items.push(serde_json::from_value(v).unwrap_or_else(|_| { ... }));
    }
}

// 3. 两者都空 → None;否则 → Some
if items.is_empty() { None } else { Some(items) }

改动三:改进错误处理

位置:第 630-636 行(unwrap_or_else 部分)

现状

items.push(serde_json::from_value(v).unwrap_or_else(|_| {
    ResponseTool::Function {
        name: String::new(),
        description: String::new(),
        parameters: Value::Null,
    }
}));

改后

match serde_json::from_value(v.clone()) {
    Ok(tool) => items.push(tool),
    Err(e) => {
        let raw = serde_json::to_string(&v).unwrap_or_default();
        warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
    }
}

warn! 输出被反序列化的 Value 摘要,便于生产排障时定位问题(无需复现调用方输入)。

unwrap_or_else 的错误值(空函数定义)在 OpenAI API 层会被拒绝,无实际价值。替换为 match + warn! 可明确跳过并记录原因。

改动四:添加测试

在文件末尾 #[cfg(test)] 区域或 tests/ 目录新增 6 个测试用例:

# 用例名 场景 验证点
T1 test_builtin_only 仅提供 builtin_tools tools 为 Some,含正确 type
T2 test_mixed_tools 同时提供 tools_defs + builtin_tools 合并后 items 顺序/数量正确
T3 test_no_tools 两端均为空 tools 为 None
T4 test_invalid_builtin builtin_tools 含无效 JSON 不崩溃,有效项保留
T5 test_function_wire_format ResponseTool::Function 序列化 JSON 结构与改动前一致(AC7
T6 test_builtin_roundtrip ResponseTool::Builtin 反序列化+序列化 原始 JSON 结构保留

实现计划

步骤

步骤 改动 文件 估算
1 扩展 ResponseTool 枚举,添加 Builtin(Value) + 自定义 Serialize/Deserialize openai_response.rs:136-146 40 行
2 重构 convert_request 分支逻辑,解耦 tools_defs 与 builtin_tools openai_response.rs:615-640 15 行
3 替换 unwrap_or_elsematch + warn! openai_response.rs:630-636 5 行
4 添加 6 个测试用例 openai_response.rs 末尾 70 行
5 cargo test 验证全部通过 - -

优先级

P0(核心修复):步骤 1 + 2,修复分支逻辑和枚举不完整问题。 P1(健壮性):步骤 3,改进错误处理。 P1(质量保障):步骤 4 + 5,测试覆盖。

依赖关系

无外部依赖。全部改动限定在 openai_response.rs 一个文件内。

风险评估

风险 概率 影响 缓解措施
自定义 Serialize/Deserialize 实现遗漏边界情况 测试覆盖所有分支:Function / Builtin / 无效值
现有 Function 序列化格式变化 自定义 Serialize 保持与原 derive 行为一致,测试覆盖 wire 格式
warn! 日志在生产环境未配置 logger 导致 panic 使用 tracing::warn!(已导入),项目已初始化 tracing logger

回滚方案

单文件改动,回滚只需 git checkout -- src/llm/provider/openai_response.rs

验收标准

# 验收条件 验证方式
AC1 tools_defs 空 + builtin_tools{"type":"web_search"} → 请求体 tools 包含 {"type":"web_search"} 单测 T1
AC2 混用场景 → tools 数组同时包含 function 和非 function 工具 单测 T2
AC3 两端空 → tools 字段为 null/None 单测 T3
AC4 无效 builtin_tools → 不 panic,有效项不受影响 单测 T4
AC5 全部现有测试通过 cargo test
AC6 cargo clippy 无新增警告 cargo clippy
AC7 ResponseTool::Function 序列化后的 JSON 结构与改动前一致 单测:验证字段顺序和值

编写人Writer Agent
编写日期2026-07-20
基于agcore builtin_tools 注入问题分析结论