数据截至 (上游 commit 3309bf4e416f)
07 — 巧妙之处、边界与局限、横向对比
这章讲什么: 前六章是「它怎么转」,这一章是「你能带走什么」和「别踩什么」。 所有条目都锚到具体代码。
1. 巧妙之处(可借鉴的技术)
1.1 错误也是观察,不是异常
妙在哪: agent 的每一次工具失败,如果直接抛异常,整个任务就断了。
OpenManus 把所有失败都转成字符串返回给模型:工具名不认识、参数不是合法 JSON、
工具内部抛异常,三种情况都变成一句 Error: …(app/agent/toolcall.py:207-216)。
模型看到错误信息,下一轮就有机会自己改参数重试。这是 agent 容错的第一原则, 代码里只有十几行,但没有它 agent 根本跑不完一个复杂任务。
同一原则在 ToolCollection.execute 里也出现了一次:ToolError 被转成
ToolFailure 而不是往上抛(app/tool/tool_collection.py:34-35)。
1.2 从 JSON Schema 反向合成 Python 函数签名
妙在哪: FastMCP 靠函数的类型注解生成工具描述,而 OpenManus 的工具把描述
写在 parameters 字典里。两者对不上。
解法不是给每个工具手写一个适配函数,而是造一个通吃的 **kwargs 函数,
再伪造它的 __signature__ 和 __doc__(app/mcp/server.py:56-58):
tool_method.__name__ = tool_name
tool_method.__doc__ = self._build_docstring(tool_function)
tool_method.__signature__ = self._build_signature(tool_function)
_build_signature(app/mcp/server.py:98-134)把 string→str、array→list
这类映射反着走一遍,造出 inspect.Parameter 列表。
任何 BaseTool 都能零改动变成 MCP 工具。
这个技巧在任何「schema 驱动 + 需要真实函数签名」的场景都能复用。
1.3 用 Protocol 让「在哪跑」变成配置
妙在哪: StrReplaceEditor 有 432 行,里面没有一行 open()、没有一行 subprocess。
所有 I/O 都走 operator,而 operator 在运行时按配置选
(app/tool/str_replace_editor.py:106-112)。
于是「文件改在本机还是改在容器里」变成一个布尔配置,工具代码完全不知情。
对比一下:如果当初在工具里写死 Path(path).read_text(),
后来要加沙箱就得把每个工具改一遍。
1.4 MCP 客户端直接继承工具表
妙在哪: 一行代码就把「远程工具」和「本地工具」统一了
(app/tool/mcp.py:49):
class MCPClients(ToolCollection):
因为 MCPClients 就是一张 ToolCollection,ToolCallAgent 遍历工具表时
完全分不出哪个是远程的。Manus 只需要把新工具 add_tools 进去
(app/agent/manus.py:152-156)。
架构含义: 「加能力」从「写代码」降级成「改配置」。浏览器能力就是这么从
内置工具变成 MCP 服务器的——app/tool/ 下已经没有 browser_use_tool.py 了。
1.5 图片单独开 一条 user 消息
妙在哪: OpenAI 的 tool 角色消息不支持多模态内容块。
工具(比如截图)返回的图片如果硬塞进 tool 消息就会被拒。
OpenManus 的做法是:tool 消息只放文字,图片另起一条 user 消息
(app/agent/toolcall.py:162-168),内容写「Image returned by <工具名>:」。
模型照样能把图和工具对上号。
1.6 改计划时按「同位置同文本」保留进度
妙在哪: 允许模型中途修订计划,但已完成的步骤不会被误重置
(app/tool/planning.py:192-199)。这是「可变计划」这类设计的必备细节——
少了它,模型每改一次计划,进度就清零一次。
2. 边界与局限(诚实清单)
2.1 设计上刻意不做的
| 不做什么 | 证据 |
|---|---|
| 记忆压缩/摘要 | Memory 只有一个 100 条的滑窗(app/schema.py:161-168) |
| 上下文管理 | 唯一手段是按字符数截断工具输出(app/agent/toolcall.py:148-149) |
| 任务持久化 | 计划存在实例字典里(app/tool/planning.py:69),进程退出即失 |
| 智能体间直接通信 | PlanningFlow 只做串行派活,agent 之间不交换消息 |
| 模糊代码编辑 | str_replace 要求唯一精确匹配(app/tool/str_replace_editor.py:297-314) |
| 并行工具调用 | act() 里是 for 循环顺序执行(app/agent/toolcall.py:142-169) |
2.2 代码里真实存在的缺口
下面每一条都能在源码里核对,按影响排序:
① run() 返回后状态恒为 IDLE。
state_context 的 finally 无条件还原状态(app/agent/base.py:81-82),
所以 FINISHED 和 ERROR 都不会活着离开 run()。
直接后果:PlanningFlow 在 app/flow/planning.py:128 那个
「执行者想提前收工就跳出」的判断不会命中。
② 默认模型不在多模态名单里,图片被静默丢弃。
MULTIMODAL_MODELS(app/llm.py:35-42)是硬编码的旧名单,
而示例配置默认用 claude-3-7-sonnet-20250219(config/config.example.toml:2)。
于是 format_messages 走 del message["base64_image"] 分支
(app/llm.py:337-339),没有任何日志。浏览器截图对模型来说等于不存在。
③ 重试装饰器的注释与行为不符。
注释写「Don't retry TokenLimitExceeded」,但 retry_if_exception_type 里包含
Exception(app/llm.py:354-360),而 TokenLimitExceeded 是 Exception 的子类
(app/exceptions.py:8-14)。所以它会被重试 6 次、退避最长每次 60 秒,
之后包成 RetryError 才被上层识别(app/agent/toolcall.py:61)。
④ 计划步骤无条件被标成完成。
只要 executor.run() 没抛异常就打勾(app/flow/planning.py:296-301),
哪怕智能体跑满步数一事无成。blocked 状态没有任何代码会写入。
⑤ 步数预算跨 run() 累加。
current_step 只在撞 max_steps 时归零(app/agent/base.py:149-151),
所以 PlanningFlow 里越靠后的步骤可用步数越少。
⑥ python_execute 没有真正的隔离。
所谓 safe_globals 是完整的内置命名空间副本
(app/tool/python_execute.py:57-60),open、__import__ 全可用,
而且不走沙箱。工具描述里的 "safety restrictions" 实际只有超时。
⑦ 沙箱模式下退出码和 stderr 是假的。
SandboxFileOperator.run_command 恒返回 (0, stdout, "")
(app/tool/file_operators.py:148-152)。
⑧ A2A 协议入口在本 commit 是坏的。
protocol/a2a/app/main.py:13 仍然
from app.tool.browser_use_tool import _BROWSER_DESCRIPTION,
但 app/tool/browser_use_tool.py 已经不存在(浏览器能 力迁到了 MCP)。
这个入口目前 import 就会失败。
⑨ 三个工具被导出但没接线。
WebSearch、Crawl4aiTool、ComputerUseTool 都在 app/tool/__init__.py
里导出,却不在任何智能体的 available_tools 中。
⑩ SandboxManager 写好了没人用。
313 行的多容器管理器(app/sandbox/core/manager.py)只被测试引用,
生产路径走的是单例 SANDBOX_CLIENT。
2.3 会在哪儿崩
| 场景 | 现象 |
|---|---|
没装 uvx / 没网 | Browser Use MCP 连接失败,只打一条 error 日志继续跑(app/agent/manus.py:98-99),于是没有浏览器能力 |
| 记忆滑窗切在 tool_calls 与 tool 消息之间 | 服务端拒绝请求(inferred,app/schema.py:163-175 无保护) |
ask_human 被触发但不在交互终端 | input() 阻塞整个事件循环(app/tool/ask_human.py:21) |
未装 Docker 却开了 use_sandbox | docker.from_env() 抛错(app/sandbox/core/sandbox.py:45) |