跳到主要内容

单个 Agent 怎么跑:对话循环、工具默认拒绝、沙箱、委派

30 秒导读: 上层 Coordinator 把目标拆成任务、任务队列 按依赖调度它们——但真正"干活"的是每个 worker 里的一个 while(true) 对话循环。本章拆开这个内核:它怎么反复调模型、把模型要的工具抽出来、默认拒绝地决定哪些工具能跑、并行执行它们、再把结果喂回去,直到模型说"讲完了"。

本章聚焦"一个 Agent 的执行内核",不展开模型适配器细节(那是 第 4 章)。你在这里会看到:对话循环的进/出条件、工具的三层过滤 + 执行门、文件系统沙箱、Agent 之间的同步委派,以及本地模型没有原生 tool_calls 时的文本回退。


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

一句话定义: 一个 Agent = "反复跟大模型对话、按模型的要求调用工具、把工具结果再喂回给模型"的一段循环代码,直到模型不再要工具、给出最终答案。

它要解决的问题: 大模型本身只会"生成文本"。它不能自己读文件、跑命令、查数据库。要让它真正完成任务,你得给它一组工具(函数),让它在回答里"申请"调用某个工具,由你的代码去执行、把结果告诉它,它再接着想。这个"申请—执行—回填—再想"的往返,就是 agentic loop(智能体循环)

用起来什么样: 最小示例——建一个注册表、注册内置工具、造一个 Agent,跑一句话:

// 示意,基于 agent/agent.ts 顶部的 @example
const registry = new ToolRegistry()
registerBuiltInTools(registry) // 把 bash/file_*/grep/glob 装进注册表
const executor = new ToolExecutor(registry)

const agent = new Agent(
{
name: 'researcher',
model: 'claude-sonnet-4-6',
systemPrompt: 'You are a rigorous research assistant.',
tools: ['file_read', 'grep'], // ← 显式授权:只给这两个工具
},
registry,
executor,
)

const result = await agent.run('总结一下项目 README。')
console.log(result.output)

一句话直觉:Agent 想成"经理",AgentRunner 想成"经理脑子里那台一圈圈转的转盘"。经理负责对外的门面(记历史、管状态、校验输出),转盘负责真正的一圈圈:问模型 → 干活 → 回填 → 再问。

这里有个关键安全默认——"默认拒绝": 上面代码里 tools: ['file_read', 'grep'] 不是可选装饰,而是授权。如果你什么都不写,这个 Agent 能用的内置工具是零个bash、写文件、删文件,全都要你显式点头才给。这一条贯穿本章。


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

2.1 部件与职责

部件干什么在哪个文件
Agent对外门面:记历史、管生命周期状态、结构化输出校验、动态增删工具agent/agent.ts
AgentRunner内核:while(true) 对话循环、工具解析(默认拒绝)、并行执行、上下文压缩agent/runner.ts
ToolRegistry工具的名字→定义表;把工具转成 LLM 认的 JSON Schematool/framework.ts
ToolExecutor真正跑一个工具:Zod 校验输入 → 执行 → 校验输出 → 截断;错误不抛,返回 isErrortool/executor.ts
内置工具bash / file_read / file_write / file_edit / grep / glob / delegate_to_agenttool/built-in/
path-safety文件系统工具的沙箱:把每个路径(含符号链接)锁死在 cwd 内tool/built-in/path-safety.ts
LoopDetector检测"同一工具/同一句话反复重复",防死循环agent/loop-detector.ts
text-tool-extractor本地模型不吐原生 tool_calls 时,从文本里抠出工具调用tool/text-tool-extractor.ts

2.2 主线走一遍(高层)

怎么读下图:从上往下是一次 run() 的时间线;中间那个方框是循环体,会转很多圈,直到模型不再要工具。

agent.run("...") Agent (agent/agent.ts)
│ transitionTo('running') · beforeRun 钩子 · 懒建 runner

runner.stream(messages) AgentRunner (agent/runner.ts)

│ ┌─ 循环外一次性做:resolveTools() → toolDefs + grantedToolNames
│ │ (默认拒绝 + preset/allow/deny 过滤都在这)
│ ▼
┌────────────── while(true) ────────────────────────────┐
│ ① 退出检查:abort? turns>=maxTurns? │
│ ② adapter.chat(messages) ── 调模型(第4章) │
│ ③ 抽 tool_use 块;没有 → 记 output,break(end_turn) │
│ ④ 有 tool_use → LoopDetector 查重 │
│ ⑤ Promise.all 并行执行每个工具: │
│ · grantedToolNames 没有它 → "not granted" 错误 │
│ · 有 → ToolExecutor.execute(不抛,返回 isError) │
│ ⑥ 把 tool_result 拼成 user 消息,push 回 messages │
│ └─ 回到 ① │
└────────────────────────────────────────────────────────┘


RunResult { output, toolCalls, tokenUsage, turns, ... }

主线里两个最重要的设计,下面各开一节:默认拒绝的工具解析(§3)并行执行 + 错误隔离(§4)


3. 核心原理一:对话循环怎么进、怎么出

3.1 它要解决的小问题

一个 agentic loop 最怕两件事:转不停(死循环、烧钱)和转出半截脏状态(留下一个没配对的 tool_use,下次把历史重放给 API 就 400)。这一节看 AgentRunner.stream(agent/runner.ts:708)怎么同时防住这两点。

3.2 循环的骨架

run() 只是把 stream() 的事件收集起来(agent/runner.ts:673),真正的循环在 stream() 里,是一个 while (true)(agent/runner.ts:779)。每圈干四件事:

  1. 调模型: const response = await this.adapter.chat(conversationMessages, baseChatOptions)(agent/runner.ts:815)。
  2. 抽 tool_use: extractToolUseBlocks(response.content)(agent/runner.ts:873)。
  3. 无工具 → 收尾: 模型这轮没要任何工具,就是最终回答,finalOutput = turnText; break(agent/runner.ts:948)。
  4. 有工具 → 执行 → 回填 → 继续: 见 §4。

3.3 三个退出口

循环靠三个条件跳出,都在每圈开头或结尾检查:

退出口触发条件代码位置
end_turn模型这轮不再要工具(自然结束)agent/runner.ts:948-949
maxTurns圈数到上限(默认 10),硬停agent/runner.ts:786,默认值 agent/runner.ts:383
abortAbortSignal 被触发(超时/取消)agent/runner.ts:781

maxTurns 是防"转不停"的兜底闸。它在每圈最开头检查,所以最多多跑一次调用就停。

3.4 一个精巧处:预算超限也要先把 tool_result 补齐再 break

这是"别留脏状态"的典型。当累计 token 超过 maxTokenBudget 时,代码不立刻 break——因为此刻 tool_use 已经 append 进历史,但对应的 tool_result 还没生成。直接 break 会留下一个孤儿 tool_use,后续任何重放这段历史的 API 调用都会被 Anthropic/OpenAI 拒(400)。

所以它设一个 pendingBudgetExceeded 标志(agent/runner.ts:869),先照常把工具跑完、把 tool_result 消息拼好 append,然后才 break(agent/runner.ts:1092)。注释把动机写得很清楚:

// agent/runner.ts:1088-1091(节选)
// Budget check is deferred until tool_result events have been yielded
// and the tool_result user message has been appended, so ... the returned
// `messages` remain resumable against the Anthropic/OpenAI APIs.

同样的"先配对再退出"逻辑也用在 loop 检测的 terminate 分支上(检测放在 yield tool_use 事件之前,见 agent/runner.ts:875-919),都是为了永不吐出不成对的 tool_use

3.5 死循环检测:LoopDetector

模型有时会卡在"反复调同一个工具、传同样参数"或"反复说同一句话"。LoopDetector(agent/loop-detector.ts:33)用滑动窗口盯这个:

  • 工具签名去重: 把这轮所有 tool_use 排序后 JSON.stringify 成一个确定性签名(computeToolSignature,agent/loop-detector.ts:102),排序保证 {b,a}{a,b} 同签名。
  • 数到阈值就报警: 连续重复 maxRepetitions(默认 3)次就返回 LoopDetectionInfo(agent/loop-detector.ts:59)。

检测到后,runner 按 onLoopDetected 配置动作(agent/runner.ts:893):terminate 直接停;warn/inject 往对话里塞一条"你好像卡住了"的警告消息(loopWarningText,agent/runner.ts:343),第二次再命中就强制停(agent/runner.ts:903);中间若某轮没检测到,警告状态重置(agent/runner.ts:916),给恢复的 agent 一次新机会。


4. 核心原理二:工具"默认拒绝"与执行门

这是本章最该带走的精华:一个内置工具从"注册在册"到"真的能被这个 Agent 跑",要过两道关。

4.1 为什么要默认拒绝

注册表里装着 bash、写文件、删文件这些"有牙齿"的工具。如果注册即可用,那么任何一个 Agent——哪怕它的任务只是"总结一段文字"——都天然能 rm -rf。更糟的是 prompt injection:被投毒的输入可能诱导模型去调一个它本不该碰的工具。

所以框架的选择是:内置工具默认一个都不给,必须显式授权。 授权渠道只有两个:toolPreset(预设组)或 allowedTools(白名单)。两个都没设,这个 Agent 解析出来的内置工具就是空数组

4.2 第一道关:resolveTools() 的三层过滤

resolveTools()(agent/runner.ts:591)在循环跑一次,决定"模型会被告知它有哪些工具"。流程:

怎么读下图:工具从"注册表全集"一路被筛,任一层没过就出局。

注册表全集 toToolDefs()

│ 先摘出"运行时/自定义工具"(addTool/customTools 注册的)——
│ 它们豁免下面的授权检查(注册本身就是授权)

┌─ 默认拒绝闸:没有 toolPreset 也没有 allowedTools?
│ → 内置工具全清空(filteredTools = []) runner.ts:624-628

① toolPreset 过滤:只留预设集合里的名字 runner.ts:631-634

② allowedTools 白名单:只留名单里的 runner.ts:637-639

③ disallowedTools 黑名单:剔掉名单里的 runner.ts:642-647

④ 框架安全栏 AGENT_FRAMEWORK_DISALLOWED:再剔一层 runner.ts:650-651

最终 = 过滤后的内置工具 + (运行时工具 - 黑名单) runner.ts:654-657

三个预设组(TOOL_PRESETS,agent/runner.ts:50):

预设含哪些工具
readonlyfile_read, grep, glob
readwritefile_read, file_write, file_edit, grep, glob
full上面全部 + bash

AGENT_FRAMEWORK_DISALLOWED(agent/runner.ts:57)目前是空数组,是为未来的框架级禁用留的基础设施。运行时工具(agent.addTool() 加的)豁免 preset/白名单,但仍受黑名单约束(agent/runner.ts:654)——"注册即授权,但你仍能事后禁掉它"。

4.3 第二道关:grantedToolNames 执行门

只在循环外过滤 toolDefs 还不够。模型只被"告知"了 toolDefs,但一个糊涂模型(或注入攻击)照样能吐出一个没授权名字的 tool_use。如果这时直接拿名字去注册表里查、查到就跑,默认拒绝就形同虚设。

于是有第二道关。循环外从 toolDefs 建一个集合(agent/runner.ts:730):

// agent/runner.ts:730
const grantedToolNames = new Set(toolDefs.map((t) => t.name))

执行每个工具之前先问这个集合(agent/runner.ts:976):

// agent/runner.ts:976-987(节选)
if (!grantedToolNames.has(block.name)) {
result = {
data: `Tool "${block.name}" is not granted to this agent. ...`,
isError: true,
}
} else {
result = await this.toolExecutor.execute(block.name, block.input, toolContext)
}

注释一针见血:模型"can still emit a tool_use for an ungranted name; gate execution on this set so a registered-but-ungranted tool can never run silently"(agent/runner.ts:728)。"告知"和"允许执行"是两套集合,后者才是最终裁决。

4.4 谁来设默认:Agent 与 Orchestrator

Agent 把配置直接映射进 runner:allowedTools: this.config.toolstoolPresetdisallowedTools(agent/agent.ts:184-186)。所以 §1 里 tools: ['file_read', 'grep'] 就是走 allowedTools 白名单。

多 Agent 编排 里,OrchestratorConfig.defaultToolPreset 可以给所有 worker 兜底授权(把默认拒绝放宽回"允许一组")——这条在编排层,不在本章展开。


5. 核心原理三:并行执行,错误不抛只标 isError

5.1 并行

模型一轮里可能同时申请多个独立工具(比如一次读三个文件)。串行等就浪费了。runner 把这一轮所有 tool_use 用 Promise.all 一起发(agent/runner.ts:1047):

// agent/runner.ts:966 起(简化)
const executionPromises = toolUseBlocks.map(async (block) => {
// grantedToolNames 门 → ToolExecutor.execute → 组装 tool_result
})
const executions = await Promise.all(executionPromises) // runner.ts:1047

底层 ToolExecutor 还有一层并发上限:Semaphore(默认 4,tool/executor.ts:61)。executeBatch 里每个调用都过信号量(tool/executor.ts:122),所以哪怕模型一次要 20 个工具,同时真正在跑的也只有 4 个。

5.2 错误隔离:这是全框架的一条铁律

工具错误永不 throw,一律变成 ToolResult{ isError: true } 为什么?因为如果一个工具抛异常把循环炸了,整个 agent 就死了;而模型其实完全可以"看到错误、换个办法"。所以框架把每一层的错都兜住、变成一条能喂回给模型的文本。

三处兜底,层层设防:

兜在哪兜什么代码
ToolExecutor.runToolZod 输入校验失败、工具内部抛异常、输出校验失败tool/executor.ts:147-189
ToolExecutor.execute工具名没注册、执行前已 aborttool/executor.ts:84-96
runner 的执行 promiseexecutor 万一还是抛了,再兜一层agent/runner.ts:995-999

ToolExecutor 的类注释把契约写死了:"All errors — including unknown tool names, Zod validation failures, and execution exceptions — are caught and returned as ToolResult objects with isError: true"(tool/executor.ts:46)。

5.3 委派 token 计回父预算

有个细节:工具结果可以带 metadata.tokenUsage(比如 delegate_to_agent 让别的 agent 跑了一圈,烧了 token)。runner 把这些委派用量加进 totalUsage(agent/runner.ts:1052-1059),让 maxTokenBudget 在委派链上仍然准确——你委派出去的活也算你头上。


6. 工具是怎么定义的:defineTool + Zod + ToolRegistry

6.1 defineTool:一个工具的样子

defineTool(tool/framework.ts:71)是造工具的唯一入口。一个工具 = 名字 + 描述 + Zod 输入 schema + execute 函数:

// 示意,来自 tool/framework.ts 的 @example
const echoTool = defineTool({
name: 'echo',
description: 'Echo the input message back to the caller.',
inputSchema: z.object({ message: z.string() }), // ← Zod:既校验运行时输入,又生成给 LLM 的 schema
execute: async ({ message }) => ({ data: message, isError: false }),
})

Zod schema 一物两用:运行时用它 safeParse 校验模型传来的参数(tool/executor.ts:154);给模型时zodToJsonSchema 转成 JSON Schema(tool/framework.ts:198toToolDefs)。可选的 outputSchema 还能校验工具输出(必须是 string,tool/framework.ts:83);maxOutputChars 能限制单个工具输出长度。

6.2 zodToJsonSchema:手写的转换器

Zod v3 没有官方 JSON Schema 导出,框架自己写了个递归转换器 convertZodType(tool/framework.ts:279),读 Zod 内部的 _def 结构,覆盖了 string/number/enum/array/object/optional/union/literal/nullable/default/record/tuple 等一大票类型。不认识的类型退化成 {}(任意值),仍是合法 JSON Schema(tool/framework.ts:482)。

6.3 ToolRegistry:名字表

ToolRegistry(tool/framework.ts:121)就是一张 Map<name, ToolDefinition>。关键约束:

  • 重名直接抛错,防静默覆盖(register,tool/framework.ts:135)。
  • 区分运行时工具: register(tool, { runtimeAdded: true }) 会把名字记进 runtimeToolNames(tool/framework.ts:142),这正是 §4.2 里"运行时工具豁免授权"能识别它们的依据。
  • toToolDefs()(tool/framework.ts:198)是 runner 每次调模型前的主力方法,把工具转成 LLM 认的格式。

Agent.addTool() 就是 register(tool, { runtimeAdded: true }) 的封装(agent/agent.ts:304),让你运行时热插工具,下一次 LLM 调用即生效。


7. 内置工具与沙箱边界

7.1 bash 不沙箱,但砍了环境变量

bashTool(tool/built-in/bash.ts:36)直接 spawn('bash', ['-c', command])(tool/built-in/bash.ts:107)——它不受路径沙箱约束,能在整台机器上跑命令。这也是为什么它默认不授权(§4)。

它做了三层收敛:

  • 环境变量白名单: 只透传 SAFE_ENV_ALLOWLIST 里那十来个安全变量(HOME/PATH/TERM…,tool/built-in/bash.ts:18),再叠一层 isSensitiveName 剔除敏感名(buildSafeShellEnv,tool/built-in/bash.ts:196)。你的 OPENAI_API_KEY 不会漏进子进程。
  • 超时 + 杀进程组: 默认 30 秒(tool/built-in/bash.ts:17);detached: true 让 bash 自成进程组,超时/abort 时 process.kill(-pid)& 后台的子孙一锅端(killProcessTree,tool/built-in/bash.ts:183)。
  • 输出脱敏: 输出过 redactSensitiveText(tool/built-in/bash.ts:69)。

7.2 文件系统工具:锁死在 cwd 里

file_read / file_write / file_edit / grep / glob 全都沙箱化。每个工具在动手前先调 resolvePathWithinCwd(tool/built-in/path-safety.ts:30),把请求路径钉死在 Agent 的 cwd 内。以 file_read 为例(tool/built-in/file-read.ts:48):

// tool/built-in/file-read.ts:48-51(节选)
const safePath = await resolvePathWithinCwd(input.path, context)
if (!safePath.ok) {
return { data: safePath.error, isError: true } // 越界 → 错误结果,不抛
}

沙箱的三条规则:

  • 默认根目录是 .agent-workspace,不是当前目录。 defaultWorkspaceDir() = <process.cwd()>/.agent-workspace(path-safety.ts:22)。注释点破动机:这样一个刚配好的 agent 默认碰不到你项目源码、.env.git/(path-safety.ts:6)。要放宽就把 cwd 设成 process.cwd();cwd: null 彻底关掉沙箱(path-safety.ts:37)。
  • 符号链接也堵。 光比较字符串路径能被符号链接绕过("我在 workspace 里放个软链指向 /etc")。所以它用 realpath 把候选路径的符号链接全解开再比(realpathTolerant,path-safety.ts:104),对还不存在的路径(如 file_write 新建文件)则解析"最长已存在前缀"再拼回后缀。返回的是解过链接的真实路径,顺带关掉了 TOCTOU 窗口(path-safety.ts:93-96)。
  • 只有 file_write 会自建根目录。 读类工具(read/edit/grep/glob)遇到根目录不存在直接报错;只有 file_writeensureRoot: truemkdir(path-safety.ts:55-77),让首次写入不必手动建目录。

判定"是否在根内"的核心函数 isWithin(path-safety.ts:99):算相对路径,不以 .. 开头且不是绝对路径,才算在里面。

7.3 delegate_to_agent:Agent 之间的同步委派

delegate_to_agent(tool/built-in/delegate.ts:24)让一个 agent 把子任务同步交给队友,拿回队友的最终文本。它只在编排的 pool worker 里注册(runTeam/runTasks),独立 runAgent 不给——而且和所有内置工具一样,得显式授权 tools: ['delegate_to_agent'] 才能调。

它在工具内部拦掉四类危险委派(tool/built-in/delegate.ts):

拦什么判据代码
自委派目标 === 自己的名字delegate.ts:45
未知目标目标不在队伍花名册里delegate.ts:52
目标已在委派链 delegationChaindelegate.ts:59-60
太深depth >= maxDelegationDepth(默认 3)delegate.ts:69-71

还有一条并发保护:如果 agent pool 没有空闲槽,委派会被拒(否则嵌套 run 会永久阻塞,delegate.ts:78)。委派完成后,子 agent 的 tokenUsage 通过 metadata 回传父 runner 计进预算(delegate.ts:106,呼应 §5.3),输出还会尽力写一份到 SharedMemory 做审计(delegate.ts:89-101)。委派的目标是"找专家队友产出你要拼进去的结果",跨 agent 协作的更多机制见 第 5 章。


8. 深入实现:两个容易被忽略的适配层

8.1 本地模型没有原生 tool_calls:文本回退

云端模型(Anthropic/OpenAI)会用规范的 tool_calls 结构返回工具调用。但本地服务器(Ollama / vLLM / LM Studio)常常把工具调用当普通文本吐出来,甚至塞在没闭合的 <think> 标签里。这时 text-tool-extractor.ts 出场,从文本里"抠"出工具调用。

关键是它只是安全网,不是主路径。在 OpenAI 兼容适配器里,只有当没有原生 tool_calls 时才启用(llm/openai-common.ts:388-397):

// llm/openai-common.ts:388-399(节选)
const hasNativeToolCalls = (message.tool_calls ?? []).length > 0
if (!hasNativeToolCalls && knownToolNames?.length > 0 && message.content) {
const extracted = extractToolCallsFromText(message.content, knownToolNames)
if (extracted.length > 0) content.push(...extracted)
}

extractToolCallsFromText(tool/text-tool-extractor.ts:203)按顺序试两种格式:先 Hermes 的 <tool_call>…</tool_call> 标签(text-tool-extractor.ts:166),再靠手写的括号深度跟踪从文本里挑出顶层 JSON 对象(extractJSONObjects,text-tool-extractor.ts:108,能正确跳过字符串内的花括号)。抠出来后有个白名单闸:只有 name 命中已注册工具名才认(parseFlat,text-tool-extractor.ts:73),避免把随手一段 JSON 当成工具调用。若真抠出了工具调用但 finish_reason 还是 stop,适配器会把它改成 tool_use 好让循环接着转(openai-common.ts:407)。

8.2 结构化输出:强制模型吐合规 JSON

AgentConfig.outputSchema 设了 Zod schema,Agent 会做三件事(agent/structured-output.ts):

  1. 注入指令: 把 schema 转成 JSON Schema、拼进 system prompt,明确要求"只回 JSON、别加解释、别加代码围栏"(buildStructuredOutputInstruction,structured-output.ts:21;注入点在 agent/agent.ts:161)。
  2. 宽容提取: 收到回答后 extractJSON(structured-output.ts:50)按四种策略试:直接 parse → ```json 围栏 → 裸 ``` 围栏 → 首个 {…末个 }(或 [])。
  3. 校验 + 重试一次: validateOutput 用 Zod 校验(structured-output.ts:117);第一次失败会带着错误反馈重试一次(validateStructuredOutput,agent/agent.ts:452),把校验错误当 user 消息喂回去让模型改。

这层挂在 Agent(门面)而非 AgentRunner(内核)上——内核只管"对话循环",结构化校验是门面职责,和生命周期状态、beforeRun/afterRun 钩子(agent/agent.ts:339402)、历史管理并列。


9. 巧妙之处(可借鉴的技术)

  • "告知集合"和"执行集合"分离。 模型看到的工具是 toolDefs,能跑的工具是 grantedToolNames——两套集合,后者是最终裁决门。这让"默认拒绝"能顶住注入攻击:模型就算被骗着调了没授权的名字,也只拿到一条 "not granted" 错误(agent/runner.ts:730976)。
  • 退出前先把 tool_use/tool_result 配对。 预算超限、loop-terminate 都不立即 break,而是先补齐 tool_result 再退,保证返回的 messages 能安全重放给 API,不留 400 隐患(agent/runner.ts:1088875)。
  • 符号链接沙箱逃逸的堵法。 不比字符串路径,而是 realpath 解开所有符号链接再比;对不存在的路径解析"最长存在前缀"。顺带关掉 TOCTOU(tool/built-in/path-safety.ts:87-96)。
  • 默认工作目录是子目录不是 cwd。 .agent-workspace 这一个小决定,让默认配置的 agent 天然碰不到你的源码和密钥(path-safety.ts:6)。
  • 错误一律降级成数据。 工具错误从不炸循环,变成 isError 结果喂回模型,让模型自己纠错——三层兜底织密(tool/executor.ts:46)。
  • 本地模型工具调用的文本回退,但只当安全网。 原生 tool_calls 永远优先,且抠出来的调用要过工具名白名单(tool/text-tool-extractor.ts:73llm/openai-common.ts:388)。

10. 边界与局限(诚实)

  • bash 完全不沙箱。 路径沙箱只管文件系统工具;bash 一旦授权就能碰整台机器。环境变量白名单和超时能收一部分风险,但授权 bash 等于给 shell 访问——这是刻意的取舍(tool/built-in/bash.ts)。
  • 框架安全栏目前是空的。 AGENT_FRAMEWORK_DISALLOWED 是空数组(agent/runner.ts:57),没有硬编码的"任何 agent 都禁"的工具;安全全靠"默认拒绝 + 显式授权"这套授予模型。
  • 结构化输出只重试一次。 校验失败带反馈重试一次仍不过,就返回 success: false(agent/agent.ts:518),不会无限试。
  • 文本工具抠取是启发式的。 靠括号深度和标签正则,遇到畸形 JSON 会静默跳过(tool/text-tool-extractor.ts),不保证抠全;这也是为什么它只是"安全网"。
  • loop 检测认的是"完全相同"。 工具签名靠精确 JSON.stringify 比对(agent/loop-detector.ts:102)——参数只要有一点不同就不算重复,语义等价但形式不同的循环检测不到。

11. 横向对比(同组其它章)

想了解去哪章
目标怎么被拆成任务 DAG、怎么调度01-coordinator-and-scheduling.md
任务图的队列/依赖/失败级联/重试02-task-queue-and-execution.md
本章循环里 adapter.chat 背后的模型对接、上下文压缩、推理 round-trip04-llm-adapters-and-context.md
SharedMemory、消息总线、共识校验、检查点、观测(委派审计落到哪)05-memory-consensus-observability.md
全景与阅读地图index.md

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

主题文件路径符号名
对话循环内核packages/core/src/agent/runner.tsAgentRunner.stream, AgentRunner.run
循环退出/预算延迟 breakpackages/core/src/agent/runner.tsmaxTurns, pendingBudgetExceeded, budget_exceeded
工具默认拒绝 + 三层过滤packages/core/src/agent/runner.tsresolveTools, hasPositiveGrant, TOOL_PRESETS, AGENT_FRAMEWORK_DISALLOWED
执行门packages/core/src/agent/runner.tsgrantedToolNames
并行执行 + 委派用量归集packages/core/src/agent/runner.tsexecutionPromises, delegationTurnUsage
Agent 生命周期/门面packages/core/src/agent/agent.tsAgent, executeRun, getRunner, addTool
结构化输出校验 + 重试packages/core/src/agent/agent.tsvalidateStructuredOutput
结构化输出工具packages/core/src/agent/structured-output.tsbuildStructuredOutputInstruction, extractJSON, validateOutput
死循环检测packages/core/src/agent/loop-detector.tsLoopDetector, computeToolSignature, consecutiveRepeats
工具定义 + 注册表 + Zod→JSONSchemapackages/core/src/tool/framework.tsdefineTool, ToolRegistry, zodToJsonSchema, convertZodType
并行执行器 + 错误隔离 + 截断packages/core/src/tool/executor.tsToolExecutor, runTool, executeBatch, truncateToolOutput
bash(不沙箱、env 白名单、杀进程组)packages/core/src/tool/built-in/bash.tsbashTool, SAFE_ENV_ALLOWLIST, killProcessTree, buildSafeShellEnv
文件系统沙箱packages/core/src/tool/built-in/path-safety.tsresolvePathWithinCwd, defaultWorkspaceDir, isWithin, realpathTolerant
委派规则packages/core/src/tool/built-in/delegate.tsdelegateToAgentTool
内置工具注册packages/core/src/tool/built-in/index.tsregisterBuiltInTools, BUILT_IN_TOOLS, ALL_BUILT_IN_TOOLS_WITH_DELEGATE
MCP 桥接packages/core/src/tool/mcp.tsconnectMCPTools, normalizeToolName, toToolResultData
本地模型文本回退packages/core/src/tool/text-tool-extractor.tsextractToolCallsFromText, extractJSONObjects, parseFlat