第 3 篇:「Pydantic 即 Schema」—— 工具生态三层解剖

第 3 篇:「Pydantic 即 Schema」------ 工具生态三层解剖

系列 :OpenManus 源码级深度解读(master @ 3309bf4e416fb1c74b008f3e86494439a31bad53

本篇覆盖 :DeepWiki ch5(Tool Ecosystem)主链路:5.1 BaseTool and ToolCollection / 5.4 PythonExecute / 5.6 StrReplaceEditor(浏览器 5.2、搜索 5.3、可视化 5.5 留给 04 篇)

核心源码app/tool/base.py(181 行)、app/tool/tool_collection.py(71 行)、app/tool/python_execute.py(75 行)、app/tool/bash.py(158 行)、app/tool/str_replace_editor.py(432 行)、app/tool/planning.py(363 行)

阅读本文你将了解: 一个 Python 类如何靠三个类属性自动变成 OpenAI function calling 的工具 schema;ToolCollection 为什么用 tuple 存工具;PythonExecute 的 "safe_globals" 名不副实、隔离全靠子进程;Bash 的 sentinel 机制与 asyncio 私有缓冲 hack;文件编辑工具的撤销栈如何实现。


1. 工具层在 Harness 里的位置

01 篇讲过,ToolCallAgent.think() 每一步把 available_tools.to_params() 塞进 LLM 请求,act() 按模型返回的 tool_call 名字去执行工具。工具层就是 Agent 的"手":模型只产出 JSON,真正改动世界(跑代码、改文件、开浏览器)全在这层发生。

OpenManus 的工具层是典型的三层结构BaseTool 定义单工具契约(181 行),ToolCollection 管理工具集合(71 行),20+ 个具体工具各自一个文件。三层加起来不到 800 行(不含具体工具实现),却撑起了整个 Agent 的行动能力------这个密度是解剖的好材料。

类图里有三个值得停留的结构信号:BaseTool 同时继承 ABCBaseModel,工具类属性就是工具定义 ,不需要任何注册器;ToolResult 家族的 CLIResult/ToolFailure 是空壳标记类,全部行为在父类;ToolCollection 反而不继承 Pydantic,是普通类------集合只做容器,不参与 schema 校验。下面逐层拆。

2. BaseTool:三个类属性就是一份工具 schema

BaseTool 的全部契约只有三个字段和一个抽象方法:

python 复制代码
# app/tool/base.py:94-96, 120-137
class BaseTool(ABC, BaseModel):
    name: str
    description: str
    parameters: Optional[dict] = None

    @abstractmethod
    async def execute(self, **kwargs) -> Any:
        """Execute the tool with given parameters."""

    def to_param(self) -> Dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters,
            },
        }

to_param() 的输出(app/tool/base.py:130-137)正是 OpenAI function calling 的 tools 数组元素格式。这意味着写一个新工具 = 写一个带 name/description/parameters 三个类属性的 Pydantic 子类 ,schema 生成、请求装配全部免费。看 PythonExecute 的完整定义(app/tool/python_execute.py:9-23):类体前 15 行就是给 LLM 看的全部接口,parameters 是一份手写的 JSON Schema,LLM 按 required: ["code"] 填参数。

这是"配置即接口"模式最朴素的形式。对比 LangChain 的 @tool 装饰器(从函数签名和 docstring 自动推 schema)和 Claude Code 的内部工具注册表,OpenManus 选择了手写 JSON Schema:啰嗦(每个工具 20-40 行 parameters 字典),但零魔法、所见即所得,LLM 看到的描述和源码里的字符串一字不差------排查"模型为什么理解错参数"时,这是巨大的调试优势。

文件顶部有一大段被注释掉的代码(app/tool/base.py:10-35, 103-145):一个基于装饰器的多方法 schema 注册方案(_register_schemastool_schemas 属性),被整体废弃成了现在的一个工具一个类。这段注释是设计演化的化石------多方法注册省了类数量,但把 schema 声明藏进了装饰器元数据;对以"可读可改"为核心诉求的教学型框架,当前的笨办法反而是对的。

3. ToolResult:四个字段打天下

工具执行结果统一走 ToolResultapp/tool/base.py:38-75),四个字段各有分工:

  • output:主输出,Any 类型(字符串为主)
  • error:错误文本(注意:错误也是结果不是异常,见第 5 节双层容错)
  • base64_image:截图旁路。浏览器工具截图后塞这里,ToolCallAgent.act() 把它转成 user 消息的 base64 附带(app/agent/toolcall.py:162-171,01 篇第 7 节)
  • system:系统级提示(如 "tool has been restarted"),与工具输出语义分开

两个 dunder 值得点破:__bool__ 按四字段任一非空判定真值(app/tool/base.py:49-50),所以 if result: 在工具代码里遍地都是;__add__ 定义了结果合并语义(app/tool/base.py:52-67)------output/error 字符串拼接,base64_image 不允许拼接(两图合一无意义,直接 raise),这个细节说明它被设计给"一个工具连续多次调用结果合并"的场景(Bash 会话读多次输出用到了)。

CLIResultToolFailureapp/tool/base.py:176-181)是零实现的标记子类,作用只在类型可读性:看到 CLIResult 就知道输出是终端文本,看到 ToolFailure 就知道集合层捕获了 ToolError

4. ToolCollection:71 行的容器哲学

ToolCollectionapp/tool/tool_collection.py:9-71)只有一个值得说的设计决策:工具存 tuple 不存 list

python 复制代码
# app/tool/tool_collection.py:15-17
def __init__(self, *tools: BaseTool):
    self.tools = tools
    self.tool_map = {tool.name: tool for tool in tools}

构造入参是 *tools 可变参数,存成 tuple 后,后续 add_tool 只能整体重建(self.tools += (tool,)app/tool/tool_collection.py:60------tuple 的 += 产生新元组)。没有任何删除工具的 API。这个约束让"Agent 拥有哪些工具"在运行期是只增不减 的,配合 01 篇讲过的 Manus 重连 MCP 时"用非 MCP 工具重建整个 ToolCollection"(app/agent/manus.py:181-188),可以看出作者的意图:单个工具不可变,集合整体可替换,避免运行期部分增删导致的状态不一致。

execute() 的容错只捕 ToolError

python 复制代码
# app/tool/tool_collection.py:25-35
async def execute(self, *, name, tool_input=None) -> ToolResult:
    tool = self.tool_map.get(name)
    if not tool:
        return ToolFailure(error=f"Tool {name} is invalid")
    try:
        result = await tool(**tool_input)
        return result
    except ToolError as e:
        return ToolFailure(error=e.message)

两个细节:其一,工具名不存在返回 ToolFailure 而不是抛异常 ------模型幻觉出一个不存在的工具名时,Agent 收到的是错误文本,下一轮 think 还能自我纠正,这正是 ReAct 自愈设计的一部分;其二,ToolError 之外的异常(如网络错误)会直接穿透集合层,由更上层的 ToolCallAgent.execute_tool() 的兜底 except 接住转成字符串观察值(app/agent/toolcall.py:207-216)。双层容错网:集合层接"业务错误"(ToolError → ToolFailure),Agent 层接"一切意外"------分工清晰,代价是错误经过的层数变多,日志排查时要在两处对照。

add_tool 的同名去重(app/tool/tool_collection.py:56-58)是防 MCP 重连风暴的关键:MCP 客户端重连时会再次 add 全部远程工具,同名跳过 + warning 保证了工具列表不会翻倍。

5. PythonExecute:名为沙箱,实为进程隔离

PythonExecute 是默认四工具之一(app/agent/manus.py:57-64),它的实现藏着本篇最大的认知纠偏:

python 复制代码
# app/tool/python_execute.py:55-64
with multiprocessing.Manager() as manager:
    result = manager.dict({"observation": "", "success": False})
    if isinstance(__builtins__, dict):
        safe_globals = {"__builtins__": __builtins__}
    else:
        safe_globals = {"__builtins__": __builtins__.__dict__.copy()}
    proc = multiprocessing.Process(
        target=self._run_code, args=(code, result, safe_globals)
    )
    proc.start()
    proc.join(timeout)

变量名叫 safe_globals,但它把完整的 __builtins__ 原样传给了 exec ------open__import__eval 全部可用。也就是说 LLM 生成的代码可以读你的文件系统、发网络请求、删目录。"safe" 只 safe 在进程边界 :代码在独立子进程里跑,join(timeout) 超时后 terminate() 强杀(app/tool/python_execute.py:68-74),主进程的内存和事件循环不受影响,仅此而已。

这个设计对"本机个人使用"是成立的(Agent 本来就有 StrReplaceEditor 能直接改文件,Python 代码并没有更大的攻击面),但所有把它当沙箱用的部署都应该清醒:真正的隔离要等 06 篇的 Docker 沙箱app/sandbox/core/),use_sandbox=True 之后才存在边界。变量命名带来的安全感是假象,这是读源码的价值所在。

把一次执行的完整时序画出来,"隔离边界在哪里生效、哪里失效"会一目了然:

时序图暴露了两个安全事实:一是传给子进程的 safe_globals(:61-64)携带完整 builtins,子进程里 open()__import__() 与主进程同权限,隔离只覆盖内存与生命周期 ,不覆盖文件系统与网络;二是超时路径(右分支)的 terminate() 只杀子进程本身,如果 LLM 代码又 fork 了孙进程,是否被连带清理取决于平台信号语义------这是它和 Docker 沙箱在进程组管理上的本质差距。共享结果字典用 Manager().dict(:56)是标准解法,但每条命令都建一个 Manager 进程的开销(约几十毫秒)在高频调用场景不可忽略。

进程隔离的两个实现细节:stdout 重定向用 StringIO 替换 sys.stdout 抓 print 输出(app/tool/python_execute.py:26-31),所以工具描述里特意写明 "Only print outputs are visible"(app/tool/python_execute.py:13)------函数返回值不进观察值,模型必须 print,这是用提示词补实现约束的例子;跨进程结果用 multiprocessing.Manager().dict() 共享字典(普通 dict 传参会被子进程 copy-on-write 隔离,写回丢失),这是 multiprocessing 的标准坑,实现者绕对了。

6. Bash:sentinel 协议与私有缓冲 hack

Bash 工具维持一个长驻 shell 会话(_BashSessionapp/tool/bash.py:16-113),让"cd 到目录再跑命令"这类有状态操作成为可能。它的读取协议是手工设计的:

python 复制代码
# app/tool/bash.py:75-93(节选)
self._process.stdin.write(
    command.encode() + f"; echo '{self._sentinel}'\n".encode()
)
await self._process.stdin.drain()
...
async with asyncio.timeout(self._timeout):
    while True:
        await asyncio.sleep(self._output_delay)
        output = self._process.stdout._buffer.decode()
        if self._sentinel in output:
            output = output[: output.index(self._sentinel)]
            break

给命令末尾追加 ; echo '<<exit>>' 作为哨兵(app/tool/bash.py:25, 76),读到哨兵即认为命令结束。这个协议有两个已知弱点:命令自身输出 <<exit>> 字符串会被误判提前截断;轮询间隔 0.2 秒(_output_delayapp/tool/bash.py:23)意味着每条命令至少 200ms 的读取延迟。

更值得讨论的是 stdout._buffer 这一行------直接读 asyncio.StreamReader私有缓冲区 而不是 read()。注释自己解释了原因(app/tool/bash.py:85-86):直接 read 会等到 EOF 永久阻塞,因为 shell 会话从不关闭流;_buffer 可以非阻塞地偷看已到达的数据。这是绕过 asyncio 公共 API 的 hack,依赖 CPython 内部实现(代码里留着 pyright: ignore 注释,app/tool/bash.py:88),Python 升级可能破坏它。工程上不推荐模仿,但它准确演示了"流式子进程 + 按标记分帧"这个通用问题的痛点:stdlib 没有一等公民方案,PTYT/pexpect 类库才把这事做对。

超时策略也和 PythonExecute 不同:Bash 超时(120 秒,app/tool/bash.py:24)不杀进程,而是置 _timed_out 标志整个会话报废 ,必须显式 restartapp/tool/bash.py:64-67, 94-98)------因为 setsid 建立的进程组(app/tool/bash.py:37)里可能有前台子进程活着,状态已不可信。工具描述里教模型"超时后转后台运行"(app/tool/bash.py:12),同样是提示词补实现约束。

7. StrReplaceEditor:唯一性校验与撤销栈

文件编辑工具(app/tool/str_replace_editor.py,432 行)是五个 command 的状态机:view / create / str_replace / insert / undo_edit。三个机制值得拆:

唯一性硬校验str_replace 先数 old_str 出现次数,0 次报错,大于 1 次拒绝执行并列出所有出现行号app/tool/str_replace_editor.py:298-314)。这是从 Anthropic 内部工具(该工具描述与 claude-code 的 Edit 工具同源)继承的安全阀:模型截取上下文不足时,盲目替换会破坏文件的不相关部分,拒绝 + 行号提示让下一轮模型能补足上下文重试。

撤销栈按路径隔离_file_history: DefaultDict[PathLike, List[str]]app/tool/str_replace_editor.py:101)在每个写操作前把整个旧文件内容 压栈(create 在 :140、str_replace 在 :323、insert 在 :381),undo_edit 弹栈回写(app/tool/str_replace_editor.py:394-406)。全量快照而非 diff,简单粗暴但正确;代价是内存无上限------反复编辑大文件时栈会吃掉几倍文件体积的内存,且没有跨会话持久化,Agent 重启撤销链即断。

双 operator 抽象 。所有文件操作经 FileOperator 接口分发,本地模式用 LocalFileOperator,沙箱模式用 SandboxFileOperator,选择发生在每次 execute 时读全局配置(app/tool/str_replace_editor.py:106-112)。这层抽象是 06 篇沙箱系统的伏笔:同一套工具语义,两套执行后端,Docker 沙箱开启后编辑工具自动把读写转发进容器,上层 Agent 代码零改动。这是整个仓库里最值得抄的架构模式。

输出侧的防护是 16000 字符截断 + 特制的续读提示(app/tool/str_replace_editor.py:29-34):被截断的响应会附带"用 grep -n 定位行号后重试"的指令------又一次提示词补实现约束,教模型绕开自己的限制而不是静默丢失信息。

8. PlanningTool:状态长在工具上

PlanningToolapp/tool/planning.py:14-70)提供 create/update/list/get/set_active/mark_step/delete 七个命令,管理带状态(not_started/in_progress/completed/blocked)的计划步骤。它的独特之处在于状态存在工具实例的字段里

python 复制代码
# app/tool/planning.py:69-70
plans: dict = {}  # Dictionary to store plans by plan_id
_current_plan_id: Optional[str] = None

工具通常被设想为无状态函数,但计划工具必须记住历史------OpenManus 的解法是把 dict 直接挂在 Pydantic 工具实例上。后果有两面:好的一面是 07 篇的 PlanningFlow 能直接构造一个 PlanningTool 实例,把"计划"当作 Flow 与 Agent 之间的共享内存使用,完全不需要外部存储;坏的一面是状态生命周期 = 工具实例生命周期,进程重启计划全丢,多进程部署时各进程的 plans 互不可见。07 篇展开它的编排骨牌效应。

9. 生产视角:三件事

第一,别把 PythonExecute 当安全边界 。公网可达的部署必须 use_sandbox=True(Docker 路线,06 篇)或迁移到 Daytona 云沙箱。默认配置下 LLM 生成代码以你的用户权限全量执行(第 5 节),这不是 bug,是明确的设计前提,但部署者必须知道。

第二,工具描述是提示词工程的一部分PythonExecute 的 "Only print outputs are visible"(app/tool/python_execute.py:13)、Bash 的超时后台化教学(app/tool/bash.py:10-12)、StrReplaceEditor 的 old_str 唯一性说明(app/tool/str_replace_editor.py:44-47)------三处都在用 description 教模型规避实现限制。改工具行为时同步检查描述文案,否则模型行为和工具实现在两个仓库版本间漂移。

第三,监控工具失败率而非工具异常率 。由于错误被两级容错网转成了文本观察值(第 4 节),进程里几乎看不到工具相关的异常日志。要观测工具健康度,需要在 ToolCollection.executeToolCallAgent.execute_tool 两处分别埋点,统计 ToolFailure 占比与兜底 except 命中次数------前者代表模型用错工具,后者代表工具本身崩溃,归因完全不同。

10. 进阶视角:加一个工具的最小闭环

给 OpenManus 加自定义工具只需四步:继承 BaseTool 填三个类属性 + 实现 execute()(参照 app/tool/python_execute.py:9-23 的最简形态)→ 在 Agent 子类覆盖 available_toolsadd_tool()(同名去重保护你,app/tool/tool_collection.py:51-62)→ 如需特殊终止语义,把工具名加进 special_tool_names(参照 Terminate,app/agent/toolcall.py:218-226)→ 如果返回截图,填 ToolResult.base64_image 走视觉旁路(app/agent/toolcall.py:162-171)。不需要碰任何框架代码------这就是"Pydantic 即 Schema"红利的兑现方式。

与同类对比:Claude Code 的工具层与 OpenManus 同源演化(StrReplaceEditor 的描述文本几乎逐字一致),但多了权限确认层与更严的路径校验;LangChain 的 BaseTool 走函数签名反射路线,schema 自动化程度高但调试时多一层转换。OpenManus 的手写 schema 处在"最笨但最透明"的一端------适合作为理解工具协议的教学基准,也适合 schema 质量要求极端可控的生产场景。

11. 小结与承上启下

工具生态三层各有一个"性格":BaseTool 用 Pydantic 类属性把工具定义压到零仪式,ToolCollection 用 tuple 与只增不减守住运行期稳定,具体工具用"实现 + 描述文案"双轨教模型正确使用自己。同时它诚实地暴露了三个边界:PythonExecute 的隔离只在进程层(app/tool/python_execute.py:57-64),Bash 的分帧协议依赖 asyncio 私有实现(app/tool/bash.py:87-89),PlanningTool 的状态活不过进程重启(app/tool/planning.py:69-70)。

下一篇走出"本机":OpenManus 怎么用 20 行配置拉起浏览器(答案是无配置时自动 uvx browser-use --cli-mcp 起一个 MCP 插件)、四引擎搜索的回退链怎么设计、以及 Python 图表为什么绕道 Node.js 渲染。

关键源码事实:

# 事实 位置
1 BaseTool = ABC + BaseModel,三属性即 schema,to_param 转 OpenAI 格式 app/tool/base.py:94-137
2 文件头部废弃的装饰器多方法注册方案(设计演化化石) app/tool/base.py:10-35, 103-145
3 ToolResult 四字段:output/error/base64_image/system,add 禁止合并图片 app/tool/base.py:38-67
4 ToolCollection 存 tuple,只增不减,无删除 API app/tool/tool_collection.py:15-17, 60
5 集合层只捕 ToolError 转 ToolFailure;其他异常穿透到 Agent 层兜底 app/tool/tool_collection.py:25-35(对照 app/agent/toolcall.py:207-216
6 add_tool 同名去重(MCP 重连防翻倍) app/tool/tool_collection.py:51-62
7 PythonExecute 的 safe_globals 含完整 builtins,隔离仅在子进程 + 5s 超时 app/tool/python_execute.py:55-74
8 跨进程结果用 Manager().dict()(普通 dict 写回丢失) app/tool/python_execute.py:55-56
9 Bash 用 sentinel 分帧(<> 可被输出伪造),读 asyncio 私有 _buffer app/tool/bash.py:25, 75-93
10 Bash 超时不杀进程,会话报废需 restart;str_replace 唯一性校验拒绝歧义替换 app/tool/bash.py:64-67(对照 app/tool/str_replace_editor.py:298-314
11 撤销栈 = 按路径的全量内容快照,内存无上限、不持久化 app/tool/str_replace_editor.py:101, 323, 394-406
12 双 FileOperator:本地/沙箱后端按配置热切换,上层零改动 app/tool/str_replace_editor.py:106-112
13 PlanningTool 状态挂实例字段,进程重启即丢 app/tool/planning.py:69-70
14 16000 字符截断附带 grep -n 续读提示(提示词补实现约束) app/tool/str_replace_editor.py:29-34
相关推荐
恋猫de小郭1 小时前
Flutter 状态管理基准测评,一个很有趣的观点
android·前端·flutter
雪芽蓝域zzs2 小时前
前端编辑组件wangEditor
前端
LayZhangStrive2 小时前
Agent开发 - 实现人类与Manus智能体的终端窗口命令交互
ai·交互·agent·react·终端·manus
木圭的AI时代指南2 小时前
为了跑星火Spark-X2.5,我把 llama.cpp 重新编译了一遍
大数据·人工智能·ai·语言模型
风骏时光牛马2 小时前
稳定性治理及疑难问题深度剖析与实战复盘
前端
Web极客码2 小时前
Pydantic 校验通过不等于答案正确:如何识别 LLM 的语义错误
服务器·人工智能·ai·llm
兜里只有三分钱~2 小时前
【SenseNova U1.5 Lite实战】AMD ROCm 192G显存部署全流程与性能调优
ai·日日新·sensenova
JeffongTan10 小时前
在LWC中镶嵌VF Page获取用户IP
前端·javascript·salesforce