Skip to content
预计 1 分钟右侧目录可快速定位

从发现 Tool 到可回灌结果:Search、Execution、Normalization 与 Result Budget ​

前置:Tool 太多时怎么暴露:Metadata、Dynamic Exposure 与 Discovery
核心问题:从“找到一个可能的 Tool”到“得到可回灌 Observation”,中间哪些步骤必须由 Runtime 完成,Result 又该怎样控制大小?

MCP catalog 初次只缓存 name/description/version;某工具被选中时才获取完整 input schema。参数 schema 失败不触发真实 I/O;permission 失败不 retry;执行 timeout 记录 attempt;结果过大进入 artifact。

动态暴露先根据 user/policy/task 过滤,再搜索 catalog,只把候选 schema 放进 context;这比千级工具全加载更可扩展。

Deferred Loading 把 schema 成本推迟到工具真正相关时;Tool Search 返回候选描述/引用,再由 Runtime 注入完整定义。要处理缓存和远程 schema 更新。

Tool Execution Pipeline ​

一旦模型提出 ToolCall,后面的关键步骤应由程序固定推进:

text
resolve exact tool/version
→ validate arguments
→ authorization / approval
→ apply deadline
→ execute
→ normalize result/error
→ persist trace/execution fact
→ apply result policy

这条链的价值是把“模型建议执行什么”和“系统怎样安全执行”分开。参数校验失败时不应触发真实 I/O;permission failure 不应该被 Tool 内部偷偷 retry;只有 Executor 完成后才能形成可供 Runtime 使用的 ToolResult。

🔍 源码对照:Tool Call 如何从协议层进入内部 Executor ​

精确定位

ts
async function callProjectedTool(name, args) {
  const selected = tools.get(name)
  if (!selected) return mcpError("unknown tool")

  const raw = await deps.callTool({
    ...selected,
    args,
    toolCallId: randomUUID(),
  })
  return normalizeMcpResult(raw)
}

对照理解

  • tools/call 做的是 协议名 → 内部 Tool identity → Runtime execution → result projection。
  • 真正执行仍由注入的 deps.callTool() 完成,所以 MCP bridge 是 Adapter,而不是 Harness Runtime 本身。
  • mcpResult() 把内部 success/error/text/image/structured content 规整成协议结果,这正是 Result Normalization 边界。

源码说明

routed-mcp-bridge.ts 可以看到一条短而完整的 adapter path:tools/list 建 catalog,tools/call resolve 已发现 Tool,再调用内部 callTool,最后通过 mcpResult 归一化 text/image/structured content;turn-observation.ts 则在更外层记录 tool-started/tool-completed。把两者连起来看,恰好说明“协议调用成功返回”与“Runtime 如何观察并持久化这次执行”是两个不同层次。

工程边界

MCP bridge 里的 result conversion 不是 Shadow Harness 的统一 ToolResult schema;内部 Runtime 应先拥有自己的 domain result,再由 MCP/HTTP 等 adapter 做边界转换。

Shadow Harness 对照

  • 执行管线固定为 resolve → validate/policy → execute → normalize → observation/record。
  • 协议 Adapter 不直接拥有 RunState mutation 权限。

验证

让同一内部 FakeTool 分别经 direct adapter 与 MCP adapter 调用,断言最终 domain ToolResult/Observation 语义一致。

Tool Result Normalization ​

HTTP 404、subprocess exit 1、Python exception、MCP protocol error 不能让 Runtime 分别写四套分支。Adapter/Executor 应统一成类似:

text
status
error_category
retryable / uncertain_outcome
content / artifact_ref
metadata / provider_code
operation_id / timing

统一不等于丢掉原始诊断;provider code 可以作为 metadata/ref 保存。Runtime 消费结构化事实,模型拿适合推理的 Observation,Trace 仍能回到原始错误。

🔍 源码对照:Result Normalization 是单独的 Adapter 边界 ​

精确定位

ts
async function callProjectedTool(name, args) {
  const selected = tools.get(name)
  if (!selected) return mcpError("unknown tool")

  const raw = await deps.callTool({
    ...selected,
    args,
    toolCallId: randomUUID(),
  })
  return normalizeMcpResult(raw)
}

对照理解

  • tools/call 做的是 协议名 → 内部 Tool identity → Runtime execution → result projection。
  • 真正执行仍由注入的 deps.callTool() 完成,所以 MCP bridge 是 Adapter,而不是 Harness Runtime 本身。
  • mcpResult() 把内部 success/error/text/image/structured content 规整成协议结果,这正是 Result Normalization 边界。

Token-efficient Tool Results ​

返回字段投影、分页、聚合、artifact ref 通常比让模型总结海量 raw result 更稳定、更便宜。工具输出应面向后续决策:projection、pagination、摘要、结构化 fields 和 artifact ref,避免把全部 raw payload 塞回模型。完整结果可外部保存,Context 只保留必要信息和 provenance。

不要让模型花 token 从巨大原始结果中找几行。

Discovery 与 Execution 中间必须有一道显式 Contract ​

大 catalog 下可以先 tool_search(query) 返回候选 metadata,但模型最终选择的仍应被规范化为内部 ToolCall:

text
candidate discovery
→ expose full schema
→ model proposes ToolCall
→ registry resolve exact version
→ argument validation
→ authorization/approval
→ executor
→ normalized ToolResult

Tool search 的结果不能直接变成可执行函数指针,否则 discovery 层就绕过了 Registry 和权限边界。

关联源码 ​

Codex 源码对照:Provider Output → Domain ToolCall → Runtime Dispatch ​

要回答的问题 ​

模型返回一个 function/tool item 后,为什么它还没有执行权?Codex 在哪一步把 provider item 变成内部 domain call,又在哪一步解析成真正可执行的 Runtime?

精确定位 ​

Tag: rust-v0.150.1

File:

Symbols:

  • handle_output_item_done(...)
  • ToolRouter::build_tool_call(...)
  • ToolCall
  • ToolCallRuntime::handle_tool_call(...)
  • ToolCallRuntime::handle_tool_call_with_source(...)
  • ToolRouter::tool_runtime(...)
  • ToolRouter::dispatch_tool_call_with_terminal_outcome(...)
  • ToolRegistry::tool(...)
  • ToolRegistry::dispatch_any_with_terminal_outcome(...)
  • AnyToolResult::into_response(...)

最小调用链 ​

text
ResponseEvent::OutputItemDone(ResponseItem)
→ handle_output_item_done(...)
→ ToolRouter::build_tool_call(ResponseItem)
→ normalized ToolCall { tool_name, call_id, payload, ... }
→ ToolCallRuntime::handle_tool_call(...)
→ ToolRouter::tool_runtime(&ToolCall)
→ ToolRegistry::tool(tool_name)
→ parallel/serial execution gate
→ ToolRouter::dispatch_tool_call_with_terminal_outcome(...)
→ ToolRegistry::dispatch_any_with_terminal_outcome(...)
→ concrete CoreToolRuntime / ToolExecutor
→ AnyToolResult
→ AnyToolResult::into_response()
→ ResponseInputItem
→ next sampling

Observable Fact ​

ToolRouter::build_tool_call() 对 ResponseItem::FunctionCall、client-side ToolSearchCall、CustomToolCall 分支分别解析并构造内部 ToolCall;非 tool item 返回 None。handle_output_item_done() 只有在成功构造 ToolCall 后才把它交给 ToolCallRuntime。

ToolCallRuntime 再从当前 StepContext 的 router/registry 获取 runtime,处理并行 execution gate 和 cancellation;ToolRegistry::dispatch_any_with_terminal_outcome() 会重新按 tool_name resolve runtime、校验 payload kind、执行 pre-tool hook,最后才调用 concrete handler。结果再通过 AnyToolResult::into_response() 转回 model-facing ResponseInputItem。

因此:

text
Provider output
≠ Domain ToolCall
≠ Executable Runtime

Design Inference ​

核心 invariant:Provider 返回的 function/tool item 不能直接获得执行权,必须先规范化为内部 domain call,再经 registry/runtime resolution、validation/policy/hook 和 execution admission 后执行。

这使 provider schema、Harness domain model 与实际 capability owner 解耦,也给 unsupported tool、payload mismatch、hook rejection、parallel admission 和 cancellation 留出确定性控制点。

Codex-specific Trade-off ​

Codex rust-v0.150.1 用 ResponseItem 作为 provider-facing item、ToolCall 作为内部 call、CoreToolRuntime/ToolExecutor 作为 runtime contract,并在 ToolCallRuntime 中实现 parallel gate。这套类型和分层位置是 Codex 当前选择;通用原则不是要求别的 Harness 复制这些类名。

Shadow Harness Delta ​

  1. 明确建立 ProviderToolItemAdapter → DomainToolCall,禁止 provider JSON 直接进入 executor。
  2. DomainToolCall 至少携带 stable call_id、canonical tool identity、validated payload 与 source metadata。
  3. Executor 前增加 runtime resolution + permission/policy + cancellation gate。
  4. trace 至少记录 provider_item_received → domain_call_built → runtime_resolved → execution_started → result_normalized。
  5. 测试 malformed provider item、unknown tool、payload kind mismatch、取消等待中的 tool call 都不能产生未授权 side effect。

不要过度推导 ​

这些文件能证明 Codex 的 normalization 与 dispatch 边界,但不能证明所有 Harness 都必须使用 Registry pattern,也不能把 ToolCall 类型名本身当成通用 Domain 定义。

完整映射见 OpenAI Codex Source Map。

Codex 源码对照:Unified Exec — Routed Shell Action → Controlled OS Side Effect ​

要回答的问题 ​

一个已经通过 Tool Router 的 shell action,怎样从 arguments 真正变成受 environment、permission、sandbox、process lifecycle 和 cancellation 控制的 OS side effect?

精确定位 ​

Tag: rust-v0.150.1

File:

Symbols:

  • ExecCommandArgs
  • get_command(...)
  • ExecCommandHandler
  • ExecCommandHandler::handle_call(...)
  • resolve_tool_environment(...)
  • resolve_sandbox_permissions(...)
  • apply_granted_turn_permissions(...)
  • UnifiedExecProcessManager::allocate_process_id(...)
  • UnifiedExecProcessManager::exec_command(...)
  • ToolCallRuntime::handle_tool_call_with_source(...)

最小调用链 ​

text
ToolInvocation(Function arguments)
→ ExecCommandHandler::handle_call(...)
→ parse ExecCommandEnvironmentArgs
→ resolve_tool_environment(...)
→ resolve cwd / filesystem
→ parse ExecCommandArgs
→ resolve_sandbox_permissions(...)
→ select shell / get_command(...)
→ allocate_process_id()
→ apply_granted_turn_permissions(...)
→ approval / additional-permission validation
→ optional apply_patch interception
→ ExecCommandRequest { command, cwd, network, tty, permissions, ... }
→ UnifiedExecProcessManager::exec_command(...)
→ output | SandboxDenied | execution error
→ ToolOutput

Cancellation path:

text
ToolCallRuntime dispatch task
↔ cancellation_token.cancelled()
→ abort dispatch when no terminal outcome exists
→ notify_tool_aborted(...)
→ AbortedToolOutput

Observable Fact ​

ExecCommandHandler::handle_call() 不会拿到 cmd 就直接 spawn。它先解析 environment/workdir,按目标 environment 解析 cwd 和 shell,规范化 sandbox/additional permissions,检查 approval policy 与 sticky turn permissions,分配 process id,构造 ExecCommandRequest,再交给 UnifiedExecProcessManager::exec_command()。

结果路径显式区分成功、SandboxDenied 与其他 execution error。外层 ToolCallRuntime 还用 cancellation token 控制 dispatch task,取消时会 abort 并返回 aborted result。

Design Inference ​

Tool routing 只完成 capability resolution,不等于 side effect execution。 对有副作用的 shell/process tool,Harness 仍需要一个 execution runtime,把 arguments → environment → permission → admission → process → output/cancellation 串成受控生命周期。

Codex-specific Trade-off ​

Codex rust-v0.150.1 的 unified exec 支持 local/remote environment、TTY、yield time、login shell、additional permissions、apply_patch interception 等。这些是产品能力取舍;通用 invariant 只是 side effect 必须经过受控 execution boundary。

Shadow Harness Delta ​

  1. 在 ToolExecutor 下增加独立 ProcessRuntime,不要让 shell handler 自己散落调用 subprocess。
  2. ProcessRequest 固化 cwd/env/permission profile/timeout/cancellation identity,再创建进程。
  3. 统一返回 completed / denied / cancelled / failed execution fact,而不是靠 stdout 文本猜状态。
  4. 增加测试:取消长命令必须终止/隔离对应 execution;permission denied 不得 fallback 成裸机重试;cwd 必须来自 resolved environment,而不是模型随意拼路径。

不要过度推导 ​

这两个 unified-exec 文件能证明请求被规范化并交给 UnifiedExecProcessManager,但不能单凭这里断言各平台最终采用哪种具体 OS spawn/sandbox backend。要证明 syscall / platform wrapper,需要继续追 process manager 与平台 executor;本轮不扩到那里。

完整映射见 OpenAI Codex Source Map。

自测 ​

  1. 从 Tool search 命中候选到形成 Observation,中间哪些步骤不能交给模型“自行认为已经完成”?
  2. Tool 原始结果 20MB。Normalization 与 result budgeting 应怎样配合,既保留可追溯事实又不撑爆 Context?

Sources ​

AI Engineering · Agent · Harness · Backend Systems