跳到主要内容

数据截至 (上游 commit dad6f5196773)

04 · 工具体系:一个 builtin tool 从声明、渲染到落地执行

本章讲什么: 模型吐出 {"name": "lobe-web-browsing____search", "arguments": "{...}"} 之后发生的一切——这段声明长什么样、由谁执行、执行完怎么变成界面上那张搜索结果卡片、跑不动时谁来拦。

不讲什么: "这一轮该把哪些工具塞进 prompt" 属于装配问题,在 上下文工程;"工具结果回填后循环怎么继续转" 在 运行时内核


1. 先建立直觉:一个工具由几块拼起来

大多数框架里,"一个工具" 就是一个函数加一段 JSON Schema。LobeHub 不是——它的一个工具是一整个 npm 包

原因很实际:同一个工具要在四个地方出现——喂给模型的声明、真正执行的代码、聊天流里的结果卡片、右侧栏的详情页。把它们放进同一个包,增删一个工具就只动一个目录。

一个工具包的四块内容:

白话面向谁
manifest工具的名片:有哪些 API、每个 API 收什么参数模型
systemRole工具自带的一段 system prompt:该怎么用我模型
ExecutionRuntime真正干活的类:发请求、读文件、跑命令机器
client/一组 React 组件:结果长什么样

怎么读这张图: 从上往下是一次调用的时间顺序,①②③④ 是四个阶段;最下面一行是本节的重点——结果分两条路走。

┌──────────────┐
│ manifest │──① 声明喂进 prompt ──▶ 模型
│ + systemRole │
└──────────────┘ │
│ ② 模型说要调 search

┌──────────────────┐ ┌───────────────┐
│ ExecutionRuntime │◀── ③ 派活 ─│ 注册/调度层 │
│ (真正干活) │ └───────────────┘
└──────────────────┘ │
│ │
│ ④ {content, state, success} │
▼ ▼
content ──▶ 回给模型 state ──▶ client/ 组件 ──▶ 人看到的卡片

一个关键设计要先记住:执行结果一分为二。 content 是字符串,给模型看;state 是结构化对象,给 UI 看。同一次调用,模型读到的是压缩过的 XML,人看到的是带图标和缩略图的卡片,两者互不干扰。


2. 样板解剖:packages/builtin-tool-web-browsing

拿联网搜索这个包逐文件看。它有三个 API:search / crawlSinglePage / crawlMultiPages

包的对外出口就是三层隔离,写在 packages/builtin-tool-web-browsing/package.json:5-9:

入口内容谁 import
.manifest + types + systemRole装配层、服务端
./client全部 React 组件前端注册层
./executionRuntimeWebBrowsingExecutionRuntime服务端 runtime、前端 executor

这样服务端 import 工具声明时,不会把一堆 .tsx 拖进 Node 打包。

2.1 manifest.ts —— 给模型看的名片

WebBrowsingManifest(packages/builtin-tool-web-browsing/src/manifest.ts:7)就是一个普通对象,类型是 BuiltinToolManifest

它的 api 数组每一项都是标准 JSON Schema:

// packages/builtin-tool-web-browsing/src/manifest.ts:12-35 节选
{
name: WebBrowsingApiName.search,
parameters: {
properties: {
query: { description: 'The search query', type: 'string' },
searchTimeRange: { enum: ['anytime', 'day', 'week', 'month', 'year'], type: 'string' },
},
required: ['query'],
type: 'object',
},
}

parameters 原样就是模型 function calling 的入参 schema,没有 zod 转换、没有编译期生成。

manifest 类型本身(packages/types/src/tool/builtin.ts:231 BuiltinToolManifest)只有七个字段,值得记住的有四个:

字段作用定义处
apiAPI 列表,每项一个 JSON Schemabuiltin.ts:190
executors这个工具能在哪跑('client' / 'server'),省略即服务端builtin.ts:202
humanIntervention工具级默认审批策略builtin.ts:210
systemRole工具自带的 prompt 片段builtin.ts:221

单个 API 的配置项(LobeChatPluginApi,builtin.ts:136)还多两个实用字段:defaultTimeoutMs(本 API 的默认超时,builtin.ts:147)和 renderDisplayControl(结果卡片默认展开还是折叠,builtin.ts:175)。

2.2 types.ts —— API 名字表(不是 TS enum)

// packages/builtin-tool-web-browsing/src/types.ts:1-7
export const WebBrowsingApiName = {
crawlMultiPages: 'crawlMultiPages',
crawlSinglePage: 'crawlSinglePage',
search: 'search',
} as const;

as const 对象而不是 TS enum,是为了让这份表既能当值用(注册表的 key)又能当类型用(WebBrowsingApiNameType),且不产生运行时枚举对象。整个体系里,manifest、注册表、执行器三处的 key 都指向这一份常量——改名只改一处。

2.3 systemRole.ts —— 工具自带的 prompt

这不是一句话描述,而是一份 120 行的操作手册:systemPrompt(date)(packages/builtin-tool-web-browsing/src/systemRole.ts:1)。它用 XML 标签分段,教模型选类别、选时间范围、怎么标脚注引用、搜不到怎么改写 query,最后一行注入当天日期(systemRole.ts:122)——所以 manifest 里是 systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD'))(manifest.ts:80),日期在模块加载时固化。

工具自带 prompt 的价值:工具的使用规则跟工具的代码放在一起,而不是散落在全局 system prompt 里。装工具 = 装它的说明书。

2.4 ExecutionRuntime/index.ts —— 真正干活的那个类

WebBrowsingExecutionRuntime(packages/builtin-tool-web-browsing/src/ExecutionRuntime/index.ts:31)的构造函数只收依赖,不碰全局:

// ExecutionRuntime/index.ts:24-29 节选
export interface WebBrowsingRuntimeOptions {
documentService?: WebBrowsingDocumentService; // 可选:把爬到的页面存成文档
searchService: SearchServiceImpl; // 必需:谁去搜
}

这一步是整个工具体系最重要的解耦:执行体不知道自己跑在浏览器还是服务器上,它只知道有个 searchService。于是同一个类被两处复用:

  • 服务端:apps/server/src/services/toolExecution/serverRuntimes/webBrowsing.ts:10 注入服务端 SearchService + 数据库文档服务。
  • 浏览器:src/store/tool/slices/builtin/executors/lobe-web-browsing.ts:23 注入走 tRPC 的前端 searchService

方法体做三件事(search,ExecutionRuntime/index.ts:44):调服务 → 截断 → 把结果压成 XML。

// ExecutionRuntime/index.ts:62-76 节选
const searchContent = data.results.slice(0, SEARCH_ITEM_LIMITED_COUNT).map(...);
const xmlContent = searchResultsPrompt(searchContent); // 压成 XML 省 token
return { content: xmlContent, state: data, success: true };

注意最后一行的 content / state 分家:content 是给模型的 XML,state未截断的完整响应,给 UI 用。

searchResultsPrompt(packages/prompts/src/prompts/search/searchResults.ts:28)把标题和 URL 放进 XML 属性、正文放元素内容,比 JSON 省掉大量引号和括号。

两个截断常量在 packages/builtin-tool-web-browsing/src/const.ts:46-48:单页正文 25000 字符、搜索结果 30 条。

坑: ExecutionRuntime/index.ts:123-125 的注释写着 "slice the top 10000 char",但实际用的 CRAWL_CONTENT_LIMITED_COUNT 是 25000——注释是旧的,以常量为准。

2.5 client/ —— 同一次调用的四种呈现

packages/builtin-tool-web-browsing/src/client/index.ts 导出四类组件。它们不是四个样式变体,而是四个不同位置、不同时刻的东西:

组件出现在哪什么时候web-browsing 的实现
Inspector工具调用的标题行参数流式输出时就开始显示client/Inspector/Search/index.tsx:11
Placeholder结果区的骨架屏执行中、还没结果client/Placeholder/Search.tsx:31
Render消息流里的结果卡片执行完client/Render/Search/index.tsx:10
Portal右侧详情栏用户点开时client/Portal/index.tsx:9

Inspector 特别值得看:它拿到 isArgumentsStreamingpartialArgs,所以模型的搜索词还在一个字一个字吐出来时,标题行已经在跳"搜索:AI agent…"(client/Inspector/Search/index.tsx:15-25)。这是"工具调用有反馈感"的来源。

Render 还兼了一个职责:报错时替换成配置表单。搜索没配好会返回 type: 'PluginSettingsInvalid',Render 直接渲染 ConfigForm 让用户就地填(client/Render/Search/index.tsx:14-17)。错误不只是红字,可以是一个修复入口。

系统里一共有六种 UI 位置,web-browsing 只用了四种。另外两种在 builtin-tool-local-system 里能看全:

  • Streaming——执行过程中持续更新的渲染(如 runCommand 的实时输出),packages/builtin-tool-local-system/src/client/Streaming/
  • Intervention——需要人批准时,给用户看的那张"要执行这条命令,同意吗"的面板,packages/builtin-tool-local-system/src/client/Intervention/(每个 API 一个)。

各自的 props 类型都定义在 packages/types/src/tool/builtin.ts:383-532(BuiltinRenderProps / BuiltinPortalProps / BuiltinPlaceholderProps / BuiltinInspectorProps / BuiltinStreamingProps / BuiltinInterventionProps)。


3. 注册与聚合层:packages/builtin-tools/src/

31 个工具包写好了,得有人把它们收拢。builtin-tools 就是这个收口包,里面是两类完全不同的注册表

3.1 第一类:能力表(工具本身)

文件导出内容
index.ts:372builtinTools32 条 LobeBuiltinTool,每条含 manifest + hidden / discoverable 标志
index.ts:42defaultToolIds默认带上的 12 个工具
index.ts:72alwaysOnToolIdsagent 模式下强制常开的 4 个
index.ts:101chatModeAllowedToolIds纯聊天模式下的白名单(只有 3 个)
identifiers.ts:32builtinToolIdentifiers28 条纯 id 列表

builtinTools 的构造有个小技巧:index.ts:372-378 把每个工具 manifest.meta 里的 title / avatar / description 提升到顶层。因为有些工具的 manifest 是按上下文动态生成的(见下),而 UI 列表里的名字和图标必须始终能同步读到。

动态 manifest 就是 resolveManifest(packages/types/src/tool/builtin.ts:344 BuiltinManifestResolver):工具可以根据"我现在是不是在子 agent 里"返回一份裁剪过的 manifest,或者返回 null 让自己彻底消失。目前只有 lobe-agent 用了它——子 agent 里不允许再派子 agent(packages/builtin-tools/src/index.ts:391-392)。

注意两张表不等长: builtinToolIdentifiers(28)只用来回答"这个 identifier 算不算内置"(消费方如 src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx:83),builtinTools(32)才是完整工具对象表。别把它们当同一份清单。

3.2 第二类:UI 表(六张一模一样的查表)

renders.ts / inspectors.ts / placeholders.ts / portals.ts / streamings.ts / interventions.ts 六个文件结构几乎一字不差,都是:

// 六个文件共同的形状,以 renders.ts:13-53 为例
const builtinToolsRenders: Record<string, Record<string, BuiltinRender>> = {};
export const registerBuiltinRenders = (entries) => { /* 合并进去 */ };
export const getBuiltinRender = (identifier?, apiName?) => builtinToolsRenders[identifier]?.[apiName];

两级 key:identifier → apiName → 组件。 消费端就是一句查表:

// src/features/Conversation/Messages/AssistantGroup/Tool/Detail/Render/CustomRender.tsx:23
const Render = getBuiltinRender(plugin?.identifier, plugin?.apiName);
if (!Render) return null;

聊天界面完全不认识任何具体工具——查不到就渲染通用的 JSON 结果块。加一个工具不需要改聊天界面一行代码。

portals.ts 比其他五个多一层:它按 identifier(不按 apiName)分别注册主体、标题、右上角操作三个槽位(portals.ts:3-9),因为侧栏是整个工具共用一块画布。

3.3 register.ts —— 唯一一处把所有包连起来的地方

registerBuiltinToolSurfaces()(packages/builtin-tools/src/register.ts:209)是幂等的一次性副作用:导入 20 多个包的 client 出口,调六次 registerBuiltinXxx,然后置 builtinToolSurfacesRegistered = true(register.ts:333)。

这里藏着一个容易忽略的事实:有些注册项没有对应的工具包,是纯 UI

identifier有 manifest 吗干什么
claude-code没有渲染外部 Claude Code CLI 回传的工具调用
codex没有渲染 Codex 的 command_execution / file_change / todo_list
github / linear / twitter没有给这些第三方技能的调用配专属卡片

packages/builtin-tool-claude-code/src/ 只有 types.tsclient/——它是一个只出 UI 不出能力的包。这条设计让 LobeHub 能把别家 agent 的执行流"接进自己的对话渲染",详见 四个执行面

3.4 两张不放组件的表

  • displayControls.ts:22 getBuiltinRenderDisplayControl(identifier, apiName)——结果卡片默认折叠还是展开。它故意独立于 renders.ts,注释(displayControls.ts:9-11)写明:tool store 的 selector 只想要这个默认值,不该被迫拖进整个 render 依赖图(会绕回 @/store/tool/selectors 形成循环)。
  • dynamicInterventionAudits.ts:4——只有一条:{ pathScopeAudit }。它是"审批策略的动态判定器"注册表,manifest 里写 type: 'pathScopeAudit' 就是查这张表。见 §7。

3.5 全部 31 个工具包,按职能分类

packages/builtin-tool-* 一共 31 个目录。横向扫一眼,能看出这套工具体系的覆盖面:

分类干什么
计算机操作local-system · cloud-sandbox · remote-device本机文件/shell、云沙箱、远端设备
外部 agent 渲染claude-code只出 UI,不出能力
信息获取web-browsing · knowledge-base · topic-reference联网搜索爬取、向量库检索、跨话题引用
记忆与笔记memory(id 是 lobe-user-memory) · notebook · agent-documents跨会话记忆、话题内笔记、agent 附件文档
任务与编排task · lobe-agent · group-management · group-agent-builder任务 CRUD、派子 agent、群聊调度
技能skills · skill-store · skill-maintainer激活技能、装技能、维护技能(见 §8)
Agent 自建agent-builder · agent-management · page-agent用对话造 agent、管 agent、文档编辑 agent
人机交互user-interaction · web-onboarding · activator向用户提问、新手引导、显式激活工具
自省与交付agent-signal · self-iteration · verify · lobe-delivery-checker · brief反思信号、自迭代、交付验收
杂项calculator · message · creds算术、发消息、凭证

4. 执行运行时:packages/tool-runtime 和三个能力包

local-system(本机)和 cloud-sandbox(云沙箱)要做的事高度重合:列目录、读文件、写文件、改文件、搜文件、跑命令。差别只在"这些动作最终由谁执行"。

packages/tool-runtime 用一个抽象基类吃掉这层重复。

4.1 ComputerRuntime —— 模板方法

ComputerRuntime (抽象)
├── listFiles / readFile / writeFile / editFile
├── searchFiles / globFiles / grepContent
├── runCommand / getCommandOutput / killCommand
│ 每个方法都是:调 callService → 组 state → 组 content
└── abstract callService(toolName, params) ◀── 子类唯一要填的洞

├── LocalSystemExecutionRuntime → Electron IPC
└── CloudSandboxExecutionRuntime → 沙箱 HTTP/tRPC

基类的每个方法长得一模一样(packages/tool-runtime/src/ComputerRuntime.ts:67 listFiles 是最短的样板):调 callService → 出错就 errorOutput → 成功就构造 state 对象 + 用 @lobechat/prompts/fileSystem 的 formatter 生成 content。抽象方法只有一个,声明在 ComputerRuntime.ts:60

CloudSandboxExecutionRuntime(packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts:23)的 callService 只有一行——转发给注入的 sandboxService;它另外加了云特有的 executeCode / exportFile

4.2 LocalSystemExecutionRuntime —— 一层字段翻译

本机这条路多一道麻烦:Electron IPC 那边用的是另一套字段名(file_path / old_string / shell_id / exit_code),跟 ComputerRuntime 的 camelCase 对不上。于是这个子类干了三件事(packages/tool-runtime/src/LocalSystemExecutionRuntime.ts):

环节符号干什么
方法名映射SERVICE_METHOD_MAP(:30)globLocalFilesglobFileswriteLocalFilewriteFile
入参翻译denormalizeParams(:81){path, search, replace}{file_path, old_string, new_string}
出参翻译normalizeResult(:169)snake_case 结果转回 camelCase

这是一层纯粹的防腐层:IPC 协议怎么改,只动这一个文件,ComputerRuntime 和上层执行器都不知情。

4.3 三个底层能力包的分工

跑在哪提供什么入口符号
local-file-shellNode / Electron 主进程文件读写改、glob、grep、shell 进程管理、git 操作readLocalFile / editLocalFile / ShellProcessManager,出口 src/index.ts:1-8
python-interpreter浏览器 Web WorkerPyodide 跑 Python,Comlink 跨 worker 调用getPythonInterpreter 惰性单例(packages/python-interpreter/src/interpreter.ts:18,原 PythonInterpreter IIFE 改成了显式 getter),消费方 src/services/python.ts
device-control用户设备的 daemon设备侧 RPC 的方法表与分发packages/device-control/src/dispatch.ts:47 DEVICE_RPC_METHODS

device-control 的设计值得单看:它把设备端所有能力摊成一张方法名数组(DEVICE_RPC_METHODS),网关按 rpc_request 里的 method 字段直接查表分发。注释说得很清楚——加一个设备能力 = 数组加一项 + 写一个 handler,网关侧零改动(dispatch.ts:31-36)。


5. 客户端工具 vs 服务端工具:同一个工具跑在哪

5.1 三条执行路径

模型说要调工具

├─(A) 前端自己跑循环 ─▶ invokeBuiltinTool ─▶ 前端 executor 注册表 ─▶ 直接执行

└─(B) 服务端跑循环 ─┬─ executor !== 'client' ─▶ ToolExecutionService ─▶ 服务端 runtime

└─ executor === 'client' ─▶ 反向派回浏览器/桌面执行,等结果

(B) 的下半支——"服务端把工具反向派回客户端"——是本节的重点,也是整套体系里最绕的一段。

5.2 谁被标成 client

只有 manifest 声明了 executors: ['client', ...] 的工具才有资格被反向派发。全仓只有一个这么声明:packages/builtin-tool-local-system/src/manifest.ts:7(另有 self-iteration 声明 ['server'])。

有资格 ≠ 一定走。真正打标记的是 apps/server/src/services/aiAgent/index.ts:3894-3910:

// aiAgent/index.ts:2520-2525 节选
if (!gatewayConfigured) {
for (const id of Object.keys(toolManifestMap)) {
if (toolManifestMap[id]?.executors?.includes('client')) toolExecutorMap[id] = 'client';
}
}

条件是 !gatewayConfigured——只有在没配设备网关的单机部署里才反向派发。配了设备网关时 executor 留空,统一走 RemoteDevice 代理路由(注释在 :2510-2519)。同一段还把 stdio 类型的 MCP 插件标成 client(:2526-2535):stdio MCP 要 spawn 本地进程,云端服务器上根本没这个二进制。

5.3 反向派发的完整来回

服务端 RuntimeExecutors 浏览器 / 桌面端

│ canDispatchToClient? (RuntimeExecutors.ts:2748)

resolveToolTimeoutMs ──▶ timeoutMs


dispatchClientTool
├─ redis.duplicate() 开一条专用连接
├─ streamManager.sendToolExecute ══ WS tool_execute ══▶ internal_executeClientTool
│ ├─ 解析 arguments
│ ├─ hasExecutor? → invokeExecutor
│ └─ 否则 → MCP 兜底
└─ waiter.waitForResult ── BLPOP tool_result:<id> ◀══ WS tool_result ══ send()


{content, state, success} → 塞回消息流,循环继续

四个文件各管一段:

文件符号职责
apps/server/.../resolveToolTimeout.tsresolveToolTimeoutMs(:53)定这次调用的时间预算
apps/server/.../dispatchClientTool.tsdispatchClientTool(:59)发 WS、等结果、永不抛异常
apps/server/.../ToolResultWaiter.tswaitForResults(:62)用 Redis BLPOP 把异步等待变成 Promise
src/store/.../client/clientToolExecution.tsinternal_executeClientTool(:64)浏览器侧执行并回传

5.4 超时:三处对齐同一个数字

超时不是各自拍脑袋,是一条链:

① args.timeout (模型自己提的)
↓ 没有就看
② manifest.api[x].defaultTimeoutMs (工具作者定的,如 local-system 的 30_000)
↓ 没有就用
③ GLOBAL_DEFAULT_TIMEOUT_MS = 120_000

clamp 到 [1_000, 800_000] ◀── 服务端是唯一裁判

随 tool_execute 下发 (executionTimeoutMs)

客户端把闹钟设在 服务端值 − 500ms ◀── 让客户端先醒

三个常量在 resolveToolTimeout.ts:8/13/20,优先级逻辑在 resolveToolTimeoutMs(:53-66),注释里那句话是设计原则:"客户端是建议者,这个函数是唯一裁决者"。

客户端提前 500ms(clientToolExecution.ts:34 SAFETY_BUFFER_MS)的目的很具体:让服务端拿到一条明确的 client_executor_timeout 失败结果,而不是一次说不清原因的 BLPOP 空转(clientToolExecution.ts:28-33 注释)。超时时还会 abort() 掉 AbortController,让底层 IPC 真的停下来(#raceAgainstDeadline,:309)。

5.5 "恰好一个结果"的四层兜底

服务端在 BLPOP 上阻塞着,客户端必须回一条结果,否则整个 agent 循环挂到超时。internal_executeClientTool 为此做了四层保险:

位置兜的是什么
sent 标志位clientToolExecution.ts:104-110重复发送直接忽略
连接晚绑定:90-102发送时才查 gatewayConnections,避免中途重连后往死 socket 里写
catch 全兜:250-278参数解析失败 / 超时 / 未知异常都转成结构化失败
finally 补发:279-297上面全漏了,兜底发一条 client_executor_no_result

服务端这边同样"永不抛":dispatchClientTool 的每条错误路径都返回一个 failed 结果对象(dispatchClientTool.ts:62-69 注释),Redis 没配、网关不支持、连接异常,统统变成一条工具失败消息,循环继续往下走。

另外 ToolResultWaiter 有个巧思:多个工具并行时用 多 key BLPOP + 共享 deadline,总等待时间是 timeoutMs 而不是 N × timeoutMs(ToolResultWaiter.ts:61-68);取消则靠 LPUSH 一个毒丸 __tool_result_cancelled__ 把 BLPOP 唤醒(:121)。

5.6 不经过模型的"伪工具调用"

src/store/.../client/localSystemToolSnapshots.ts 是个有意思的旁支:用户在输入框里 @ 了一个本地文件,前端直接调 localFileService 读出来,然后伪造一条 lobe-local-system/readFile 的工具调用记录塞进消息流(materializeLocalSystemToolSnapshots,:133)。

它复用了同一套 formatFileContent / formatFileList(:36 / :95),所以生成的 content/state 跟真工具调用完全同构——UI 渲染、模型读到的格式都一样。区别只在 toolCallId 是本地造的(createToolCallId,:15)。模型没有调用它,但看到的东西和调用了一模一样。


6. 外部插件与 MCP

6.1 服务端的四岔路口

服务端执行的入口是 ToolExecutionService.executeTool(apps/server/src/services/toolExecution/index.ts:82),先过权限闸(:89-102),再按 payload.type 分流:

executeTool (先过连接器权限闸)

├─ type === 'mcp' ──▶ executeMCPTool (index.ts:173)

└─ 其他 / 'builtin' ──▶ BuiltinToolsExecutor (builtin.ts:23)
├─ source = 'lobehubSkill' ──▶ MarketService
├─ source = 'composio' ──▶ ComposioService
└─ 其余 ──▶ serverRuntimes 工厂表

source 字段的存在是为了解决一个真实问题:从数据库里恢复的工具调用(比如人工批准之后重放)不带 source,得靠实时查 store 补回来(src/store/chat/slices/plugin/actions/pluginTypes.ts:51-67)。

服务端 runtime 注册表(apps/server/src/services/toolExecution/serverRuntimes/index.ts:59)存的是工厂函数而不是实例:

// serverRuntimes/types.ts:6
export type ServerRuntimeFactory = (context: ToolExecutionContext) => any;

因为有些 runtime 需要 per-request 上下文(userId / topicId / agentId),有些不需要。webBrowsing.ts:11-32 就是典型:拿到 context 后才决定要不要挂 documentService(没有 userId/agentId 就不存文档)。

查不到 runtime 时的报错是硬失败:Builtin tool "x" is not implemented(builtin.ts:92)。packages/builtin-tools/src/index.ts:131-136 的注释专门记了一次踩坑——lobe-group-agent-builder 因为没注册服务端 runtime,曾经一被模型调用就抛这个错。

6.2 MCP

MCPService(apps/server/src/services/mcp/index.ts:53)干两件事:

方法干什么位置
listTools拉 MCP server 的工具表,把 inputSchema 直接当 parameters:94
callTool转发调用,把 MCP content blocks 转成 {content, state}:213

两个工程细节:

  • 客户端按参数缓存。 getClient(:284)用序列化后的连接参数当 key 缓存 MCPClient 实例,重试时才 skipCache
  • NoValidSessionId 才重试。 listToolsretry 包了一层,但只有会话失效这一种错误会重试,其他错误立即 bail 成 TRPCError(:118-133)——避免对一个真坏掉的 server 反复轰炸。

MCP 的一大分叉在传输类型:stdio 型的 MCP 必须在用户机器上 spawn 进程,所以服务端要么把它派回客户端(§5.2),要么走设备代理 executeMcpViaDevice(apps/server/src/services/toolExecution/index.ts:350)。cloud 型走 executeCloudMCPTool,http/sse 型服务端直连。

浏览器侧的对应实现是 invokeMCPTypePlugin(src/store/chat/slices/plugin/actions/pluginTypes.ts:316),它多做一步:结果先过 archiveToolResultViaServer(:359)——超长内容存归档、只把截断版留在上下文里。

6.3 插件市场与安装态

位置干什么
市场索引apps/server/src/modules/PluginStore/index.ts:8PLUGINS_INDEX_URL 下的 index.<locale>.json,失败回落默认语言,再失败返回 []
安装记录packages/database/src/models/plugin.ts:9 PluginModeluser_installed_plugins 表的增删查改
前端调用src/store/chat/slices/plugin/actions/pluginTypes.ts 按类型分流,exector.ts 封装远程执行器

PluginModel.create(plugin.ts:26)用 onConflictDoUpdate(identifier, userId) 做 upsert——重复安装是更新而不是报错。整张表存的关键字段是 manifest(装的时候抓下来的完整 manifest)和 customParams(MCP 连接参数就藏在 customParams.mcp 里)。


7. 安全与审批的落地面

7.1 审批策略写在哪

三个策略值:'never'(直接跑)/ 'required'(要批准,但 auto-run 模式可以豁免)/ 'always'(必须批准,谁也豁免不了)。

写法有四种,优先级从高到低:

层级写在哪例子
全局审计代码里的 globalInterventionAudits,每次调用都跑安全黑名单
API 级动态api[x].humanIntervention = { dynamic: {...} }local-systempathScopeAudit
API 级静态api[x].humanIntervention = 'required' 或规则数组local-systemrunCommand(manifest.ts:229)
工具级manifest.humanIntervention整个工具的默认值

一句话记住优先级:api?.humanIntervention ?? manifest.humanIntervention(packages/agent-runtime/src/agents/GeneralChatAgent.ts:99)——API 级覆盖工具级

规则数组还支持按参数匹配,类型注释里给了写法(packages/types/src/tool/builtin.ts:195):

// 示意,非源码 —— 来自 builtin.ts:160 的注释示例
humanIntervention: [
{ match: { command: 'git add:*' }, policy: 'never' }, // git add 免批
{ policy: 'always' }, // 其余一律要批
]

7.2 判定流程

checkInterventionNeeded(GeneralChatAgent.ts:159)把一批工具调用切成两堆:要批的、可以直接跑的。判定顺序(命中即停):

每个工具调用

├─① 全局审计命中且 policy='always' ─────────▶ 要批准(不可豁免)

├─② manifest 有 dynamic 配置 ──▶ 跑 resolver ──▶ never? 直接跑
│ └─ 否则要批准

├─③ 全局审计命中但 policy≠'always' ─────────▶ 要批准(headless 下放行)

├─④ 静态配置匹配到 'always' ────────────────▶ 要批准(不可豁免)

├─⑤ approvalMode = headless / auto-run ─────▶ 直接跑

├─⑥ 工具不在 manifestMap 里(未知工具)─────▶ 要批准

├─⑦ approvalMode = allow-list ──────────────▶ 查白名单

└─⑧ approvalMode = manual(默认)───────────▶ 按静态配置判

第 ⑥ 步值得单说:模型报了一个 manifest 里查不到的工具名,系统不是直接拒,而是当成危险操作要求人批准,并打一条 warn(GeneralChatAgent.ts:266-274)。只在 manual/allow-list 模式生效——auto-run 的用户自认风险。

7.3 动态审计的样板:pathScopeAudit

manifest 里只写一个字符串 type: 'pathScopeAudit',真正的判定函数在 packages/builtin-tools/src/dynamicInterventionAudits.ts:4 那张表里查。manifest 是纯数据,不含函数——这样 manifest 才能被序列化、存库、跨进程传。

createPathScopeAudit(packages/builtin-tool-local-system/src/interventionAudit.ts:71)的逻辑一句话:动到工作目录外的路径就要批准

提取 args 里所有路径字段(path/file_path/directory/oldPath/newPath/pattern/items[])
│ extractPaths(:34)

全都在 /tmp、/var/tmp 底下? ── 是 ─▶ 放行(免批)
│ 否

有任何一个不在 workingDirectory 下? ── 是 ─▶ 要批准
│ 否

放行

workingDirectorymetadata 来,不是从模型参数来(:81)——模型改不了自己的沙箱边界。没有 workingDirectory 时函数直接返回 false(:84-86),即不额外拦截。

所以 local-system 的 8 个文件类 API 全部挂 { dynamic: { default: 'never', policy: 'required', type: 'pathScopeAudit' } }:目录内静默执行,越界才弹窗。而 runCommand 是无脑 'required'(manifest.ts:229)——shell 命令没法靠路径判断安全性。

7.4 headless 模式:blocked 与 resolve_blocked_tools

定时任务、bot 消息这类没人盯着的运行(approvalMode === 'headless'),弹窗没有意义。此时 agent 不发批准请求,而是发一条 resolve_blocked_tools 指令(GeneralChatAgent.ts:676-684)。

执行器 resolve_blocked_tools(packages/agent-runtime/src/executors/resolveTools.ts:78 resolveBlockedTools,原 RuntimeExecutors 单体已重构进 packages/agent-runtime)不执行工具,只为每个被拦的调用伪造一条失败的 tool 消息:

// RuntimeExecutors.ts:4345-4351
const result = {
content: 'Blocked by security/privacy.',
error: 'blocked_by_security_privacy',
state: { type: 'blocked' },
success: false,
};

同时写库时打上 pluginIntervention: { rejectedReason, status: 'rejected' }(:4376)。这样做的意义是让循环不死:模型收到一条明确的"这个操作被安全策略拦了"的工具结果,可以换个方式继续,而不是卡在等待里。

指令类型定义在 packages/agent-runtime/src/types/instruction.ts:298-310

7.5 SSRF 防护

工具会拿模型给的 URL 去 fetch,这是典型的 SSRF 入口(诱导服务器去访问内网 169.254.169.254 之类的元数据地址)。packages/ssrf-safe-fetch/index.ts:64 ssrfSafeFetch 做三件事:

措施实现位置
拦私网 IPrequest-filtering-agent,http/https 各一个 agent:75-94
重定向也拦agent 传成函数,按 parsedURL.protocol 动态选,HTTP→HTTPS 跳转照样过滤:92-95
限响应体大小readBodyWithCap 边读边数,到量就 break(会关流、释放连接):31-53

浏览器版是空壳,直接用原生 fetch(packages/ssrf-safe-fetch/index.browser.ts:27)——浏览器里不存在 SSRF。

网页爬虫的 naive 实现是主要消费方,顺手带上了大小上限(packages/web-crawler/src/crawImpl/naive.ts:44-51)。被拦时错误信息会附上文档链接告诉自托管用户怎么放行内网(index.ts:118-121)。


8. Skills:另一种"工具"

8.1 skill 和 tool 的本质区别

builtin toolskill
本体一段可执行代码 + JSON Schema一段 Markdown 说明书 + 附件
模型怎么用直接调用它的某个 APIactivateSkill 把说明书读进上下文,再用已有的工具照做
增加一个写一个 npm 包写一个 SKILL.md
类型BuiltinToolManifest(builtin.ts:189)BuiltinSkill(packages/types/src/skill/index.ts:43)

一句话:tool 给 agent 新的动作,skill 给 agent 新的做法。

8.2 packages/builtin-skills —— 内置说明书

// packages/builtin-skills/src/index.ts:23-29
export const builtinSkills: BuiltinSkill[] = [
AgentBrowserSkill, // 用浏览器做事的操作手册
ArtifactsSkill, // 生成 SVG / HTML / React 交付物
LobeHubSkill, // 操作 LobeHub 自身(20 多个 reference 文件)
TaskSkill, // 任务管理
// FindSkillsSkill ← 注释掉了
];

一个 skill 就是 { identifier, name, description, content, resources? },content 是整段 Markdown。ArtifactsSkill(packages/builtin-skills/src/artifacts/index.ts:7)最简单——只有 content;VerifySkill(verify/index.ts:34)最复杂——挂了 10 个 reference 文件,分成 references/(操作手册)和 surfaces/(不同运行环境的做法)。

VerifySkill 还是唯一一个故意不进 builtinSkills 数组的:它是给外部构建者(Claude Code / Codex)通过 lh acceptance install 拉到磁盘上用的,不加载进 LobeHub 自己的 agent 运行时(packages/builtin-skills/src/index.ts:31-38 的注释解释了这个决定)。resource 的 key 保留 .md 后缀,就是为了落盘后目录结构 1:1 对应、skill 里的相对链接还能解析(verify/index.ts:30-32)。

8.3 三个 skill 相关的工具包

identifierAPI职责
builtin-tool-skillslobe-skillsactivateSkill / readReference / runCommand / execScript / exportFile用技能
builtin-tool-skill-storelobe-skill-storesearchSkill / importSkill / importFromMarket装技能
builtin-tool-skill-maintainerlobe-skill-maintainercreateSkill / getSkill / listSkills / renameSkill / replaceSkillIndex写技能(系统内部用)

activateSkill 的描述写得很直白(packages/builtin-tool-skills/src/manifest.base.ts:6-7):"按名字激活一个技能来加载它的指令……返回你应该遵照执行的技能内容;找不到就返回可用技能列表"。技能内容是作为工具执行结果注入上下文的,不是预先塞进 system prompt——这是渐进式披露:上下文里只留一句"你可以 activateSkill",真正的长手册按需加载。

readReference 则是第二级披露:技能正文里提到某个附件,模型再调一次把它读进来(manifest.base.ts:21-40)。

危险 API 照样挂审批:runCommandexecScript 都是 humanIntervention: 'required'(manifest.base.ts:66manifest.ts:22)。


9. 巧妙之处(可以带走的)

  1. content / state 双通道。 一次执行产出两份结果:给模型的压缩文本、给 UI 的完整对象。互不迁就——模型省 token,人看细节(BuiltinServerRuntimeOutput,packages/types/src/tool/builtin.ts:481)。

  2. ExecutionRuntime 只认注入的 service。 同一个 WebBrowsingExecutionRuntime 类被服务端和浏览器两处 new,只换构造参数(serverRuntimes/webBrowsing.ts:15 vs executors/lobe-web-browsing.ts:23)。执行逻辑零重复。

  3. UI 是两级查表,不是 switch。 getBuiltinRender(identifier, apiName) 查不到就退回通用渲染。聊天界面不认识任何具体工具,所以加工具不用改界面(renders.ts:40)。

  4. manifest 里不放函数,只放函数名。 动态审批写 type: 'pathScopeAudit',函数在 dynamicInterventionAudits 表里查。manifest 保持纯数据,可序列化、可入库、可跨进程(dynamicInterventionAudits.ts:4)。

  5. 超时只有一个裁判。 模型能建议、工具作者能设默认,但最终值由服务端 clamp 到 [1s, 800s],再下发给客户端;客户端把闹钟往前拨 500ms,好让失败原因是明确的而不是"超时了不知道为啥"(resolveToolTimeout.ts:50-52clientToolExecution.ts:28-34)。

  6. "恰好一个结果"当成硬约束来守。 客户端四层兜底、服务端每条错误路径都返回失败对象——因为对面有个 BLPOP 在阻塞,漏一条结果就是挂一个循环(clientToolExecution.ts:332-354)。

  7. UI-only 的工具包。 builtin-tool-claude-code 没有 manifest 也没有执行体,只有渲染组件——用来把外部 agent 的执行流渲染进自家对话(packages/builtin-tool-claude-code/src/)。

  8. 伪造的工具调用。 @ 一个本地文件,前端直接读并伪造一条完全同构的工具调用记录,复用同一套 formatter,UI 和模型都察觉不到区别(localSystemToolSnapshots.ts:133)。

  9. 注释即事故记录。 packages/builtin-tools/src/index.ts:131-136 用整段注释记下"为什么 lobe-group-agent-builder 不在 supervisor 工具列表里"(没服务端 runtime,一调就抛)。这类注释比 changelog 有用得多。


10. 边界与局限

  • api.parameters 是裸 Record<string, any>(packages/types/src/tool/builtin.ts:201),没有 zod/TS 层面的 schema 校验。写错的 JSON Schema 要到模型调用时才暴露。

  • 模型给的参数不做结构校验。 服务端只判 JSON 能不能解析(toolExecution/builtin.ts:95-115),解析成功就直接进 runtime;字段缺失/类型不对由各个 runtime 自己防。好处是能把"参数被截断了"这种情况诊断得很细(detectTruncatedJSON),坏处是校验责任分散。

  • 反向派发依赖 Redis。 dispatchClientTool 没有 Redis 就直接返回 redis_unavailable 失败(dispatchClientTool.ts:85-92),没有内存 fallback。

  • BLPOP 独占连接。 每次派发都 redis.duplicate() 开新连接(dispatchClientTool.ts:96),高并发下是连接数压力;代码用 finally { blockingClient.disconnect() } 兜住不泄漏,但这是硬成本。

  • 参数级审批匹配很粗。 matchesAlwaysPolicy 的字符串匹配就是 String(paramValue).includes(matcher) || matcher.includes('*')(GeneralChatAgent.ts:126-128)——含 * 的 matcher 无条件命中,不是真正的 glob。想按命令前缀精细放行会踩坑。

  • pathScopeAudit 只看已知字段名。 extractPaths(interventionAudit.ts:33-58)硬编码了 path/file_path/directory/oldPath/newPath/pattern/items[]。工具用别的参数名传路径就绕过了审计。

  • 注册表是模块级可变全局量。 六张 UI 表和执行器表都是模块作用域的 Record / Map,靠布尔标志保证只注册一次(register.ts:163executors/index.ts:39)。测试里要 mock 得靠模块级 mock,不能注入。

  • 文档与代码有漂移。 例如 crawl 截断的注释写 10000、常量是 25000(ExecutionRuntime/index.ts:123-125 vs const.ts:46)。


11. 代码地图

主题文件路径符号名
manifest 类型定义packages/types/src/tool/builtin.tsBuiltinToolManifest, LobeChatPluginApi, LobeBuiltinTool
六种 UI 组件的 propspackages/types/src/tool/builtin.tsBuiltinRenderProps, BuiltinInspectorProps, BuiltinPortalProps, BuiltinPlaceholderProps, BuiltinStreamingProps, BuiltinInterventionProps
执行结果的形状packages/types/src/tool/builtin.tsBuiltinServerRuntimeOutput, BuiltinToolResult
样板工具:声明packages/builtin-tool-web-browsing/src/manifest.tsWebBrowsingManifest
样板工具:API 名表packages/builtin-tool-web-browsing/src/types.tsWebBrowsingApiName
样板工具:自带 promptpackages/builtin-tool-web-browsing/src/systemRole.tssystemPrompt
样板工具:执行体packages/builtin-tool-web-browsing/src/ExecutionRuntime/index.tsWebBrowsingExecutionRuntime, WebBrowsingRuntimeOptions
样板工具:截断常量packages/builtin-tool-web-browsing/src/const.tsCRAWL_CONTENT_LIMITED_COUNT, SEARCH_ITEM_LIMITED_COUNT
样板工具:UI 出口packages/builtin-tool-web-browsing/src/client/index.tsWebBrowsingRenders, WebBrowsingInspectors, WebBrowsingPlaceholders, WebBrowsingPortal
工具总表 / 分组常量packages/builtin-tools/src/index.tsbuiltinTools, defaultToolIds, alwaysOnToolIds, chatModeAllowedToolIds, runtimeManagedToolIds
内置 id 判定表packages/builtin-tools/src/identifiers.tsbuiltinToolIdentifiers
六张 UI 表的注册入口packages/builtin-tools/src/register.tsregisterBuiltinToolSurfaces
UI 表(逐张)packages/builtin-tools/src/{renders,inspectors,placeholders,portals,streamings,interventions}.tsregisterBuiltinRenders / getBuiltinRender 等六组同构函数
折叠/展开默认值packages/builtin-tools/src/displayControls.tsgetBuiltinRenderDisplayControl
动态审批判定器表packages/builtin-tools/src/dynamicInterventionAudits.tsdynamicInterventionAudits
外部 agent 的纯 UI 注册packages/builtin-tools/src/{codex,github,linear,twitter}/index.tsCodexRenders, GithubInspectors, LinearRenders, TwitterInspectors
计算机操作抽象基类packages/tool-runtime/src/ComputerRuntime.tsComputerRuntime, callService
本机 IPC 适配层packages/tool-runtime/src/LocalSystemExecutionRuntime.tsLocalSystemExecutionRuntime, SERVICE_METHOD_MAP, denormalizeParams, normalizeResult
云沙箱执行体packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.tsCloudSandboxExecutionRuntime, executeCode
本机文件/shell 能力packages/local-file-shell/src/file/index.tsreadLocalFile, writeLocalFile, editLocalFile, grepContent, globLocalFiles
本机 shell 进程管理packages/local-file-shell/src/shell/process-manager.tsShellProcessManager
浏览器内 Pythonpackages/python-interpreter/src/interpreter.tsPythonInterpreter(Comlink 包住的 Pyodide worker)
设备侧 RPC 方法表packages/device-control/src/dispatch.tsDEVICE_RPC_METHODS, executeDeviceRpc
前端执行器注册表src/store/tool/slices/builtin/executors/index.tsregisterBuiltinToolExecutors, hasExecutor, invokeExecutor
前端 web-browsing 执行器src/store/tool/slices/builtin/executors/lobe-web-browsing.tsWebBrowsingExecutor, webBrowsing
客户端反向执行src/store/chat/slices/agentRun/actions/transports/client/clientToolExecution.tsinternal_executeClientTool, #raceAgainstDeadline, SAFETY_BUFFER_MS
伪造的本地文件工具调用src/store/chat/slices/agentRun/actions/transports/client/localSystemToolSnapshots.tsmaterializeLocalSystemToolSnapshots
服务端派回客户端apps/server/src/modules/AgentRuntime/dispatchClientTool.tsdispatchClientTool, clampTimeout
Redis 等结果apps/server/src/modules/AgentRuntime/ToolResultWaiter.tsToolResultWaiter, waitForResults, cancel
超时裁决apps/server/src/modules/AgentRuntime/resolveToolTimeout.tsresolveToolTimeoutMs, GLOBAL_DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS
派发分支(client vs server)apps/server/src/modules/AgentRuntime/RuntimeExecutors.tscanDispatchToClient(:2748), resolve_blocked_tools(:4333)
谁被标成 clientapps/server/src/services/aiAgent/index.tstoolExecutorMap 赋值段(:2520-2536)
服务端总入口apps/server/src/services/toolExecution/index.tsToolExecutionService, executeTool, executeMCPTool
builtin 分流apps/server/src/services/toolExecution/builtin.tsBuiltinToolsExecutor, execute
服务端 runtime 工厂表apps/server/src/services/toolExecution/serverRuntimes/index.tsgetServerRuntime, hasServerRuntime, ServerRuntimeFactory
审批判定主逻辑packages/agent-runtime/src/agents/GeneralChatAgent.tscheckInterventionNeeded, getToolInterventionConfig, resolveDynamicPolicy, matchesAlwaysPolicy
路径越界审计packages/builtin-tool-local-system/src/interventionAudit.tscreatePathScopeAudit, pathScopeAudit, extractPaths
blocked 指令类型packages/agent-runtime/src/types/instruction.tsAgentInstructionResolveBlockedTools
SSRF 防护packages/ssrf-safe-fetch/index.tsssrfSafeFetch, readBodyWithCap, SSRFOptions
MCP 服务apps/server/src/services/mcp/index.tsMCPService, listTools, callTool, getClient
插件市场索引apps/server/src/modules/PluginStore/index.tsPluginStore, getPluginList
插件安装记录packages/database/src/models/plugin.tsPluginModel, create, query
前端插件分流src/store/chat/slices/plugin/actions/pluginTypes.tsinvokeBuiltinTool, invokeMCPTypePlugin, internal_invokeRemoteToolPlugin
内置技能表packages/builtin-skills/src/index.tsbuiltinSkills, VerifySkill
技能类型packages/types/src/skill/index.tsBuiltinSkill, skillManifestSchema
技能工具packages/builtin-tool-skills/src/manifest.base.tsactivateSkillApi, readReferenceApi, runCommandApi
结果压 XMLpackages/prompts/src/prompts/search/searchResults.tssearchResultsPrompt

继续读: 工具怎么被挑进这一轮的 prompt → 上下文工程;工具结果回填后循环怎么转 → 运行时内核;同一条指令流跑在浏览器/服务端/云沙箱/CLI 的差异 → 四个执行面