数据截至 (上游 commit 3309bf4e416f)
03 — 工具系统与 MCP 双向桥
这章讲什么: 先看「一个工具最少要长什么样」,再看代表性工具各自的巧思, 最后看 OpenManus 怎么同时当 MCP 的客户端(用别人的工具)和服务端 (把自己的工具借给别人)。
1. 工具的最小契约
1.1 三个字段 + 一个方法
BaseTool 同时继承 ABC 和 BaseModel(app/tool/base.py:78-137):
| 成员 | 类型 | 作用 |
|---|---|---|
name | str | 模型调用时用的名字 |
description | str | 写给模型看的说明书,决定它会不会用对 |
parameters | dict | JSON Schema,描述入参 |
execute(**kwargs) | 抽象协程 | 真正干活的地方 |
加上一个把自己翻译成 OpenAI 格式的方法(app/tool/base.py:124-137):
def to_param(self) -> Dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
要点: description 和 parameters 是纯手写的字符串/字典,不是从 Python 类型
自动推导的。看 app/tool/str_replace_editor.py:37-100 就知道——那一大段说明文字
就是这个工具的全部「用户手册」,模型只能靠它判断怎么用。
1.2 ToolResult:可加、可判真、可替换
ToolResult 有四个字段:output、error、base64_image、system
(app/tool/base.py:38-75),外加三个小设计:
| 方法 | 行为 | 为什么有用 |
|---|---|---|
__bool__ | 任一字段非空即真 | if result: 直接判断有没有产出 |
__add__ | 逐字段拼接;base64_image 冲突时抛错 | 合并多段输出 |
__str__ | 有 error 就显示错误,否则显示 output | 直接 str(result) 喂给模型 |
子类 CLIResult 和 ToolFailure 只是语义标记,没有额外行为
(app/tool/base.py:176-181)。
1.3 ToolCollection:一张名字表
ToolCollection(app/tool/tool_collection.py:9-71)本质是
tools 元组 + tool_map 字典,提供三件事:
to_params()—— 把全表转成 schema 列表,交给模型。execute(name=..., tool_input=...)—— 按名字找工具并await tool(**tool_input); 找不到返回ToolFailure,ToolError也被转成ToolFailure(:25-35)。add_tool()—— 重名会被跳过并打警告(:56-58),不是覆盖。
最后这条在 MCP 场景很重要:两台服务器提供同名工具时,先连上的赢。
2. 代表性工具里的巧思
2.1 str_replace_editor:唯一匹配 + 环境无关
这是最值得读的一个工具(app/tool/str_replace_editor.py),五个命令:
view / create / str_replace / insert / undo_edit。
巧思一:替换必须唯一。 str_replace 先数出现次数(:297-314):
occurrences = file_content.count(old_str)
if occurrences == 0:
raise ToolError(f"No replacement was performed, old_str ... did not appear verbatim")
elif occurrences > 1:
raise ToolError(f"... Multiple occurrences of old_str ... in lines {lines}. Please ensure it is unique")
出现 0 次或多次都拒绝,并且多次时把行号列出来告诉模型该补多少上下文。 这是一种「宁可失败也不猜」的策略——和 Aider 那种多级模糊匹配是相反的取舍 (对比见 07 章)。
巧思二:改完回一段带行号的快照。 替换后截取改动点前后各 4 行
(SNIPPET_LINES = 4,:28)返回给模型(:325-338),让它自己核对改对没有。
巧思三:环境无关。 所有文件读写都走一个 operator,而 operator 是运行时选的
(:106-112):
def _get_operator(self) -> FileOperator:
return (
self._sandbox_operator
if config.sandbox.use_sandbox
else self._local_operator
)
同一份工具代码,配置一改就从「改本机文件」变成「改容器里的文件」 (详见 06 章)。
巧思四:撤销靠内存栈。 _file_history 是个 defaultdict(list)(:101),
每次改动前把旧内容 push 进去,undo_edit 就 pop 回写(:394-406)。
进程一退,历史全丢。
2.2 bash:一个不会退出的 shell 会话
难点是:每条命令都新起一个 bash 的话,cd 和环境变量就丢了。
_BashSession(app/tool/bash.py:16-113)的做法是开一个常驻的 bash 子进程,
用一个哨兵字符串判断「这条命令跑完了」:
_sentinel: str = "<<exit>>"
...
self._process.stdin.write(command.encode() + f"; echo '{self._sentinel}'\n".encode())
然后轮询直到输出里出现哨兵(app/tool/bash.py:82-93)。
代价是一处 hack: 它直接读了 asyncio 流的私有缓冲区
self._process.stdout._buffer(app/tool/bash.py:87-89),并在读完后手动
clear()(:110-111)。注释解释了原因——正常 read() 会一直等 EOF,而这个
shell 永远不会 EOF。有效,但依赖 CPython 私有实现。
2.3 python_execute:名字里的「安全」要打折看
工具 docstring 写的是「with timeout and safety restrictions」
(app/tool/python_execute.py:10),实际上:
-
超时是真的:另起一个
multiprocessing.Process,join(timeout)后还活着就terminate()(:61-73)。 -
隔离是假的:所谓
safe_globals其实是完整的内置命名空间副本(:57-60):safe_globals = {"__builtins__": __builtins__.__dict__.copy()}open、__import__、os全都可用。真正的隔离要靠 06 章 的 Docker 沙箱,而PythonExecute不走沙箱。
还有一个使用上的坑:它只捕获 stdout,函数返回值看不见——所以 description 里
特意写了「Only print outputs are visible」(:13)。
2.4 web_search:四引擎降级链
WebSearch 内置 Google / Baidu / DuckDuckGo / Bing 四个引擎
(app/tool/web_search.py:193-198),排序规则在 _get_engine_order(:360-385):
配置的首选引擎
│ 失败
▼
配置的 fallback 列表(默认 DuckDuckGo → Baidu → Bing)
│ 全失败
▼
剩下没试过的引擎
│ 还是全失败
▼
等 retry_delay 秒(默认 60),整轮重来,最多 max_retries 次(默认 3)
单个引擎调用本身还包了一层 tenacity 重试(:387-389)。
三层容错是因为免费搜索接口的限流实在太常见。
注意: WebSearch 虽然在 app/tool/__init__.py:9 里导出,
但没有被任何智能体装进工具表。同样情况的还有 Crawl4aiTool 和
ComputerUseTool——搜索与浏览现在统一走 MCP 的 Browser Use。
2.5 ask_human:一条同步的 input()
最短的工具(app/tool/ask_human.py:20-21),就是一句 input()。
它是阻塞的同步调用,会卡住整个事件循环——在 CLI 场景无所谓,
搬进服务端就得换掉。
3. MCP 是什么(一句话)
MCP(Model Context Protocol,模型上下文协议) 是 Anthropic 提的一套标准: 工具提供方跑一个「服务器」,agent 当「客户端」连上去,协议规定了 「怎么列出工具、怎么调用工具、怎么回传结果」。
好处很直接:工具不用写进 agent 的代码库。别人写好一个 MCP 服务器, 你连上就能用。
OpenManus 两头都做了:
别人的 MCP 服务器 别人的 agent
(浏览器 / 数据库 / …) │
▲ │ MCP 协议
│ MCP 协议 ▼
┌──────┴───────┐ ┌────────────────┐
│ MCPClients │ OpenManus │ MCPServer │
│ 客户端 │ ───────────────── │ 服务端 │
└──────────────┘ └────────────────┘
把远程工具变成 把 bash/editor/terminate
本地 BaseTool 暴露成 MCP 工具
4. 客户端:MCPClients
4.1 关键设计:它就是一张工具表
class MCPClients(ToolCollection):
app/tool/mcp.py:49 这一行是整个集成的支点。因为 MCPClients 继承 了
ToolCollection,远程工具和本地工具落在同一张 tool_map 里,
ToolCallAgent 完全不需要知道哪个是远程的。
每个远程工具被包成 MCPClientTool(app/tool/mcp.py:14-46),它的 execute
就是一次 RPC:
result = await self.session.call_tool(self.original_name, kwargs)
返回内容里的 TextContent 拼成字符串、ImageContent 取第一张塞进 base64_image
(app/tool/mcp.py:29-44)——正好对上 02 章 讲的图片回传路径。