第 4 章
能力如何运行
Tools、Sandbox 与 Delegation
Tool、MCP、Skill、Sandbox、Subagent 和 Artifact 并不是六个并列功能。本章沿一项工具调用往下看:它怎样进入候选目录,怎样通过授权并出现在模型面前,又怎样执行、返回,最后成为用户真正拿到的结果。
MCP、Skill、Sandbox 与 Subagent 不在同一层;它们分别改变来源、可见性、执行环境与委派边界。
- 01Available
配置、内建、MCP 与可选委派形成候选目录
- 02Authorized / Visible
身份、Skill 与 deferred schema 决定模型能看到什么
- 03Executed / Returned
真实调用再过 guardrail,并在 Sandbox 中产生结果
- 04Delivered
Artifact 声明进入状态,Run 结算另行验证
一项能力要经过六道检查
sales-review 需要读从 sales-notes.pdf 转换出的销售材料、搜索外部证据、写入报告,必要时还可能把一段独立调查委派给 Subagent。把这些名字并排列出,只能说明系统组件很多,无法回答模型何时看见能力、谁允许真实执行,以及结果怎样成为用户交付物。
更准确的主线是六个状态:Available 表示能力进入候选目录;Authorized 表示调用者身份允许它参与本次装配;Visible 表示当前模型调用实际拿到 schema;Executed 表示真实 callable 已经过运行时策略;Returned 表示 ToolMessage 或 Command 回到图状态;Delivered 表示结果被明确登记,并在 Run 结算中接受交付校验。
Tool 是模型可调用的能力单元。MCP 是 Tool 的远程来源与协议,不代表每个 MCP 调用都会变成 durable background task。Skill 为 Agent 提供任务说明和操作规范,激活后还可以限制工具与 secret 的使用。Sandbox 是副作用执行环境。Subagent 是通过 task 工具触发的一次有界委派。Artifact 则是 Agent 明确声明要交付的文件路径。
这些概念会在生命周期的不同位置出现。MCP 改变 Available 的来源,Skill 主要改变 Visible 与 Executed,Sandbox 承接 Executed,Subagent 把一次调用扩展为新的有界 graph,Artifact 连接 Returned 与 Delivered。它们不是一条继承链,也不共享同一种信任模型。
本章的结果不是记住六个定义,而是能沿一次 web search 或文件写入判断:能力从哪里来,何时暴露,谁能拒绝,副作用在哪里发生,返回进入哪份状态,以及为何 Run 仍可能在交付结算时失败。
先汇总工具,再去重和授权
get_available_tools 从配置工具、内建工具、缓存的 MCP 工具、ACP 能力和可选 Subagent 工具形成候选集。是否加入上传、视觉、后台任务或 delegation 工具取决于本次配置;Local Sandbox 默认还会阻止未经允许的 host bash 进入目录。
来源有优先级,因为模型不能安全处理两个同名、schema 却不同的函数。固定提交按 config-loaded、built-in、MCP、ACP 的顺序拼接,第一次出现的名字获胜,后续重名项被记录并跳过。这是目录确定性,不是运行时授权。
随后 Agent 装配用 Principal 对候选集做第一层 authorization,得到 authorized tools,再构造 deferred catalog。被拒绝的工具不能通过 tool_search 重新进入目录。真实 tool call 到来时,GuardrailMiddleware 还会做第二次检查;第一层保护模型视野,第二层保护执行入口。
logger.info(f"Total tools loaded: {len(loaded_tools)}, built-in tools: {len(builtin_tools)}, MCP tools: {len(mcp_tools)}, ACP tools: {len(acp_tools)}")
# Deduplicate by tool name — config-loaded tools take priority, followed by
# built-ins, MCP tools, and ACP tools. Duplicate names cause the LLM to
# receive ambiguous or concatenated function schemas (issue #1803).
all_tools = [_ensure_sync_invocable_tool(t) for t in loaded_tools + builtin_tools + mcp_tools + acp_tools]
seen_names: set[str] = set()
unique_tools: list[BaseTool] = []
for t in all_tools:
if t.name not in seen_names:
unique_tools.append(t)
seen_names.add(t.name)
else:
logger.warning(
"Duplicate tool name %r detected and skipped — check your config.yaml and MCP server registrations (issue #1803).",
t.name,
)
return unique_tools摘录中的 all_tools 保留了来源顺序,seen_names 则把名字选择变成确定结果。它证明 Available 不是 Python import 的全集,而是一次按配置、模型能力和运行开关组装出的目录。
如果 sales-review 的身份没有外部搜索权限,正确行为是在装配期移除相应 schema;如果目录允许搜索,但某次调用参数或当前策略不允许,执行期 guardrail 可以返回错误 ToolMessage。两种 deny 出现在不同时间,排查时不能只看模型是否生成了 tool call。
目录构建失败、授权 provider 失败或工具名冲突都发生在真实副作用之前。失败采用 fail-open 还是 fail-closed 取决于配置与边界;安全配置下不能把“授权服务不可用”静默解释成“全部允许”。
MCP 按需公开 schema,Skill 再限制使用范围
MCP server 可以提供大量工具。若每次都把完整 schema 塞进模型上下文,目录越大,提示成本和错误选择概率越高。deferred discovery 因此只在系统提示中暴露候选名称;模型需要某项能力时先调用 tool_search,取回匹配工具的完整 schema。
tool_search 返回的不只是文字。它通过 Command.update 写入 promoted names,并携带当前 catalog hash。DeferredToolFilterMiddleware 只接受与当前目录 hash 相同的 promotion;配置变化后,旧 ThreadState 中的名字不会无条件扩大新目录。
这一步只是在已授权目录中按需公开工具。SkillToolPolicyMiddleware 还会根据真正激活的 Skill,继续过滤模型的 request.tools、真实调用和 tool_search 返回。enabled Skill 只表示它可以被发现;slash 指令或实际读取 SKILL.md 后,才算建立了激活上下文。
def build_tool_search_tool(catalog: DeferredToolCatalog) -> BaseTool:
catalog_hash = catalog.hash
@tool
def tool_search(query: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Fetches full schema definitions for deferred tools so they can be called.
Deferred tools appear by name in <available-deferred-tools> in the system
prompt. Until fetched, only the name is known. This tool matches a query
against the deferred tools and returns the matched tools complete schemas;
once returned, a tool becomes callable.
Query forms:
- "select:Read,Edit" -- fetch these exact tools by name
- "notebook jupyter" -- keyword search, up to max_results best matches
- "+slack send" -- require "slack" in the name, rank by remaining terms
"""
matched = catalog.search(query)
if not matched:
content, names = f"No tools found matching: {query}", []
else:
content = json.dumps([convert_to_openai_function(t) for t in matched], indent=2, ensure_ascii=False)
names = [t.name for t in matched]
return Command(
update={
"promoted": {"catalog_hash": catalog_hash, "names": names},
"messages": [ToolMessage(content=content, tool_call_id=tool_call_id, name="tool_search")],
}
)
return tool_search代码卡中的 catalog_hash 把 names 与一次目录构造绑定,promoted 则是写回 ThreadState 的可见性状态。tool_search 返回完整 OpenAI function schema,但该结果仍须经过活动 Skill policy 的过滤,不能把被政策移除的 schema 或 promotion 带回图状态。
Skill 权限也不是多个被动读取结果的并集。显式 slash 激活具有主导地位,后续被动读取的第二个 Skill 不能借机放宽 allowed tools。secret 注入还要求 enabled、allowlisted、active、supplied、declared 同时成立,并在调用时读取 live registry,以便撤销立即生效。
因此,MCP 回答“工具从哪里来”,deferred discovery 回答“schema 什么时候给模型看”,Skill 回答“当前任务还允许使用哪些工具”。三者会一起工作,但不能互相代替。
Skill 既限制模型能看到什么,也限制工具能否执行
只过滤 prompt 或 model request 仍不够。模型可能重放旧 tool call,provider 可能返回目录外名字,或者 tool_search 结果在返回途中越过新政策。SkillToolPolicyMiddleware 因而同时实现模型与工具 wrapper。
wrap_model_call 用 _filter_model_request 过滤 request.tools;wrap_tool_call 再计算 allowed names,必要时用 _blocked_tool_message 在真实 handler 之前拒绝调用,并对 tool_search 的 schemas 和 promoted names 再过滤一次。
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
policy = self._active_policy(request)
return handler(self._filter_model_request(request, policy=policy, refresh_decision=True))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
policy = self._active_policy(request)
_, paths = policy
if not paths:
self._store_policy_decision(request, policy, None)
return await handler(request)
filtered = await asyncio.to_thread(
self._filter_model_request,
request,
policy=policy,
refresh_decision=True,
)
return await handler(filtered)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
policy = self._active_policy(request)
if not policy[1]:
return handler(request)
allowed = self._allowed_names(request, policy=policy)
blocked = self._blocked_tool_message(request, allowed=allowed)
if blocked is not None:
return blocked
return self._filter_tool_search_result(request, handler(request), allowed=allowed)摘录中的 blocked 明确发生在 handler 之前;_filter_tool_search_result 则发生在允许执行的 tool_search 返回之后。这样,模型看到的目录、真实可调用集合和下一轮保存的 promotion 使用同一活动政策。
这套规则只能从已经授权的候选集中继续删减,不能凭空增加工具。若 Skill policy 解析失败,固定提交只保留 always-safe builtins,不会因为规则读取失败就放开全部权限。
Sandbox 可以复用,但每次 Agent 结束仍会 release
需要文件系统或命令执行的工具会通过 Sandbox。默认 lazy initialization 让第一项相关工具按 user/thread 身份 acquire 环境;eager 模式则可在 before_agent 提前准备。授权检查位于 acquisition 边界,因此换一个触发 Sandbox 的工具不能绕过执行权限。
SandboxState 中的 sandbox_id 是图需要继续看见的稳定身份。lazy helper 会在当前 runtime.state 中写入新 id,但 LangGraph 不会自动把这次原地修改交给 reducer;SandboxMiddleware 必须把 ToolMessage 或原 Command 包成新的 Command.update。
这就是代码卡中 _attach_sandbox_update 的作用:它保留原 messages、goto、graph 和 resume,只合并 sandbox 字段。若不做这一步,下一图节点、ToolOutputBudget 或 Subagent 可能看不到刚取得的环境,ThreadState 的 sandbox reducer 也无法阻止同一 thread 被悄悄换成冲突 id。
@staticmethod
def _attach_sandbox_update(result: ToolMessage | Command, sandbox_id: str) -> ToolMessage | Command:
"""Wrap or merge ``result`` so that ``sandbox.sandbox_id`` is persisted.
- ``ToolMessage`` -> ``Command(update={"sandbox": ..., "messages": [msg]})``
- ``Command`` with dict update -> merge ``sandbox`` key, preserve all
existing fields (``messages``, ``goto``, ``graph``, ``resume``, ...).
- ``Command`` with non-dict / None update -> leave it untouched to
avoid silent data loss on unknown update shapes.
"""
sandbox_update = {"sandbox": {"sandbox_id": sandbox_id}}
if isinstance(result, ToolMessage):
return Command(update={**sandbox_update, "messages": [result]})
existing_update = result.update
if isinstance(existing_update, dict):
merged_update = {**existing_update, **sandbox_update}
return dc_replace(result, update=merged_update)
return result完成 _attach_sandbox_update 后,后续 Run 能找到同一个工作环境,是因为 per-user/thread 身份、路径映射和 provider 约定保持稳定,并不是因为容器对象永远不 release。SandboxMiddleware 会在 Agent 结束后调用 release;Local provider 可以不做实际销毁或继续缓存,其他 provider 也可以把实例归还 warm pool。换句话说,release 不等于销毁 Sandbox。
虚拟路径把 uploads、workspace、outputs 和 enabled-only Skill projection 映射到受控位置。请求 secrets 不继承整个 Gateway 环境,而是按声明和政策显式注入。Skill projection 只读,Sandbox 中的 Agent 不能改写 canonical Skill。
Sandbox 解决“副作用在哪里发生、如何隔离”,不决定哪个文件应该交付。sales-review.md 写进 outputs 后仍只是文件;Artifact 声明和 Run settlement 是后两步。
工具在 workspace / outputs 产生文件;present_files 只登记选中的 output,worker 再验证本次 Run 的交付。
只有收益超过协调成本时,才把工作切给 Subagent
task 是一种特殊 Tool。它适合独立、非重叠、能并行节省时间的调查,或者需要专门模型、工具、Skill 与上下文隔离的有界任务;“任务很复杂”“文件很多”本身不构成委派理由。
sales-review 可以把互不依赖的行业数据检索交给 Subagent,同时由 Lead Agent 继续整理内部访谈;但如果第二步必须读取第一步刚写的同一文件,拆成两个并发任务只会制造共享状态竞争和重复发现成本。
委派前还要计算容量、每 Run 总量、token/loop budget、结果综合成本和取消延迟。全局 capacity 与本次 Run 的并发/总量限制是两层约束;长工具调用只在 stream yield 边界协作取消,因此 timeout 不等于任意时刻强制终止副作用。
# Inherit parent agent's tool_groups so subagents respect the same restrictions
parent_tool_groups = metadata.get("tool_groups")
resolved_app_config = runtime_app_config
if config.model == "inherit" and parent_model is None and resolved_app_config is None:
resolved_app_config = get_app_config()
effective_model = resolve_subagent_model_name(config, parent_model, app_config=resolved_app_config)
# Subagents should not have subagent tools enabled (prevent recursive nesting).
# Subagents also must not get list_uploaded_files — they have an independent
# ThreadState where runtime.state["uploaded_files"] is absent, so the
# current-run file exclusion would not work.
available_tools_kwargs = {
"model_name": effective_model,
"groups": parent_tool_groups,
"subagent_enabled": False,
"include_upload_tool": False,
}
if resolved_app_config is not None:
available_tools_kwargs["app_config"] = resolved_app_config
tools = get_available_tools(**available_tools_kwargs)摘录显示子 Agent 继承父级 tool_groups,并显式设置 subagent_enabled=False、include_upload_tool=False。前者防止递归 task,后者避免独立 ThreadState 在缺少当前 uploaded_files 时错误判断上传范围。
选择性继承还包括 sandbox/thread identity、user/auth、run/trace 与固定的 Extension snapshot;它不复制完整父对话。Lead Agent 传入一段明确 task prompt,子 Agent 在自己的消息状态中工作,再把结果和状态事件汇回父工具调用。
因此 Subagent 隔离的是推理上下文和执行循环,Sandbox 隔离的是副作用环境。两者可以共享受限 Sandbox 身份,但这不等于共享完整 ThreadState,也不等于完全资源隔离。
Subagent 重新建图,却不是一条可恢复的 child Thread
SubagentExecutor 会按选定类型解析模型、工具、Middleware 和 Skill,重新调用 create_agent。它不是让 Lead Agent 在同一 messages 数组中换一段 system prompt,也不是复用父 graph 的下一节点。
子 graph 使用新的 ThreadState 与初始 messages,保留选定的运行身份和资源;callbacks 可以复制,但会移除绑定事件循环的 RunJournal,避免重复记账和跨 loop future。结果通过 task 的 ToolMessage 与自定义 subagent events 返回。
最关键的边界写在 create_agent。Subagent 以 checkpointer=False 创建一次性执行上下文,没有 child checkpoint,也没有自己的可恢复 Thread 语义;业务 thread_id 仍通过 context 传递,不能据此推断它创建了一条子 Thread。
# system_prompt is included in initial state messages (see _build_initial_state)
# to avoid multiple SystemMessages which some LLM APIs don't support.
bound_tools = list(tools if tools is not None else self.tools)
agent = create_agent(
model=model,
tools=bound_tools,
middleware=middlewares,
system_prompt=None,
state_schema=ThreadState,
checkpointer=False,
)
self._describe_assembly(
app_config=app_config,
tools=bound_tools,
middlewares=middlewares,
deferred_setup=deferred_setup,
extensions=extensions if extensions is not None else self.extensions,
)
return agent代码卡中的 state_schema=ThreadState 表示子 graph 仍使用相同字段契约,checkpointer=False 则关闭子图持久化。_describe_assembly 记录它的构造来源,但不把一次委派升级为独立 Run。
正常完成、预算触顶、loop cap、timeout、取消和工具错误会被归一成兼容的 status,并可附加 stop_reason。容量触顶时应拒绝或等待,而不是无上限创建 Agent;取消是协作式的,外部副作用仍需由具体工具保证幂等或补偿。
[OpenAI Agents SDK 的官方多 Agent 文档](https://openai.github.io/openai-agents-python/multi_agent/)将 manager 保留最终回答的模式称为 agents-as-tools,而 handoff 会让 specialist 接管后续对话。DeerFlow 的 task 更接近前者:Lead Agent 接收工具结果并负责最终 sales-review,不把用户会话永久转交给子 Agent。
工具返回成功,不等于 Run 已经交付
普通 Tool 返回 ToolMessage,修改多字段状态的 Tool 可以返回 Command。写文件工具先在 Sandbox 的 outputs 中产生字节;这一步只证明副作用完成,并没有选择哪个文件进入产品界面。
present_files 校验并规范化 outputs 路径,再通过 Command.update 写入 artifacts 与成功 ToolMessage。ThreadState 的 reducer 对路径保持顺序并去重,所以同一文件被重复声明不会产生无限列表。
代码卡中的 normalized_paths 是交付声明,messages 是给模型看的调用结果。present_files 本身不读取文件确认存在,也不比较 Run 前后的 workspace;因此工具返回成功仍不能直接推出 RunStatus.success。
# The merge_artifacts reducer will handle merging and deduplication
return Command(
update={
"artifacts": normalized_paths,
"messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
},
)标准 Gateway 的 worker 不复用 normalized_paths 作为“交付已完成”的证明,而是在 graph 结束后比较 workspace snapshot,判断本次新增或修改的 output 是否被已声明 Artifact 覆盖,并尝试写 run.delivery receipt。Chapter 02 已讲过:settlement 失败可以把成功候选降为 error,相关持久化写入也可能失败后继续 terminal 尝试。
Artifact 生成属于本章的能力返回,Artifact 覆盖验证属于 Run 提交协议。把两者分开,才能解释“磁盘有文件但页面无结果”“页面有路径但本次没有产生对应 output”“工具成功但 Run 结算失败”三种不同故障。
侧栏:Skill、MCP 与 Extension 不在同一信任级别
Skill 主要为 Agent 提供任务说明和操作规范,激活后还会限制工具与 secret;它会以只读形式放进 Sandbox。MCP 暴露远程 Tool,仍要经过目录过滤、deferred schema、Skill policy 与执行 guardrail。
Python Extension 则是 operator 配置的 trusted host code。安装 hook、import、Gateway service 与 router 都以宿主权限执行;Middleware contribution 使用语义 placement,但 isolation 只是在普通 hook 失败时保护主链,不把恶意宿主代码变成低权限代码。
durable MCP task 也不能与普通 MCP tool 混写。只有配置为后台任务的那一类调用会进入 Agent loop 外的 task service、lease、retry 或 dead-letter 路径;普通 MCP 工具仍在当前 model-tool loop 内返回。
项目交流时可以这样区分:Skill 告诉 Agent 当前任务该怎样做,并限制它能用什么;MCP 提供远程工具;Extension 则直接扩展受信任的宿主进程。把三者都叫“插件”,就看不出它们拥有完全不同的权限,出错时影响的范围也不同。
面试时沿一项工具调用来讲
回到 sales-review:候选 Tool 先按来源汇合并去重,Principal 在装配期过滤目录;大量 MCP schema 先延迟,tool_search 只晋升当前 catalog hash 下、同时满足活动 Skill policy 的能力;真实调用再次经过 guardrail。
有副作用的工具在 per-user/thread Sandbox 中执行,lazy acquire 得到的 sandbox_id 通过 Command.update 回写 ThreadState。独立且收益明确的工作才使用 task;Subagent 重新建 graph、选择性继承身份和资源、禁止递归,并以 checkpointer=False 保持一次性。
文件返回还要走最后两步:write_file 产生字节,present_files 声明 Artifact,worker settlement 验证本次 output 与声明是否相符。能力越多并不自动意味着系统更强,真正的设计价值在于每次扩张都有可见性、权限、执行环境和交付边界。
- 90 秒表达:DeerFlow 把能力分成候选、授权、可见、执行、返回和交付六个状态。工具来源先去重,装配授权保护目录,运行时 guardrail 保护真实调用;
MCPschema 按catalog hash延迟晋升,Skillpolicy 同时过滤 schema、执行和 promotion;Sandbox用稳定身份承接副作用;Subagent是无 child checkpoint 的有界 agents-as-tools 委派;Artifact声明与 Run 交付结算保持分离。 - 边界一:enabled
Skill不等于 activatedSkill,发现能力不等于获得政策权限。 - 边界二:
release不等于销毁Sandbox;连续性来自身份、挂载和 provider 契约。 - 边界三:
Subagent不是子 Thread,present_files成功也不是 Run success。