跳到主要内容

工具层:元工具路由、并发批处理与权限闸门

30 秒导读: 模型自己只会「说」,要真正去查财报、读文件、跑命令,靠的是工具。 本章讲 Dexter 的「手脚」怎么装配:一份工具注册表决定这次对话能用哪些工具、每个工具怎么 向模型自我介绍;一个执行器把模型一口气发出的多个调用,聪明地「能并行的并行、该串行的串行」; 一个权限引擎在每次危险调用(尤其是 bash)真正执行前,做 fail-closed 的安全判定。 最后用金融领域的 get_financials 演示一种「元工具」范式:一次调用、内部再开一层 LLM 自己路由复杂度。

本章聚焦「工具怎么被注册、被并发调度、被权限把关」。至于工具跑完的大结果如何被截断/持久化03 上下文工程 的事;记忆类工具(memory_*)的内部机制在 04 记忆系统;子 agent 工具(spawn_subagent)与 Skill 工作流05 委派与专长。本章只把它们当作注册表里的普通条目提及。


1. 这是什么(零基础也能懂)

先建立一个心智模型

大语言模型本身是个「只会说话的大脑」:你问它「苹果最新季度营收多少」,它只能凭训练时的记忆 瞎猜,既不会上网、也不会读你的文件。工具(tool) 就是给这个大脑装上的「手脚」——一组它可以 「请求调用」的函数。模型在回答里说「我要调用 get_financials,参数是 {query: "AAPL 最新营收"}」, 框架就真的去执行这个函数,把结果塞回对话,让模型接着往下推理。

三个必须回答的问题

一旦你要给 agent 装几十个工具,立刻冒出三个现实问题。Dexter 的工具层就是这三个问题的答案:

问题白话本章对应主线
这次能用哪些工具、每个怎么自我介绍?有的工具要 API key 才能开;描述太长又费 token工具注册表(§3)
模型一次要调 5 个工具,怎么跑最快又不出乱子?只读的可以并行;写文件的必须一个个来并发执行器(§4)
模型要跑 rm -rf / 怎么办?危险命令必须先过闸门,甚至直接拒绝权限引擎(§5)

用起来什么样

从使用者视角,这一切是隐形的。你在终端里问一句话,可能触发这样一串(简化的)事件流:

你: 对比 AAPL 和 MSFT 最近一年的营收,再把结论写进 report.md

→ 模型决定调用 get_financials(query="compare AAPL vs MSFT revenue last year") [只读, 自动放行]
→ 模型决定调用 write_file(path="report.md", ...) [写操作, 弹窗问你]
▸ 终端提示: "Allow write to report.md? [y / session / always / n]"
→ 你按了 y → 文件写入 → 模型汇报完成

只读的查询工具默默跑掉,写文件这种有副作用的动作停下来问你。这条「自动放行 vs 停下问人」 的分界线,正是权限引擎画出来的。


2. 顶层全景(它大概怎么转)

三条主线在一次工具调用里是这样串起来的。先看这张图——从上到下是一次工具调用的生命周期, 左边是数据、右边是负责的模块:

┌───────────────────────────────────────────────┐
启动时 │ registry.ts getToolRegistry(model) │
(装配) │ · 按 env / 平台裁剪出「本次可用工具」清单 │
│ · 每个工具带 compactDescription(省 token) │
│ · 每个工具标 concurrencySafe(能否并行) │
└───────────────┬───────────────────────────────┘
│ 注入系统提示 + bindTools 给模型

模型回一条 AIMessage,里面可能有多个 tool_calls


┌───────────────────────────────────────────────┐
每一轮 │ tool-executor.ts AgentToolExecutor.executeAll│
(调度) │ ① partition: 连续 concurrencySafe → 并行批 │
│ ② 其余 → 串行,逐个过闸门 │
└───────────────┬───────────────────────────────┘
│ 每个调用执行前

┌───────────────────────────────────────────────┐
每次调用 │ permissions/engine.ts evaluatePermission │
(把关) │ allow → 直接跑 │
│ ask → 查 session 缓存,没批过就弹窗问人 │
│ deny → 静默拒绝,连人都不惊动 │
└───────────────────────────────────────────────┘

各模块一句话职责:

模块干什么关键文件
工具注册表决定本次装哪些工具、怎么向模型描述、能否并行src/tools/registry.ts
并发执行器把模型发来的一批调用分成并行批 / 串行,驱动执行src/agent/tool-executor.ts
并发原语多个异步生成器并发跑、带上限、谁先出值先 yieldsrc/utils/concurrency.ts
权限引擎每次调用给出 allow/ask/deny,bash 走解析-分类-匹配src/permissions/engine.ts
命令解析器把 bash 字符串拆成可分析的 segment,fail-closedsrc/permissions/command-parser.ts
只读分类器判断一个 segment 是否纯读(不改磁盘/网络)src/permissions/read-only.ts
规则库bash 规则语法/匹配/持久化 + 内建安全底线src/permissions/rules.ts

3. 主线一:工具注册与描述

这节讲:一次对话开始前,Dexter 怎么装配出「本次可用的工具清单」,以及每个工具如何用 最省 token 的方式向模型自我介绍。

3.1 一个工具在注册表里长什么样

Dexter 不是把裸工具函数丢给模型,而是给每个工具包一层元信息。这个结构叫 RegisteredTool (src/tools/registry.ts:28-39):

// 真实源码,registry.ts:28
export interface RegisteredTool {
name: string; // 工具名,要和 tool.name 一致
tool: StructuredToolInterface; // 真正可调用的工具实例
description: string; // 给系统提示的「完整版」描述(何时用/何时别用)
compactDescription: string; // 1-2 句的「精简版」,省 token
concurrencySafe: boolean; // 能否和别的并行工具同时跑
}

四个字段各有分工:description 是长篇说明书,compactDescription 是电梯广告, concurrencySafe 是给执行器看的「我能不能并行」标签,tool 才是真身。

3.2 为什么要「精简描述」——省 token

每个工具的完整 description 动辄几十行(看 §6 的 GET_FINANCIALS_DESCRIPTION 就知道)。 如果把十几个工具的完整描述全塞进系统提示,光工具说明就吃掉几千 token。

Dexter 的取舍:完整 schema 由 bindTools() 走模型原生的工具协议传递(模型天然能看到参数结构), 而系统提示里只放一句话的 compactDescription。组装这段精简清单的是 buildCompactToolDescriptions(registry.ts:263-267):

// 真实源码,registry.ts:263
export function buildCompactToolDescriptions(model: string): string {
return getToolRegistry(model)
.map((t) => `- **${t.name}**: ${t.compactDescription}`)
.join('\n');
}

产出就是一行行 - **get_financials**: Financial statements and metrics…。 这段被 src/agent/prompts.ts:224 注入系统提示。注释里说得很直白:模型已经通过 bindTools() 拿到完整 schema,所以提示里不必重复。

3.3 按环境条件注入工具

getToolRegistry(model)(registry.ts:48)先无条件放入一批核心工具(finance 四件套、 文件读写、memory、cron 等),然后按运行环境动态追加几个:

  • web_search 回退链(fallback chain)——registry.ts:164-196。这是最精巧的一处: Dexter 支持 Exa / Perplexity / Tavily / LangSearch 四家搜索供应商,但只有配了对应 API key 的才进候选。然后按用户 /search 选的偏好把首选排到最前,其余作为后备,包成 一个 web_search 工具。也就是说,模型只看到一个「搜索」工具,底层却是「首选挂了自动 换下一家」的链。没配任何 key 时,web_search 干脆不出现在清单里

    首选(用户 /search 选的) → 其余已配 key 的供应商 → 都失败才报错
    ┌──────────┐ fail ┌────────────┐ fail ┌──────────┐
    │ Exa │ ───────▶ │ Perplexity │ ───────▶ │ Tavily … │
    └──────────┘ └────────────┘ └──────────┘
  • x_search——registry.ts:198-206,仅当 process.env.X_BEARER_TOKEN 存在才注入。

  • skill——registry.ts:208-217,仅当 discoverSkills() 发现了本地 Skill 才注入(详见 05)。

  • bash——registry.ts:221-229,仅非 Windows(process.platform !== 'win32')才注入, 因为它依赖 /bin/sh 和 POSIX 进程组。注释还点明:CLI-only 的渠道门控(WhatsApp 网关里没人 在键盘前,不给 bash/ask_user_question)不在这里做,而在 Agent.createCLI_ONLY_TOOLS 里过滤(src/agent/agent.ts:29:86-87)。

一句话:注册表 = 一份「按当前环境算出来的、这次真能用的工具菜单」。

3.4 并发标签怎么被取走

执行器不关心整个 RegisteredTool,它只要一张「工具名 → 能否并行」的查找表。 getToolConcurrencyMap(registry.ts:237-239)就把注册表压成这张 Map:

// 真实源码,registry.ts:237
export function getToolConcurrencyMap(model: string): Map<string, boolean> {
return new Map(getToolRegistry(model).map(t => [t.name, t.concurrencySafe]));
}

哪些标了 concurrencySafe: true?所有纯查询/只读工具(finance 四件套、web_fetch、browser、 read_file、memory_search…)。哪些是 false?有副作用或要人机交互的:write_fileedit_filememory_updateask_user_questionskillbash。这张表是下一节「并行 vs 串行」的唯一依据。


4. 主线二:并发执行与限流

这节讲:模型一轮可能吐出好几个 tool_calls,执行器怎么决定哪些能同时跑、进度怎么实时冒出来、 重复调用怎么被软性劝退。

4.1 核心思路:把「连续的只读调用」批成并行

设想模型说「同时查 AAPL、MSFT、GOOGL 三家的营收」,发来 3 个 get_financials 调用。它们互不 依赖、都是只读——没理由排队等。但如果中间夹了个 write_file,写操作就必须自己单独、按顺序来。

AgentToolExecutor(src/agent/tool-executor.ts:45)的做法是先分区(partition)再执行partitionToolCalls(:94-115)扫一遍调用列表,把连续的 concurrencySafe 调用攒进同一个 并行批,遇到不安全的就单开一批:

模型这一轮发来: [get_fin, get_fin, write_file, read_file, get_market]
└─────┬─────┘ │ └──────┬──────┘
并行批(2个) 串行(1个) 并行批(2个)
partitionToolCalls 的规则(tool-executor.ts:104):
isSafe 且 上一批也是并行批 → 塞进上一批
否则 → 另起一批

真实代码就是这段判断(tool-executor.ts:104-111):

// 真实源码,tool-executor.ts:104
const isSafe = this.concurrencyMap.get(call.name) ?? false;
const lastBatch = batches[batches.length - 1];
if (isSafe && lastBatch?.concurrent) {
lastBatch.calls.push(call); // 并入当前并行批
} else {
batches.push({ concurrent: isSafe, calls: [call] }); // 另起一批
}

注意「连续」二字:写操作会打断并行批。这保证了执行顺序里,写操作两侧的只读调用不会 「跨过」它并行,语义上更安全。executeAll(:73-88)随后遍历这些批:并行批(且多于 1 个) 走 executeBatchConcurrently,否则逐个串行。

4.2 并发原语:谁先出值谁先被 yield

并行批交给 executeBatchConcurrently(tool-executor.ts:120-126),它把每个调用变成一个 异步生成器,再交给通用原语 all(src/utils/concurrency.ts:24)。

all 的巧妙在于它不是「等全部跑完再一起返回」,而是Promise.race 唤醒最先出值的那个 生成器,立刻把值 yield 出去,再把它重新入队(concurrency.ts:44-58)。效果是:

  • 进度实时:哪个工具先有进度消息,UI 就先看到它,不用等最慢的。
  • 天然背压 + 限流:concurrencyCap(执行器传的 DEFAULT_MAX_CONCURRENCY = 10, tool-executor.ts:31)限制同时在跑的生成器数,满了就让后来的在 waiting 里排队, 一个跑完立刻补一个进来(concurrency.ts:53-57)。
promises(在跑, ≤ cap) waiting(排队)
┌────┬────┬────┐ ┌────┬────┐
│ g1 │ g2 │ g3 │ Promise.race │ g4 │ g5 │
└──┬─┴────┴────┘ └────┴────┘
│ g1 先出值 → yield 它的值 → g1 重新入队
│ g2 结束 → 从 waiting 取 g4 补位

谁快谁先被消费,慢的不挡快的

4.3 进度通道:工具内部怎么把「Fetching…」冒出来

工具执行不是黑盒。每次调用前,执行器建一个 progress-channel(tool-executor.ts:190), 把它的 emit 塞进 config.metadata.onProgress 传给工具。工具内部随时 onProgress('Fetching...'), 执行器则用 for await (const message of channel) 把这些消息实时转成 tool_progress 事件 (tool-executor.ts:204-206)。工具的 promise 一 settle 就 channel.close(),循环自然结束。 这就是终端里工具跑动时那些流动小字的来源。

4.4 两道「劝退」而非「拦截」的软机制

执行器里还有两处只警告、不阻断的设计,专治模型的坏习惯:

  • Skill 去重:partitionToolCalls 开头(tool-executor.ts:99-101),如果模型又要跑一个 本轮已经执行过的 skill,直接 continue 跳过——同一个 skill 不重复触发。
  • 软限流警告:每个调用前查 ctx.scratchpad.canCallTool(tool-executor.ts:175)。它永远 返回 allowed=true(见 src/agent/scratchpad.ts:131,注释明说「Always allows the call but provides warnings」),但当同一工具被调太多次、或这次 query 和之前高度相似时,会附一段 warning,通过 tool_limit 事件注入回上下文,提醒模型「别再原地打转,换个工具/换个词/直接 向用户坦白数据缺口」。这是防重试死循环,而不是硬性配额。

5. 主线三:权限引擎(安全闸门)

这节讲:每次工具调用真正执行前,evaluatePermission 怎么给出 allow / ask / deny。重点是 bash ——它是唯一能跑任意命令的工具,也是整套权限系统的战场。

5.1 三种裁决与一条分界线

入口 evaluatePermission(src/permissions/engine.ts:107-116)极简:

工具裁决
write_file / edit_file一律 ask(遗留行为:改文件必问)
bash交给 evaluateBash 做完整判定(本节核心)
其它一切allow(自动放行)

裁决落到执行器 executeSingleWithId(tool-executor.ts:141-172)后:deny 静默拒绝、连人 都不惊动(:142-146,注释说这是「避免橡皮图章疲劳」——真正危险的东西不该反复弹窗磨钝用户); ask 则先查 session 审批缓存,没批过才弹窗问人;allow 直接跑。

5.2 bash 判定:fail-closed 的六步瀑布

evaluateBash(engine.ts:38-102)的整套哲学写在文件头注释里,可以浓缩成一条从上到下、命中即停 的瀑布。读法:越靠上优先级越高;任何一步命中就定案。

bash 命令字符串


① 解析失败(parser 标 unknown) ──► ask,且 sessionCacheable=false(永不缓存)
│ 能解析

② 内建安全底线 builtinDeny ────────► deny(不可被用户规则覆盖)
│ 过关

③ 命中用户 deny 规则 ──────────────► deny


④ 命中用户 ask 规则 ───────────────► ask


⑤ 每个 segment 都命中 allow 规则 ──► allow
│ 否则

⑥ 默认 defaultBashDecision(ask) 只读命令「识别但不自动放行」

几个不显然但关键的点:

  • fail-closed(失败即保守)是第一原则。第①步:只要解析器看不懂这条命令, 就当作可疑,退回问人,而且永不进 session 缓存(engine.ts:42-50)。宁可多问,不可放行看不懂的东西。
  • 安全底线不可被用户覆盖。第②步的 builtinDeny 排在所有用户规则之前——用户就算写了 allow 规则也压不过它(engine.ts:57-62)。
  • allow 要求「每个 segment 都过」。一条 a && b 里只要有一段没被 allow 覆盖,整条就不放行 (engine.ts:83-88)。而且带写重定向(>)的 segment 永远不能靠基于词的规则自动放行 ——因为规则表达不了重定向目标,只能退回问人。
  • 只读 ≠ 自动放行(当前阶段)。第⑥步注释点明:read-only 命令会被识别并展示,但在 OS 沙箱 落地前仍然问人(engine.ts:90-101)。这是一个诚实的「尚未完成」的边界。

5.3 命令解析器:为什么「看不懂就拒绝」

parseCommand(src/permissions/command-parser.ts:389)是这套系统的安全基石。它把 /bin/sh -c 的命令串拆成一个个独立 segment(以 && || | ; & 分隔),每段解析出:命令词、 basename、参数、前置的 VAR=value、以及有没有写重定向(ParsedSegment,command-parser.ts:23-36)。

它的设计信条是**「宁可标 unknown,绝不猜」**。词法分析器 lex(command-parser.ts:70)遇到任何 动态或无法静态审查的构造,一律 fail(...) 标为 unknown —— 而 unknown 在引擎里就等于「不自动放行」:

构造例子为什么 fail-closed
变量 / 命令替换$VAR`cmd`展开后是什么,静态看不出来
子 shell / 分组( … ){ … }改变执行语义,难分析
glob 通配rm *.txt?[abc]展开成哪些文件无法预知
here-doc / 进程替换<<EOF<(cmd)内容/命令不可见
未闭合引号、注释'…# …意图不明,保守处理

解析器还主动穿透包装器(WRAPPERS,command-parser.ts:58-60,如 time/nice/env/timeout), 跳过包装器和它的选项参数,直抵真正会跑的那条命令——这样 env LD_PRELOAD=x ls 不会因为 命令词是 env 就蒙混过关,里面的 LD_PRELOAD= 仍被留给安全底线检查(command-parser.ts:360-365)。 而 sh/bash/python 等解释器故意不算包装器,它们要作为命令词暴露出来,好被判为「非只读」。

5.4 内建安全底线:两类硬拒绝

builtinDeny(src/permissions/rules.ts:141-150)是那道用户改不动的墙,只拒两类东西:

  • 危险环境变量注入——DANGEROUS_ENV(rules.ts:109)。像 LD_PRELOAD=PATH=NODE_OPTIONS=GIT_SSH_COMMAND= 这些赋值能劫持命令解析或注入代码,一律 deny。
  • 读取机密路径——SECRET_PATTERNS(rules.ts:116-129)。命中 .env.ssh/.aws/id_rsa*.pem*.key.dexter/credentials 等,一律 deny。连 --opt=VALUE 里的 value 也会被拆出来单独比对(secretCandidates,rules.ts:131-135)。

5.5 只读分类器:保守的白名单

「这条命令算不算只读」由 isReadOnly(src/permissions/read-only.ts:69-75)判,规则是 白名单 + fail-closed:不在允许表里的命令,一律当「会改东西」处理。三重否决:

  1. 有写重定向(>)→ 不是只读;
  2. NEVER_READ_ONLY(read-only.ts:18-23,含解释器 python/node/sh、以及 sed/awk/tee/dd 这些能写的)→ 不是只读;
  3. READ_ONLY 白名单里,但还要过逐命令的 flag 检查(read-only.ts:53-66)——比如 git 只有 status/log/diff 等子命令算只读(gitReadOnly),find-exec/-delete 就不算(findReadOnly)。

5.6 session 审批缓存:批过一次,本会话别再烦

用户在弹窗里可以选 allow-session(本次会话内不再问)或 allow-always(永久规则)。 「同一个东西算不算批过」由 sessionKey(engine.ts:127-135)定义,它对不同工具用不同粒度:

工具session key含义
write_file / edit_file固定 file:write共享一把钥匙:批准写文件后,两个工具都放行
bashbash:<精确命令>按精确命令:批了 git status 绝不顺带放行 git push --force
其它工具名按工具粒度

bash 之所以按精确命令串做 key,注释讲得很清楚:一个 allow-session 授权绝不能外溢到 另一条命令——哪怕两条命令共享同一个宽泛的 ask 规则(engine.ts:120-134)。执行器在 tool-executor.ts:152 用这个 key 查 sessionApprovedTools;选 allow-always 时则调 addRule('allow', proposedRule)(tool-executor.ts:167-169)把规则永久写进 .dexter/settings.json

能被「always allow」提议什么规则也很讲究(proposeRule,rules.ts:166-178):只读命令 才给宽松的前缀通配(Bash(ls:*),甚至精确到子命令 Bash(git status:*));会改东西的命令 只给精确规则(Bash(rm important.txt))——这样批准一次善意的 rm 绝不会顺手放行 rm -rf / 这种毁灭性兄弟。


6. 「元工具」范式:一次调用、内部消化复杂度

这节用金融领域的 get_financials 做样例,点出 Dexter 一个反复出现的设计模式:把复杂度藏进 工具内部,对外只暴露「一个自然语言查询」的简单契约。

6.1 问题:金融数据源太碎

「查财报」其实分好多种子接口:利润表、资产负债表、现金流量表、关键比率、分部营收、盈利意外…… 如果把它们全暴露成八个独立工具,主 agent 就得自己纠结「营收该调哪个」「对比两家该调几次」, 既费 token 又容易选错。

6.2 思路:在工具内部再开一层 LLM 路由

get_financials 的答案是元工具(meta-tool):对外只有一个入参 query(自然语言), 内部再开一次 LLM 原生 tool-calling,让一个「路由小模型」把这句话分派到真正的子工具上。 看它对外的契约(GET_FINANCIALS_DESCRIPTION,src/tools/finance/get-financials.ts:15-46), 核心承诺就一句:「Call ONCE with the complete natural language query — the tool handles complexity internally」

内部流程 createGetFinancials(...).func(get-financials.ts:129-218)分四步:

query: "compare AAPL vs MSFT revenue last year"

① callLlm(query, { tools: FINANCE_TOOLS }) 内层再开一次 LLM 原生 tool-calling
│ 系统提示 buildRouterPrompt: 教它 ticker 解析 + 日期推断 + 选哪个子工具

模型回一批子工具调用: get_income_statements(AAPL), get_income_statements(MSFT)

② Promise.all 并行执行所有子工具调用,每个带超时 withTimeout

③ 用 FINANCIAL_FORMATTERS[tool] 把每份原始数据格式化、按 ticker 归并

④ formatToolResult(combinedData, allUrls) 汇总 + 附带 source URLs 供核对

对应真实源码:第①步 callLlm(input.query, { model, systemPrompt: buildRouterPrompt(), tools: FINANCE_TOOLS }) (get-financials.ts:143-147);第②步 Promise.all(toolCalls.map(...)) 并行执行、每个包 withTimeout(...)(:159-186);第③步按 FINANCIAL_FORMATTERS(src/tools/finance/formatters.ts:416) 逐个格式化、以 ${tool}_${ticker} 为 key 归并(get-financials.ts:198-205)。

FINANCE_TOOLS(get-financials.ts:60-73)就是那八个真子工具:getIncomeStatementsgetBalanceSheetsgetCashFlowStatementsgetAllFinancialStatementsgetEarningsgetKeyRatiosgetHistoricalKeyRatiosgetFinancialSegments。路由提示 buildRouterPrompt(:79-118)则把「Apple → AAPL」「last quarter → report_period_gte 3 months ago」 「要 P/E 走 key_ratios、要营收走 income_statements」这些领域知识灌给内层模型。

6.3 为什么这个范式值得学

优点具体好处
主 agent 的工具面变小一个 get_financials 顶八个子工具,系统提示更省、模型更少选错
复杂度就地消化ticker 解析、日期推断、多公司对比、并行取数,全在工具内部搞定
天然并行内层用 Promise.all 一次把多家/多指标并发拉回来
可核对结果附 sourceUrls,方便下游/用户验证

get_market_dataread_filings 等其它金融工具是同一模子刻出来的(registry.ts:57-77 都标 concurrencySafe: true)。记住这个契约:对模型说「一次调用,内部处理复杂度」,把纠结留给工具自己。

6.4 顺带一提:filesystem 与 bash 需审批

和这些「读」性质的元工具相反,write_file/edit_file/bash 都是 concurrencySafe: false 且要过 §5 的权限闸门:文件写一律 ask(engine.ts:112-114),bash 走完整六步瀑布。 它们的 compactDescription 也如实标注——例如 bash 是 「Run a shell command … CLI only; every command asks for approval」(registry.ts:226)。


7. 边界与局限(诚实地说)

  • 只读 bash 现在仍要人批engine.ts:90-101 的注释明说:read-only 的自动放行要等 OS 沙箱 (Phase 3) 才开;当前阶段只读命令只是被「识别 + 展示分类」,不省那次弹窗。这是刻意的保守。
  • 命令解析器保守到会误伤。任何 glob、变量、子 shell 都触发 fail-closed → 问人。安全但偶尔啰嗦—— 一句普通的 ls *.md 也会因为 * 被标 unknown。这是「宁问勿放」的自觉代价。
  • 权限只覆盖 bash 与文件写。其它工具(搜索、fetch、finance)一律 allow,不进闸门——它们被 当作无副作用的读操作。真正的网络副作用(比如 web_fetch 打到内网)不在本引擎的防护范围内。
  • 软限流不是硬配额canCallTool 永远放行(scratchpad.ts:131),只发警告。一个执意刷同一 工具的模型不会被真正拦住,只会被反复劝。

8. 巧妙之处(可借鉴)

  1. 完整 schema 走 bindTools、提示里只放一句话(registry.ts:263)——把「给模型看参数」和 「给模型看用途」分到两条通道,系统提示因此省下大量 token。
  2. 搜索供应商包成一条回退链、对模型只露一个工具(registry.ts:164-196)——供应商是运维细节, 模型不该关心。配 key 才注入,零配置就消失。
  3. 「连续只读才批成并行、写操作打断批次」(tool-executor.ts:94-115)——用极简的一次线性扫描 同时拿到「最大并行」和「写操作两侧不乱序」两个性质。
  4. Promise.race 驱动的并发原语带天然背压(concurrency.ts:24-60)——快的工具的进度先冒出来, 慢的不挡道,还顺便实现了并发上限。
  5. 权限判定是 fail-closed 的瀑布,安全底线压在用户规则之上(engine.ts:38-102)——看不懂就问、 危险的直接拒、且拒绝不惊动人以免审批疲劳。
  6. session key 按工具语义分粒度(engine.ts:127-135)——文件写共享钥匙求方便,bash 按精确命令 求安全,一把钥匙绝不外溢到危险兄弟命令。
  7. 元工具「一次调用、内部路由」契约(get-financials.ts)——把领域复杂度锁在工具里,主 agent 的决策面保持干净。

9. 代码地图(导航索引)

主题文件关键符号
工具注册结构src/tools/registry.tsRegisteredToolgetToolRegistry
精简描述(省 token)src/tools/registry.tsbuildCompactToolDescriptions
并发标签映射src/tools/registry.tsgetToolConcurrencyMap
搜索回退链条件注入src/tools/registry.tsallWebSearchProviderscreateWebSearchTool
CLI-only 渠道门控src/agent/agent.tsCLI_ONLY_TOOLSAgent.create
并行/串行分区src/agent/tool-executor.tsAgentToolExecutorpartitionToolCallsexecuteAll
并行批执行src/agent/tool-executor.tsexecuteBatchConcurrentlyexecuteSingleWithId
并发原语 + 限流src/utils/concurrency.tsall(concurrencyCap)
进度通道src/utils/progress-channel.tscreateProgressChannel
软限流 / skill 去重src/agent/scratchpad.tscanCallToolhasExecutedSkill
权限入口src/permissions/engine.tsevaluatePermissionevaluateBashsessionKey
命令解析(fail-closed)src/permissions/command-parser.tsparseCommandlexParsedSegmentWRAPPERS
只读分类src/permissions/read-only.tsisReadOnlyNEVER_READ_ONLYREAD_ONLY
安全底线 + 规则src/permissions/rules.tsbuiltinDenymatchSegmentproposeRuleaddRule
元工具样例src/tools/finance/get-financials.tscreateGetFinancialsGET_FINANCIALS_DESCRIPTIONbuildRouterPrompt

相邻章节: 上层的主循环如何调用本章的执行器,见 01 核心主循环; 工具大结果的截断与预算,见 03 上下文工程; memory_* 工具的内部,见 04 记忆系统; spawn_subagentskill 的展开,见 05 委派与专长; 全景见 Dexter 首页