Skip to content

ToolSpec与函数规格

从工具定义到 Responses 请求,解释 ToolDefinition、ResponsesApiTool、ToolSpec、namespace 与延迟工具之间的边界。

基于rust-v0.150.0
CodexRustToolsResponses API

ToolSpec与函数规格 ​

一个工具能被模型调用,至少要经过两次转换:运行时先提供一个可执行对象和它的模型规格,客户端再把规格 序列化成 Responses 请求中的 tools。因此,ToolDefinition、ResponsesApiTool 和 ToolSpec 不是三个 重复的名字,而是三个不同的所有权层级。

本文面向已经了解 StepContext 会持有工具 Router 的读者。建议先读 工具系统架构总览,它解释 registry、Router 和模型可见规格的关系;如果还 不熟悉请求构造,可先读模型请求构造。本文只解释规格对象如何建模、 转换和序列化,不展开 JSON Schema 的递归清洗算法,也不讲某一个工具 handler 的业务语义。读完后,读者应能 从一个 handler 的 spec() 找到最终 JSON,判断一个字段是在运行时使用、对模型公开,还是只用于延迟发现。

1. 三层定义 ​

先把三个容易混淆的对象放在同一条线上:

层级对象所有者主要消费者是否直接发给模型
定义层ToolDefinitioncodex-tools 的转换函数MCP、dynamic tool 适配器否
Responses 函数层ResponsesApiToolresponses_api.rsToolSpec、namespace是,作为函数工具内容
顶层规格层ToolSpectools crate 与 Core 计划Prompt.tools、搜索和请求构造是,经过序列化后发送

ToolDefinition 只描述名称、说明、输入 schema、可选输出 schema 和是否延迟加载。它不携带 Responses API 的 type 标签,也不决定工具属于哪个 namespace。tool_definition_to_responses_api_tool 把它投影为函数工具, 并把 defer_loading: true 转成可选的 defer_loading 字段。

相关源码:

  • codex-rs/tools/src/tool_definition.rs :: ToolDefinition
  • codex-rs/tools/src/tool_definition.rs :: ToolDefinition::into_deferred
rust
pub struct ToolDefinition {
    pub name: String,
    pub description: String,
    pub input_schema: JsonSchema,
    pub output_schema: Option<JsonValue>,
    pub defer_loading: bool,
}

impl ToolDefinition {
    pub fn renamed(mut self, name: String) -> Self {
        self.name = name;
        self
    }

    pub fn into_deferred(mut self) -> Self {
        self.output_schema = None;
        self.defer_loading = true;
        self
    }
}

into_deferred 同时做两件事:标记工具需要后续发现,并丢弃输出 schema。代码没有在这里解释丢弃原因; 能够直接确认的是,延迟表示不再携带该本地元数据,而真正执行仍由 runtime 和结果类型负责。

图中的箭头不是简单的类型转换:ToolDefinition 是跨来源的中间表示,ResponsesApiTool 是 Responses 函数 形状,ToolSpec 才能容纳函数、namespace、tool search、web search 和 custom 等不同顶层变体。

2. 函数规格 ​

2.1 字段所有权 ​

ResponsesApiTool 由四类信息组成:模型识别工具所需的名称和说明,输入约束 parameters,函数调用模式 strict,以及可选的 defer_loading。output_schema 虽然位于 Rust 结构体中,却标记为 #[serde(skip)], 它是本地工具结果适配可以使用的元数据,不会随函数规格直接序列化。

源码位置:codex-rs/tools/src/responses_api.rs :: ResponsesApiTool

rust
pub struct ResponsesApiTool {
    pub name: String,
    pub description: String,
    pub strict: bool,
    pub defer_loading: Option<bool>,
    pub parameters: JsonSchema,
    #[serde(skip)]
    pub output_schema: Option<Value>,
}

当前 Core 内置函数工具通常将 strict 设为 false。这不是说输入永远不校验,而是表示工具规格本身不要求 Responses API 按严格结构化输出模式校验;Registry 仍会在收到调用后检查 payload 类型,handler 还可以继续 做业务级验证。只有部分 extension 规格显式使用 strict: true,因此不能把该字段概括成“所有工具都严格”或 “所有工具都不严格”。

2.2 顶层变体 ​

ToolSpec 使用 #[serde(tag = "type")],枚举变体名决定 wire JSON 的顶层 type。当前源码中的变体如下:

源码位置:codex-rs/tools/src/tool_spec.rs :: ToolSpec

rust
pub enum ToolSpec {
    #[serde(rename = "function")]
    Function(ResponsesApiTool),
    #[serde(rename = "namespace")]
    Namespace(ResponsesApiNamespace),
    #[serde(rename = "tool_search")]
    ToolSearch {
        execution: String,
        description: String,
        parameters: JsonSchema,
    },
    #[serde(rename = "web_search")]
    WebSearch {
        external_web_access: Option<bool>,
        indexed_web_access: Option<bool>,
        filters: Option<ResponsesApiWebSearchFilters>,
        user_location: Option<ResponsesApiWebSearchUserLocation>,
        search_context_size: Option<WebSearchContextSize>,
        search_content_types: Option<Vec<String>>,
    },
    #[serde(rename = "custom")]
    Freeform(FreeformTool),
}

ToolSpec::Function 与 ToolSpec::Freeform 都描述一个可调用工具,但协议形状不同:前者的输入是 JSON 参数, 后者的输入由 format 中的语法定义。ToolSpec::Namespace 不是一个 runtime,它是多个函数或 custom 工具的 容器;ToolSpec::ToolSearch 和 ToolSpec::WebSearch 也不是普通 Registry handler 的函数规格,它们分别由 工具发现机制和 provider hosted 能力消费。

ToolSpec::name() 只提供统一的索引名称:函数和 namespace 取结构体名称,tool search、web search 使用固定 名称,custom 使用 FreeformTool.name。它不负责判断工具是否已注册,也不负责处理 namespace 内部的子工具名; 这些工作属于 Router 和 Registry。

3. 生成路径 ​

3.1 内置工具 ​

Core handler 把运行时实现和规格放在同一实现族中。以 update_plan 为例,spec 函数构造的是一个函数工具, 参数 schema 由 JsonSchema 构造器组成,真正的计划状态更新仍由另一个 handler 消费这些参数。

源码位置:codex-rs/core/src/tools/handlers/plan_spec.rs :: create_update_plan_tool

rust
pub fn create_update_plan_tool() -> ToolSpec {
    let plan_item_properties = BTreeMap::from([
        (
            "step".to_string(),
            JsonSchema::string(Some("Task step text.".to_string())),
        ),
        (
            "status".to_string(),
            JsonSchema::string_enum(
                vec![json!("pending"), json!("in_progress"), json!("completed")],
                Some("Step status.".to_string()),
            ),
        ),
    ]);

    let properties = BTreeMap::from([
        (
            "explanation".to_string(),
            JsonSchema::string(Some(
                "Optional explanation for this plan update.".to_string(),
            )),
        ),
        (
            "plan".to_string(),
            JsonSchema::array(
                JsonSchema::object(
                    plan_item_properties,
                    Some(vec!["step".to_string(), "status".to_string()]),
                    Some(false.into()),
                ),
                Some("The list of steps".to_string()),
            ),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "update_plan".to_string(),
        description: r#"Updates the task plan.
Provide an optional explanation and a list of plan items, each with a step and status.
At most one step can be in_progress at a time.
"#
        .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["plan".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}

这里有一个重要边界:description 中的“最多一个 in_progress”是给模型的行为约束,不等同于 Rust 类型系统 或 JSON Schema 的完整不变量。真正的计划更新逻辑还要在工具 handler 中执行;只阅读这个 spec 函数,不能推出 所有非法状态都会被拒绝。

3.2 外部定义 ​

MCP 和 dynamic tool 不是直接手写 ResponsesApiTool。它们先变成 ToolDefinition,再经过统一转换函数。 这使名称改写、延迟标记和 schema 解析集中在一个边界上。

相关源码:

  • codex-rs/tools/src/responses_api.rs :: tool_definition_to_responses_api_tool
  • codex-rs/tools/src/dynamic_tool.rs :: parse_dynamic_tool
rust
pub fn parse_dynamic_tool(
    tool: &DynamicToolFunctionSpec,
) -> Result<ToolDefinition, serde_json::Error> {
    Ok(ToolDefinition {
        name: tool.name.clone(),
        description: tool.description.clone(),
        input_schema: parse_tool_input_schema(&tool.input_schema)?,
        output_schema: None,
        defer_loading: tool.defer_loading,
    })
}

pub fn dynamic_tool_to_responses_api_tool(
    tool: &DynamicToolFunctionSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
    Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool(
        tool,
    )?))
}

pub fn tool_definition_to_responses_api_tool(
    tool_definition: ToolDefinition,
) -> ResponsesApiTool {
    ResponsesApiTool {
        name: tool_definition.name,
        description: tool_definition.description,
        strict: false,
        defer_loading: tool_definition.defer_loading.then_some(true),
        parameters: tool_definition.input_schema,
        output_schema: tool_definition.output_schema,
    }
}

parse_dynamic_tool 的 ? 是一个真实失败边界:外部输入 schema 不能被当前 JsonSchema 表示时,转换函数会 返回错误。Core 的 dynamic handler 在装配时把这个错误转换为 None,记录错误并跳过当前工具;它不会让已经成功 注册的其他 dynamic tools 一起消失。后续 JSON Schema 专题会解释 schema 如何清洗、压缩和保留 $ref;本文只需要 记住,错误发生在工具进入 Registry 之前。

源码位置:codex-rs/core/src/tools/handlers/dynamic.rs :: DynamicToolHandler::from_parts

rust
let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
// Exposure controls deferral; tool search restores this marker for deferred results.
output_tool.defer_loading = None;
let spec = match namespace {
    Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
        name: namespace.name.clone(),
        description: if namespace.description.trim().is_empty() {
            default_namespace_description(&namespace.name)
        } else {
            namespace.description.clone()
        },
        tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
    }),
    None => ToolSpec::Function(output_tool),
};
Some(Self {
    tool_name,
    spec,
    exposure: if tool.defer_loading {
        ToolExposure::Deferred
    } else {
        ToolExposure::Direct
    },
})

源码位置:codex-rs/core/src/tools/spec_plan.rs :: append_dynamic_tool_runtimes

rust
let Some(handler) = DynamicToolHandler::new(tool) else {
    tracing::error!("Failed to convert dynamic tool {:?} to OpenAI tool", tool.name);
    continue;
};
registry.register_external(Arc::new(handler));

3.3 请求消费者 ​

规格完成后不会直接从 ToolSpec 调用模型。build_prompt 把 Router 已经筛选的规格复制到 Prompt.tools; 后续 build_responses_request 再根据 provider 是否使用 Responses Lite 选择不同的序列化函数。

相关源码:

  • codex-rs/core/src/session/turn.rs :: build_prompt
  • codex-rs/core/src/client_common.rs :: Prompt
rust
pub(crate) fn build_prompt(
    input: Vec<ResponseItem>,
    router: &ToolRouter,
    turn_context: &TurnContext,
    base_instructions: BaseInstructions,
) -> Prompt {
    Prompt {
        input,
        tools: router.model_visible_specs(),
        parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
        base_instructions,
        output_schema: turn_context.final_output_json_schema.clone(),
        output_schema_strict: !crate::guardian::is_guardian_reviewer_source(
            &turn_context.session_source,
        ),
    }
}

Prompt.tools 是请求快照,而不是 Registry 的借用。它的构造时机在 Step 已经确定模型可见面之后;因此同一 个 runtime 可以继续存在于 Registry 中,但没有出现在这次 Prompt 里。parallel_tool_calls 也是 Prompt 的 请求级字段,不能从某个 ResponsesApiTool.strict 字段推导出来。

4. 顶层变体 ​

4.1 函数与命名空间 ​

Responses API 支持顶层 function,也支持 namespace 容器。ResponsesApiNamespaceTool 允许 namespace 同时包含 JSON function 和 custom tool,因此 namespace 不是“只给 MCP 用”的专用结构。

相关源码:

  • codex-rs/tools/src/responses_api.rs :: ResponsesApiNamespace
  • codex-rs/tools/src/responses_api.rs :: ResponsesApiNamespaceTool
rust
pub struct ResponsesApiNamespace {
    pub name: String,
    pub description: String,
    pub tools: Vec<ResponsesApiNamespaceTool>,
}

pub enum ResponsesApiNamespaceTool {
    #[serde(rename = "function")]
    Function(ResponsesApiTool),
    #[serde(rename = "custom")]
    Custom(FreeformTool),
}

当 MCP server 的工具以 namespace 进入模型时,namespace 名称承担路由分组作用,子工具仍保持自己的名称。Router 后续会把响应中的 namespace/name 组合成 ToolName;本篇只解释 wire 规格,不把 namespace 名称当作 handler 查找算法。

4.2 Custom工具 ​

FreeformTool 不接受 JSON 对象参数,而是把语法放在 format 中。当前 apply_patch 就是这一变体:模型生成 的是符合 Lark grammar 的文本,Core 的 custom call 路径再把文本交给 patch parser。

源码位置:codex-rs/core/src/tools/handlers/apply_patch_spec.rs :: create_apply_patch_freeform_tool

rust
pub fn create_apply_patch_freeform_tool(include_environment_id: bool) -> ToolSpec {
    let definition = if include_environment_id {
        APPLY_PATCH_LARK_GRAMMAR.replace(
            "start: begin_patch hunk+ end_patch",
            "start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID: \" filename LF",
        )
    } else {
        APPLY_PATCH_LARK_GRAMMAR.to_string()
    };
    ToolSpec::Freeform(FreeformTool {
        name: "apply_patch".to_string(),
        description: "The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(),
        defer_loading: None,
        format: FreeformToolFormat {
            r#type: "grammar".to_string(),
            syntax: "lark".to_string(),
            definition,
        },
    })
}

include_environment_id 改变的是 grammar definition,不是 ToolSpec 的变体。也就是说,同一个 custom 工具 可以因为调用环境不同而拥有不同的输入语法;不能仅凭名称判断它是否接受环境 ID。

4.3 Hosted变体 ​

WebSearch 的规格由 provider 能力和 WebSearchMode 共同决定。Cached、Indexed 和 Live 会映射为不同 的 external_web_access 与 indexed_web_access 组合;禁用或没有模式时返回 None,不会生成一个“禁用状态的 web_search 工具”。

源码位置:codex-rs/core/src/tools/hosted_spec.rs :: create_web_search_tool

rust
let (external_web_access, indexed_web_access) = match options.web_search_mode {
    Some(WebSearchMode::Cached) => (false, None),
    Some(WebSearchMode::Indexed) => (true, Some(true)),
    Some(WebSearchMode::Live) => (true, None),
    Some(WebSearchMode::Disabled) | None => return None,
};

Some(ToolSpec::WebSearch {
    external_web_access: Some(external_web_access),
    indexed_web_access,
    filters: options
        .web_search_config
        .and_then(|config| config.filters.clone().map(Into::into)),
    user_location: options
        .web_search_config
        .and_then(|config| config.user_location.clone().map(Into::into)),
    search_context_size: options
        .web_search_config
        .and_then(|config| config.search_context_size),
    search_content_types,
})

这个分支说明 hosted tool 与普通函数的边界:它没有 ResponsesApiTool.parameters,也不要求本地 Registry 为 web_search 提供一个普通 handler。provider 负责消费它的 hosted wire shape;Core 的 ToolRouter 只负责决定 是否把它放进可见规格集合。

5. Wire形状 ​

5.1 普通请求 ​

create_tools_json_for_responses_api 对每个 ToolSpec 调用 serde_json::to_value,保留顶层变体和函数名称。 create_tools_raw_json_for_responses_api 则直接产生 RawValue,供请求结构嵌入,二者表达相同的 JSON 数组。

相关源码:

  • codex-rs/tools/src/tool_spec.rs :: create_tools_json_for_responses_api
  • codex-rs/tools/src/tool_spec.rs :: create_tools_raw_json_for_responses_api
rust
pub fn create_tools_json_for_responses_api(
    tools: &[ToolSpec],
) -> Result<Vec<Value>, serde_json::Error> {
    let mut tools_json = Vec::new();

    for tool in tools {
        let json = serde_json::to_value(tool)?;
        tools_json.push(json);
    }

    Ok(tools_json)
}

pub fn create_tools_raw_json_for_responses_api(
    tools: &[ToolSpec],
) -> Result<Arc<RawValue>, serde_json::Error> {
    serde_json::value::to_raw_value(tools).map(Arc::from)
}

output_schema 没有出现在这个 JSON 中,是因为它位于 ResponsesApiTool 的 #[serde(skip)] 字段。这个设计让本地 结果适配可以保留结构化信息,却不会把未被 Responses tool wire 协议声明的字段误发给 provider。

5.2 Lite请求 ​

Responses Lite 不能简单地把普通请求 JSON 换一个函数名。create_tools_json_for_responses_lite 会把顶层 Function、Freeform 和默认 functions namespace 合并到一个 namespace 中;其他变体保持顶层位置和原顺序。

源码位置:codex-rs/tools/src/tool_spec.rs :: create_tools_json_for_responses_lite

rust
for tool in tools {
    match tool {
        ToolSpec::Function(tool) => {
            functions
                .tools
                .push(ResponsesApiNamespaceTool::Function(tool.clone()));
        }
        ToolSpec::Freeform(tool) => {
            functions
                .tools
                .push(ResponsesApiNamespaceTool::Custom(tool.clone()));
        }
        ToolSpec::Namespace(namespace) if namespace.name == DEFAULT_FUNCTION_NAMESPACE => {
            if !namespace.description.trim().is_empty() {
                functions.description = namespace.description.clone();
            }
            functions.tools.extend(namespace.tools.clone());
        }
        tool => {
            tools_json.push(serde_json::to_value(tool)?);
            continue;
        }
    }
    functions_index.get_or_insert(tools_json.len());
}

if let Some(functions_index) = functions_index
    && !functions.tools.is_empty()
{
    tools_json.insert(
        functions_index,
        serde_json::to_value(ToolSpec::Namespace(functions))?,
    );
}

functions_index 记录第一个可合并工具在结果数组中的位置。这样 tool_search 等非函数工具先出现时,合并后的 默认 namespace 仍插入到第一个函数工具原本所在的位置,而不是无条件追加到末尾。若没有任何 function、custom 或非空默认 namespace,函数容器不会被创建。

因此,“模型看到的工具名称”必须结合 provider 路径解释。普通 Responses 请求可以有多个顶层 function;Lite 请求 可能把它们投影成 functions namespace。这个投影不改变 Registry 的真实名称,也不改变后续 Router 对响应 namespace 的解析职责。

6. 延迟发现 ​

6.1 搜索条目 ​

延迟工具需要同时提供搜索文本和可加载规格。ToolSearchInfo::from_tool_spec 把函数或 custom 工具包装进默认 functions namespace,把 defer_loading 设为 Some(true);函数工具的 output_schema 同样被清除。namespace 变体则保留原 namespace,并逐个修改子工具。

源码位置:codex-rs/tools/src/tool_search.rs :: ToolSearchInfo::from_spec

rust
let output = match spec {
    ToolSpec::Function(mut tool) => {
        tool.defer_loading = Some(true);
        tool.output_schema = None;
        LoadableToolSpec::Namespace(ResponsesApiNamespace {
            name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
            description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
            tools: vec![ResponsesApiNamespaceTool::Function(tool)],
        })
    }
    ToolSpec::Freeform(mut tool) => {
        tool.defer_loading = Some(true);
        LoadableToolSpec::Namespace(ResponsesApiNamespace {
            name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
            description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
            tools: vec![ResponsesApiNamespaceTool::Custom(tool)],
        })
    }
    ToolSpec::Namespace(mut namespace) => {
        if namespace.description.trim().is_empty() {
            namespace.description = default_namespace_description(&namespace.name);
        }
        for tool in &mut namespace.tools {
            match tool {
                ResponsesApiNamespaceTool::Function(tool) => {
                    tool.defer_loading = Some(true);
                    tool.output_schema = None;
                }
                ResponsesApiNamespaceTool::Custom(tool) => {
                    tool.defer_loading = Some(true);
                }
            }
        }
        LoadableToolSpec::Namespace(namespace)
    }
    ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => return None,
};

这里的 LoadableToolSpec 只有 Function 和 Namespace 两种变体,因为搜索结果描述的是“稍后可加载的函数/自定义 工具”,而不是再次搜索 web 或再次搜索 tool search。搜索结果被选中后,SpecPlan 才把它重新并入下一次请求的 模型可见规格。

6.2 搜索文本 ​

搜索文本不是把整个 JSON schema 转成字符串。default_tool_search_text 只抽取工具名称、名称的空格变体、描述 以及参数属性的描述;namespace 还会贡献 namespace 名称和 namespace 说明。这解释了为什么参数字段命名和 描述会影响工具搜索,而 output_schema 不会影响搜索文本。

相关源码:

  • codex-rs/tools/src/tool_search.rs :: default_tool_search_text
  • codex-rs/tools/src/tool_search.rs :: append_schema_search_text
rust
fn append_function_search_text(tool: &ResponsesApiTool, parts: &mut Vec<String>) {
    push_search_part(parts, tool.name.clone());
    push_search_part(parts, tool.name.replace('_', " "));
    push_search_part(parts, tool.description.clone());
    append_schema_search_text(&tool.parameters, parts);
}

fn append_schema_search_text(schema: &JsonSchema, parts: &mut Vec<String>) {
    if let Some(description) = &schema.description {
        push_search_part(parts, description.clone());
    }
    if let Some(properties) = &schema.properties {
        for (name, schema) in properties {
            push_search_part(parts, name.clone());
            append_schema_search_text(schema, parts);
        }
    }
    if let Some(items) = &schema.items {
        append_schema_search_text(items, parts);
    }
    if let Some(variants) = &schema.any_of {
        for variant in variants {
            append_schema_search_text(variant, parts);
        }
    }
}

这段代码的消费者是 BM25 搜索索引,而不是模型请求本身。修改工具描述可能改变“能否被搜索到”,却不一定改变 直接暴露时发送的 JSON;修改 parameters 既可能改变请求 schema,也可能改变搜索关键词,因此调试 deferred tool 时需要分别检查两个消费者。

7. 失败边界 ​

7.1 Schema解析 ​

外部工具的 input_schema 进入 parse_tool_input_schema。这个函数会复制并预处理 JSON,再反序列化为 JsonSchema;单独的 null 类型会被明确拒绝。这个错误发生在规格进入 Router 之前,所以模型不会看到一个 “看起来可用、实际无法解析”的工具条目。

相关源码:

  • codex-rs/tools/src/json_schema.rs :: parse_tool_input_schema
  • codex-rs/tools/src/json_schema.rs :: deserialize_tool_input_schema
rust
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
    let mut input_schema = prepare_tool_input_schema(input_schema);
    compact_large_tool_schema(&mut input_schema);
    deserialize_tool_input_schema(input_schema)
}

fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result<JsonSchema, serde_json::Error> {
    let schema: JsonSchema = serde_json::from_value(input_schema)?;
    if matches!(
        schema.schema_type,
        Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Null))
    ) {
        return Err(singleton_null_schema_error());
    }
    Ok(schema)
}

parse_tool_input_schema 仍可能接受空 schema 或被兼容处理后的 schema;“不是 singleton null”不等于“业务参数 一定正确”。后续 Registry 的 payload kind 检查和具体 handler 的参数校验仍然必要。

7.2 大型外部规格 ​

MCP 与 dynamic tool 的规格还可能受到来源特定的边界限制。普通 MCP 转换保留已有 schema 接受行为;Agent Plugin MCP 转换在序列化后的规格超过限制时,才用一个宽松的空对象 schema 替代参数细节。这个替代发生在模型可见 规格层,不代表 runtime 忽略了真实参数。

源码位置:codex-rs/tools/src/responses_api.rs :: agent_plugin_mcp_tool_to_responses_api_tool

rust
let mut tool = tool_definition_to_responses_api_tool(
    parse_agent_plugin_mcp_tool(tool)?.renamed(tool_name.name.clone()),
);
if serde_json::to_vec(&tool)?.len() > MAX_SERIALIZED_MCP_TOOL_BYTES {
    tool.parameters = JsonSchema::object(
        Default::default(),
        /*required*/ None,
        Some(true.into()),
    );
}
Ok(tool)

这条路径的设计取舍是“保证模型能看到并调用工具”与“保留完整 schema 描述”之间优先前者。不能据此推断所有 MCP 工具都使用空参数 schema,也不能把它和 JSON Schema 的通用 compaction 混为一谈;后者发生在输入 schema 解析阶段,前者只针对 Agent Plugin MCP 的最终 Responses 函数规格。

8. 测试证据 ​

8.1 Wire断言 ​

tool_spec_tests::responses_lite_groups_default_function_and_custom_tools 构造了五类输入:tool search、顶层 function、其他 namespace、freeform 和已有 functions namespace,随后断言输出只有三个顶层元素,且默认 namespace 中的子工具顺序为 lookup_order、exec、existing、last。这证明的是 Lite 的合并规则和顺序, 不是 provider 一定接受该 JSON,也不证明 runtime 会执行这些工具。

tool_spec_tests::namespace_tool_spec_serializes_expected_wire_shape 则同时放入 function child 和 custom child, 断言 namespace JSON 的 type、名称、说明、子工具类型和 grammar 字段。它验证 namespace 是混合容器,而不是 只允许 function 的特殊结构。

源码位置:codex-rs/tools/src/tool_spec_tests.rs :: responses_lite_groups_default_function_and_custom_tools

rust
let tools = create_tools_json_for_responses_lite(&[
    ToolSpec::ToolSearch {
        execution: "client".to_string(),
        description: "Search tools".to_string(),
        parameters: JsonSchema::object(
            BTreeMap::new(),
            /*required*/ None,
            /*additional_properties*/ None,
        ),
    },
    ToolSpec::Function(responses_lite_function("lookup_order")),
    ToolSpec::Namespace(ResponsesApiNamespace {
        name: "editor".to_string(),
        description: "Editing tools".to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(
            responses_lite_function("edit"),
        )],
    }),
    ToolSpec::Freeform(FreeformTool {
        name: "exec".to_string(),
        description: "Run code".to_string(),
        defer_loading: None,
        format: FreeformToolFormat {
            r#type: "grammar".to_string(),
            syntax: "lark".to_string(),
            definition: "start: /.+/".to_string(),
        },
    }),
    ToolSpec::Namespace(ResponsesApiNamespace {
        name: "functions".to_string(),
        description: "Existing default tools".to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(
            responses_lite_function("existing"),
        )],
    }),
    ToolSpec::Function(responses_lite_function("last")),
])
.expect("serialize Responses Lite tools");

assert_eq!(tools.len(), 3);
assert_eq!(tools[0]["type"], "tool_search");
assert_eq!(tools[2]["type"], "namespace");
assert_eq!(tools[2]["name"], "editor");
assert_eq!(tools[2]["tools"][0]["name"], "edit");
assert_eq!(tools[1]["type"], "namespace");
assert_eq!(tools[1]["name"], "functions");
assert_eq!(tools[1]["description"], "Existing default tools");
assert_eq!(
    tools[1]["tools"]
        .as_array()
        .expect("functions namespace should contain tools")
        .iter()
        .map(|tool| (tool["type"].as_str(), tool["name"].as_str()))
        .collect::<Vec<_>>(),
    [
        (Some("function"), Some("lookup_order")),
        (Some("custom"), Some("exec")),
        (Some("function"), Some("existing")),
        (Some("function"), Some("last")),
    ]
);

另一个混合子工具的 wire 断言位于 codex-rs/tools/src/tool_spec_tests.rs :: namespace_tool_spec_serializes_expected_wire_shape,可用于继续核对 namespace 中 function 与 custom 的完整 JSON。

8.2 延迟断言 ​

tool_search_tests::top_level_function_search_results_use_the_default_namespace 给一个带 output_schema 的函数 规格,断言搜索结果把它放进默认 namespace、设置 defer_loading: Some(true),并把 output_schema 变为 None。这直接验证了延迟搜索条目和直接请求规格不是同一个对象快照。

源码位置:codex-rs/tools/src/tool_search_tests.rs :: top_level_function_search_results_use_the_default_namespace

rust
let function_tool = ResponsesApiTool {
    name: "lookup_order".to_string(),
    description: "Look up an order".to_string(),
    strict: false,
    defer_loading: None,
    parameters: JsonSchema::object(BTreeMap::new(), None, None),
    output_schema: Some(serde_json::json!({ "type": "object" })),
};
let search_info = ToolSearchInfo::from_tool_spec(
    ToolSpec::Function(function_tool.clone()),
    /*source_info*/ None,
)
.expect("top-level function should be searchable");

assert_eq!(search_info.entry.output, LoadableToolSpec::Namespace(
    ResponsesApiNamespace {
        name: "functions".to_string(),
        description: String::new(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            defer_loading: Some(true),
            output_schema: None,
            ..function_tool
        })],
    },
));

这项测试不能证明搜索算法的排序质量,也不能证明命中后一定会在下一次 sampling 暴露工具;它只证明规格到 LoadableToolSpec 的投影字段。

9. 复现路径 ​

在 Codex 源码 workspace 中运行以下测试,可以把本文的三个关键边界分别复现出来:

bash
cargo test -p codex-tools tool_spec
cargo test -p codex-tools responses_api
cargo test -p codex-tools tool_search

阅读源码时可以做三个只读练习:

  1. 从 create_update_plan_tool 找到 plan 数组的 required 和 additionalProperties,说明哪些约束来自 schema,哪些只写在 description 中。
  2. 给 create_tools_json_for_responses_lite 输入一个只有 ToolSearch 的数组,判断为什么不会生成空的 functions namespace;再加入一个 Function,预测 namespace 会插入到哪个位置。
  3. 对同一个函数规格分别查看直接请求和 ToolSearchInfo::from_tool_spec 的结果,列出 defer_loading、output_schema 和 namespace 的差异,并指出这两个对象的消费者。

10. 边界 ​

ToolSpec 是模型协议的规格层,不是工具权限、并行策略或执行结果的总账:

  • ToolExposure 决定规格是否进入 direct、deferred 或 code mode 表面,见工具系统架构总览。
  • JsonSchema 描述输入形状,但 schema 解析、压缩、$ref 保留和兼容降级属于 JSON Schema 专题。
  • ResponsesApiTool.output_schema 是本地元数据,当前不会序列化到工具 JSON;不要把它当成 provider 已声明的输出协议。
  • FreeformTool 的 grammar 只约束模型生成的文本,实际 patch 或 command 执行仍由具体 runtime 完成。
  • WebSearch 是 hosted tool 规格;它是否出现取决于 provider capability 和 web search mode,不等价于一个普通 本地 handler。

顺着这些边界继续阅读时,可以回到工具系统架构总览,把本篇的规格对象放回 SpecPlan → ToolRouter → Prompt 主线;请求 JSON 的最终消费者则可继续结合模型请求构造 阅读。JSON Schema、ToolSearch 和 Router 的细节会在对应专题发布后分别展开。