ToolSpec与函数规格
一个工具能被模型调用,至少要经过两次转换:运行时先提供一个可执行对象和它的模型规格,客户端再把规格 序列化成 Responses 请求中的 tools。因此,ToolDefinition、ResponsesApiTool 和 ToolSpec 不是三个 重复的名字,而是三个不同的所有权层级。
本文面向已经了解 StepContext 会持有工具 Router 的读者。建议先读 工具系统架构总览,它解释 registry、Router 和模型可见规格的关系;如果还 不熟悉请求构造,可先读模型请求构造。本文只解释规格对象如何建模、 转换和序列化,不展开 JSON Schema 的递归清洗算法,也不讲某一个工具 handler 的业务语义。读完后,读者应能 从一个 handler 的 spec() 找到最终 JSON,判断一个字段是在运行时使用、对模型公开,还是只用于延迟发现。
1. 三层定义
先把三个容易混淆的对象放在同一条线上:
| 层级 | 对象 | 所有者 | 主要消费者 | 是否直接发给模型 |
|---|---|---|---|---|
| 定义层 | ToolDefinition | codex-tools 的转换函数 | MCP、dynamic tool 适配器 | 否 |
| Responses 函数层 | ResponsesApiTool | responses_api.rs | ToolSpec、namespace | 是,作为函数工具内容 |
| 顶层规格层 | ToolSpec | tools 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 :: ToolDefinitioncodex-rs/tools/src/tool_definition.rs :: ToolDefinition::into_deferred
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
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
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
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_toolcodex-rs/tools/src/dynamic_tool.rs :: parse_dynamic_tool
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
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
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_promptcodex-rs/core/src/client_common.rs :: Prompt
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 :: ResponsesApiNamespacecodex-rs/tools/src/responses_api.rs :: ResponsesApiNamespaceTool
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
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
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_apicodex-rs/tools/src/tool_spec.rs :: create_tools_raw_json_for_responses_api
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
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
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_textcodex-rs/tools/src/tool_search.rs :: append_schema_search_text
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_schemacodex-rs/tools/src/json_schema.rs :: deserialize_tool_input_schema
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
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
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
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 中运行以下测试,可以把本文的三个关键边界分别复现出来:
cargo test -p codex-tools tool_spec
cargo test -p codex-tools responses_api
cargo test -p codex-tools tool_search阅读源码时可以做三个只读练习:
- 从
create_update_plan_tool找到plan数组的required和additionalProperties,说明哪些约束来自 schema,哪些只写在 description 中。 - 给
create_tools_json_for_responses_lite输入一个只有ToolSearch的数组,判断为什么不会生成空的functionsnamespace;再加入一个Function,预测 namespace 会插入到哪个位置。 - 对同一个函数规格分别查看直接请求和
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 的细节会在对应专题发布后分别展开。
