dsh 源码解析
第二段 · 主干 · 第 07 章

工具系统:注册表与五阶段执行流水线

四个 waterfall、单调 guard、乱序派发有序提交

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/core/tools/src/index.ts(1946 行)、packages/core/agent-loop/src/tool-calls.ts(289 行)

上一章的装配把工具 schema 送到了模型面前。这一章讲模型回答"我要调 write"之后发生的事。

一个工具调用要穿过多少东西:参数解析(模型可能给出非法 JSON)、tools/pre-execute 策略(沙箱要不要拦)、guard(不可翻转的否决)、审批(要不要问人)、tools/execute 环绕(超时、重试、指标)、工具体、输出 schema 校验、render 投影、tools/post-execute(能改写、能拦下)、finalizeContent(工具自己的最后一手)、lossless 物化、tools/result 观察者、写入 session。

而且一个 step 里可能有六个调用同时来,其中三个能并行三个不能。取消可能在这十几个环节的任何一个位置到达。

这一章讲这套东西怎么在"任意插件都能插手"和"日志永远可重放"之间同时成立。

全章图示:assets/ch07-tool-pipeline.svg

工具执行流水线:五阶段、四个 waterfall、乱序派发有序提交


1. 注册一个工具要交出什么

ToolDefinitionindex.ts:229)扩展 ToolSchema,除了模型看得见的三个字段(name / description / parameters),还要求一样东西:

export interface ToolOutputDefinition {
  readonly schema: JsonSchemaNode
  render(args: unknown, value: JsonValue): ContentBlock[]
  presentationMeta?(args: unknown, value: JsonValue): JsonValue
}

output 是强制的。 工具体不返回"给模型看的文本",它返回一个规范 JSON 值,然后由 render 投影成模型可见内容。

这个分离值不少钱:

同一个值,三个消费者。 模型要 contentrender 的产物);UI 要 presentationMeta(一个 diff、一组搜索命中);Code Mode 里的程序要结构化的 value 本身(第 6 章那个 run_code,SDK 调用返回的是值,不是渲染文本)。如果工具直接返回字符串,后两者就没了。

投影是纯函数,所以能重放。 presentCall / presentResult 的 JSDoc 反复强调 Pure and side-effect-side-free … a UI may call it during live streaming AND a session-log replay。UI 的卡片不存在日志里,日志里只有 argsmeta——卡片是重放时算出来的。

value 刻意不进日志。 ToolExecutionSuccess.value 的注释:Execution-local canonical value; deliberately omitted from durable events。日志里存的是 contentmeta。因为 value 可能很大(一个文件的全部内容),而模型看到的和 UI 需要的都已经在别的字段里了。

register()tools/src/index.ts:1043)的校验也值得看:

if (output === undefined || typeof output !== 'object'
  || typeof output.render !== 'function' … ) throw new TypeError(…)
assertSupportedJsonSchema(output.schema)
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) throw new TypeError(…)
if (name === RUN_CODE_NAME) throw new Error(`… reserved …`)

run_code无条件保留,注释解释了为什么不能"只在 code mode 下保留":

Reserved unconditionally: any agent may select a code mode for itself, so a name free to take under the deployment default would become a collision the moment a preset mounted.

一个只在某些配置下才冲突的名字,会在挂载某个 preset 的那一刻炸掉——而那个 preset 的作者和那个工具的作者互不相识。


2. 三个不给模型看的元数据

ToolDefinition 上有三个字段明确标注"永不发给模型":

timeoutMs 注释:it is NEVER sent to the model — schemas() whitelists only name/description/parameters。而且声明它是一个承诺

Declaring it asserts this tool forwards exec.signal to a cooperative implementation that can reach quiescence when the signal aborts.

超时靠 tool-call-timeout-policy(一个 tools/execute 环绕)执行。它不能硬杀同进程代码,所以"能超时"必须由工具自己保证。

isConcurrencySafe(args) 只有返回 true 才算加入并行组,executionMode()index.ts:1160)把所有别的情况都归到 exclusive:

if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
  const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
  return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
  return { kind: 'exclusive' }
}

没声明 → exclusive;返回非 true → exclusive;抛错 → exclusive;工具不存在 → exclusive。默认串行,并行是显式选择,而且这个分类器抛错也只会让调度更保守。它还是按参数判断的——read 读文件可以并行,同一个工具做别的事可能不行。

presentCall / presentResult 纯 UI 投影。

三个字段共用一个模式:能力元数据和模型契约分开schemas() 白名单只放三个字段,别的什么都不会漏进 prompt。


3. 五阶段流水线

ToolRuntime.execute() 的内部被拆成四个 scheduler 方法(index.ts:452,符号键、@internal):

prepare(exec)   → 'dispatch' | 'post-result' | 'final-result'
dispatch(exec)  → 'post-result' | 'final-result'
finalize(exec, result)   // 跑 post-execute,然后 finish
finish(exec, result)     // 只跑内容定稿 + 物化 + 通知

拆开的理由在下一节(并发调度)。先看每一段做什么。

prepare:有序的门

prepareExecution()index.ts:1462)的顺序:

1. createExecution:参数 lossless 快照 + deepFreeze,分配 token
2. 已取消? → ABORTED_BEFORE_DISPATCH(final-result)
3. tools/pre-execute waterfall → allow | deny | ask
4. ask → serviceAsk()(走审批 seam)
5. allow 时跑 guard 链
6. 有拒绝理由 → 物化成 isError(post-result)
7. 再检查一次取消
8. → dispatch

三个细节:

PreToolDecision 里没有"改写参数"。 注释写明:Input rewriting is excluded because arguments are already logged and presented. 参数在派发前已经写进 tool/call 事件、已经在 UI 上显示过。允许策略改写它,日志和实际执行就分叉了——这是上一章那条"model-visible ⟺ logged"的直接推论。

ask 在没有审批服务时降级为 deny。 serviceAsk()index.ts:1689)用 ctx.get('approval') 而不是 static inject

The seam is consumed opportunistically … a deployment that composes no ApprovalService keeps the historical degrade to deny, and an unmount mid-session degrades the same way on the next ask.

而且四种结果给四条不同的拒绝理由

case 'allowed-once': allow
case 'rejected':     `the user rejected tool "…"`
case 'cancelled':    `approval for tool "…" was cancelled`
case 'unavailable':  `tool "…" requires approval, but no approval channel is available`

注释点出了为什么要分开:so the model can tell a human "no" from an absent approval channel。人说"不行",模型该换个做法;审批通道没配好,模型换做法也没用。合成一条 denied 就把这个区别抹掉了。

没有 agent 也降级为 deny:without an agent there is no session to audit to and no UI to route to

guard 是单调的。 ToolGuard 是同步函数,返回字符串即拒绝(index.ts:711):

export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

guard() 的 JSDoc:Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied.

为什么要在可扩展的 waterfall 之外再来一层?因为 waterfall 能被短路。一个 tools/pre-execute 监听器不调 next() 就返回 allow,下游全部被跳过——第 2 章讲过这是 waterfall 的语义。这对"超时策略"之类的协作型扩展没问题,但对安全边界不行。guard 跑在 waterfall 之后,遍历全局层再遍历 scope 链(guardReason()index.ts:1119),任何一层说不就是不。

能否决但不能授权,这条不变量让 guard 可以任意叠加:装十个 guard,安全性只增不减。

dispatch:环绕 + 工具体

dispatchScheduledExecution()index.ts:1569):

const result = await this.ctx.waterfall(
  carrier, 'tools/execute', mutableExec,
  () => this.dispatchToolBody(mutableExec),
)

tools/execute 是环绕型 waterfall——超时、重试、指标都挂这里。它能改的只有一样东西:

export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
  signal: AbortSignal   // 唯一可变字段
}

调用身份不可变,signal 可替换。 一个超时策略需要传一个"叠加了 deadline"的信号进去。但这里有个坑:环绕器可以传一个完全无关的信号,从而把调用方的取消能力摘掉。

dispatchToolBody()index.ts:1530)在调工具体之前把原始信号焊回去

const wrapperSignal = exec.signal
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
exec.signal = fused.signal
try {

  state.bodyInvoked = true
  const returned = await tool.execute(exec.arguments, exec)

} finally {
  fused.dispose()
  exec.signal = wrapperSignal
}

fuseToolSignals()index.ts:1889)是个"任一 abort 则 abort"的合并器,两个细节:

caller === wrapper 时不建 controller,直接返回原信号和空 dispose。没人替换信号是常见情况,不该为它分配对象和挂监听。

abort 后立刻摘监听。 abortFrom()controller.abort(reason) 之后就 dispose()。长期运行的会话里,泄漏的 abort 监听器会一直挂在调用方信号上。

finally 里把 exec.signal 恢复成环绕器给的那个——因为环绕器还在栈上,它 await next() 返回后可能还要读这个字段。

结果规范化

工具体返回之后走 createSuccessResult()index.ts:1793):

const detached = snapshotToolValue(tool.name, candidate)              // lossless JSON
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
const value = deepFreeze(detached)
rendered = tool.output.render(exec.arguments, value)                  // 投影
const content = snapshotProjection(tool.name, 'render', rendered)
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) { … }

工具的输出被拿自己声明的 schema 校验。 一个工具返回了不符合自己 output.schema 的值,得到 INVALID_TOOL_OUTPUT 失败,而不是把坏数据传下去。

presentationMeta 只在 exec.parent === undefined 时算。 嵌套调用(Code Mode 的 SDK 子派发)不算 UI 投影——没有卡片要画。省掉的不只是 CPU,还有一份可能很大的 JSON。

投影异常被规范化成同一个失败类。 projectionError()index.ts:524)把 render 抛出的任何东西变成 ToolOutputError,理由是"工具违反了自己的输出契约",而不是"工具执行失败"。

finalize / finish:post-execute 与最后一手

postExecute()index.ts:1735)的决策类型:

export type PostToolDecision =
  | { kind: 'accept'; content?: ContentBlock[]; value?: never; … }
  | { kind: 'accept'; value: JsonValue; content?: never; … }
  | { kind: 'block'; feedback: ContentBlock[]; … }

contentvalue 互斥用类型表达(两个重载各把对方标成 never),运行时再查一次(Object.hasOwn 同时命中就抛 TypeError)。因为替换 value 会走一遍 render 重新算 content,同时给两个就有两个真相。

替换 value 还有一条:cannot replace the value of a failed result。失败结果没有 value(类型上 value?: never),给它一个就把失败偷偷变成了成功。

block 的语义是"把纠正反馈变成一个错误结果"——压缩策略发现结果太大、沙箱策略事后发现越界,都走这里。而且注释交代了一个不明显的取舍:

Context deferred by the tool body survives an accepted result but is discarded when the outer call is blocked; a block exposes only context the blocking decision explicitly supplied.

被拦下的调用,它工具体里 deferContext() 攒的东西丢掉。因为那些上下文描述的是一次被否决的执行。

最后 finishScheduledExecution()index.ts:1629)做三件事,套着两层 try:

materializedResult = this.materializeFinalResult(result)                    // 物化
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
this.notifyResult(exec, finalResult)

两层 try 都在失败时 materializeFinalResult(toolErrorResult(error))——物化本身失败也要产出一个合法结果。因为这个函数的调用方(调度器)已经写了 tool/call 事件,必须拿到一个能写 tool/result 的东西。

finalizeContent 是工具自己的最后一手,JSDoc 里三个约束:

The registry snapshots this callback when execution starts and invokes it exactly once for every normalized outcome, including pipeline failures that bypass tools/post-execute … The callback must be total and must not throw.

启动时快照(所以中途卸载重装工具不会换掉它)、恰好一次连绕过 post-execute 的流水线失败也要跑。第三条是关键:它是工具唯一能保证"我的每个结果都经过我手"的钩子。代价是它必须 total——没有失败路径可走了。

notifyResult()index.ts:1663)里,Object.freeze(exec) 在派发观察者之前,然后每个回调单独 try,异步失败也 catch 掉转成 warn。和上一章 session 的 observer 隔离一模一样:观察者的失败不能改变已经定下的结果。


4. 并发:乱序派发,有序提交

executeToolCalls()agent-loop/src/tool-calls.ts:60)是上面那四个 scheduler 方法被拆开的原因。

模型一次给了六个调用。目标是:能并行的重叠跑,但策略、结果、上下文全部按模型顺序

分组

while (next < planned.length) {
  const first = planned[next]!
  const mode = ctx.tools.executionMode(first.exec).kind
  const group = mode === 'parallel' ? planned.slice(next) : [first]
  const outcome = await runGroup(…)
  next += outcome.consumed

}

exclusive 调用自己一组(一个屏障),parallel 调用把剩下全部乐观地收进一组——runGroup 会在真正启动前逐个重新分类,遇到 exclusive 就停下,只报告实际消费了几个。

注释解释了为什么要重新分类:Commit before classifying again so registry changes affect unstarted calls. 一个工具可能在这一批调用执行的过程中被卸载或替换(第 2 章的热更新),或者某个 guard/restriction 变了。已经启动的照旧,没启动的按新状态判断。

滚动池

runGroup()tool-calls.ts:117)里 fillPool()

while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
  const nextCall = group[nextToStart]!
  if (nextToStart > 0 && mode === 'parallel'
    && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
  await startCall(nextToStart)
  nextToStart++
  throwSchedulerFailure()
  await commitReady()
  throwSchedulerFailure()
  if (signal.aborted) aborted = true
}

主循环:

await fillPool()
while (inFlight.size > 0) {
  const settledIndex = await Promise.race(inFlight.values())
  inFlight.delete(settledIndex)
  throwSchedulerFailure()
  await commitReady()
  throwSchedulerFailure()
  if (signal.aborted) aborted = true
  await fillPool()
}

Promise.race 拿最先落地的那个,删掉,提交能提交的,然后补池。默认上限 10(maxParallelToolCalls)。

注意 startCallprepareawait 的:pre-execute 和 guard 是串行的,只有 dispatch/body 重叠。所以一个审批弹窗不会和另一个审批弹窗同时出现,一个 guard 也不会看到乱序的调用。

只在连续前缀上提交

commitReady() 是这段代码的核心:

while (committed < group.length) {
  const slot = slots[committed]
  if (slot === undefined) break            // 前面还没落地,停
  const result = slot.needsPost
    ? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result)
    : ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result)
  appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
  for (const context of result.additionalContexts ?? []) acceptContext(context)
  concluded ||= result.concludesTurn === true
  committed++
}

committed 只沿连续的已落地槽位前进。调用 3 先跑完、调用 1 还在跑,那么 3 的 post-execute 一步都不会跑——它躺在 slots[2] 里等着。

这是整个并发设计的关键决定:post-execute 属于有序阶段,不属于派发阶段。这就是 dispatchfinalize 必须拆成两个 scheduler 方法的原因。

为什么值得付这个代价(一个慢调用会堵住后面所有已完成调用的收尾)?因为 post-execute 上挂的是有累积状态的策略:压缩策略要算 token 预算、spill 策略要分配溢出槽位。它们必须按模型看到的顺序看到结果,否则同一份对话在重放时会得到不同的预算决策。

tool/result 的写入顺序也因此和模型顺序一致——日志可重放的前提。

取消:每个调用都必须有结果

取消可能在三个位置到达,代码里三处 if (signal.aborted) aborted = true 分别对应,注释也标出了"abort 可能在这里到达"。

到达之后:

if (aborted) {
  for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
  return { consumed: group.length, aborted: true, concluded }
}

而外层:

if (outcome.aborted) {
  for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
  return { concluded }
}

没启动的调用得到一对合成的 tool/call + tool/resultappendSkippedToolCalltool-calls.ts:245),错误码 ABORTED_BEFORE_DISPATCH

为什么要给一个从没跑过的调用写一对事件?因为模型的 assistant message 已经宣布了这些调用。provider 的契约是每个 tool call 必须有对应的 tool result;缺一个,下一次请求就是非法的。上一章那个 crash repair 干的是同一件事,只是在崩溃恢复路径上。

已启动的调用则先 drain 完、提交、接收上下文,然后才轮到合成结果——注释:Started calls and accepted context settle first; every remaining model call then receives an ordered synthetic result before the turn aborts.

调度器自身失败:绝不伪造结果

schedulerFailure 那套机制处理的是另一类事:不是工具失败(那是正常的 isError 结果),而是调度器自己坏了prepare 抛出、物化抛出)。

} catch (error: unknown) {
  schedulerFailure ??= { error }
  await Promise.allSettled(inFlight.values())
  throw schedulerFailure.error
}

停止新派发、等已启动的全部落地、抛出第一个失败。 模块注释写得很直接:

A terminal scheduler failure preserves already-recorded tool/call events without fabricating results.

和取消路径刻意相反:取消是可以合成结果的(ABORTED_BEFORE_DISPATCH 是一个诚实的说法),调度器失败不行——因为此时"这个调用到底跑了没有"这件事,代码自己也不知道了。写一个编出来的结果,就是往一个可重放的日志里塞一句谎。留下一个孤立的 tool/call 反而是准确的:上一章那个 TOOL_OUTCOME_UNKNOWN 修复路径正是为这种形状准备的。

await Promise.allSettled(inFlight.values()) 也不能省。让已启动的工具体在无人观察的情况下继续跑,会在下一个 turn 制造出乱写文件的幽灵。

参数解析:非法 JSON 原样传下去

function parseArguments(raw: string): unknown {
  try {
    return raw ? JSON.parse(raw) : {}
  } catch {
    return raw
  }
}

空串 → {};解析失败 → 返回原始字符串

不在这里报错,是因为 defineTool 的参数校验会在工具体之前拒掉它,并给出一条说明哪里不对的错误——那条消息比"JSON 解析失败"对模型有用得多。而 tool/call 事件存的始终是模型给出的原始字符串(上一章),所以重放时看到的就是模型真实写出的东西。


5. 取消的三种结果码

流水线里有三个不同的取消结果,区分它们的是 bodyInvoked 这一个布尔:

export const TOOL_ABORTED = 'ABORTED'                                 // 体跑过了
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' // 体没跑

cancellationResult()index.ts:1519):

return state.bodyInvoked
  ? toolAbortedResult(prior)
  : toolAbortedBeforeDispatchResult(prior)

bodyInvoked 只在 dispatchToolBody()tool.execute() 之前那一行置位。所以这个区分是精确的:副作用可能已经发生了吗? 一个被 ABORTEDwrite 可能已经写了文件;一个被 ABORTED_BEFORE_DISPATCHwrite 一定没有。恢复逻辑和模型都需要这个区别。

还有一处:取消只替换成功的结果。

result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
  ? this.cancellationResult(exec, resultWithDeferredContexts)
  : resultWithDeferredContexts,

因为工具自己的失败信息比"被取消了"更有用。工具在取消到达前就报了错,那条错误保留。

prior 参数让取消结果能携带原来那个结果的部分内容——工具跑完了、正要提交时被取消,产出的内容不必扔掉。


6. 设计代价

有序 post-execute 会造成队头阻塞。 六个并行调用里第一个慢,后五个跑完也得等它收尾。换成"派发完就地收尾"能消掉阻塞,但会让有累积状态的策略看到乱序结果,从而破坏重放确定性。选了确定性。

并行默认关闭。 每个想并行的工具都得自己写 isConcurrencySafe 并守住"不改父级状态"的约定。默认全串行会更简单也更慢;默认全并行会在共享状态上出竞态。选了"显式选择加入"。

finalizeContent 必须 total。 它在流水线的最外层跑,包括那些绕过 post-execute 的失败路径。所以它自己不能有失败路径——一个工具想在这里做可能失败的事就没地方去了。

guard 只能同步。 因为它跑在 waterfall 之后、派发之前的有序段里,异步 guard 会把这个位置变成又一个可等待点,也会引出"guard 执行期间状态变了怎么办"。想做异步检查就得回到 tools/pre-execute,而那里能被短路。

审批 seam 是可选服务,不见了就是 deny。 一个部署忘了装 approval 插件,所有 ask 全变 deny——安全但会显得"工具全坏了"。四条不同的拒绝理由是对这个代价的补偿。

value 不进日志,所以不能从日志重算 render 日志里存的是 render 的产物。将来改了 render 的实现,历史会话里的旧 content 不会跟着变。这对 content正确的(模型当时确实看到的是那个),但也意味着渲染逻辑的 bug 修不了历史。

流水线有十几个可插手的点,调试链路长。 一个 deny 可能来自 pre-execute 监听器、来自审批、来自任意一层 guard;一个 isError 可能来自工具体、post-execute 的 block、输出 schema 校验、投影异常或物化失败。dsh 用 ToolErrorInfo { name, code } 保留结构化归属来对冲,但读一条日志的时候仍然需要知道这十几个位置。


7. 可迁移的经验

1. 工具返回结构化值,不返回给模型看的字符串。 一个值 + 若干纯投影,模型、UI、程序化调用各取所需。工具直接返回文本,后两者就永远补不回来。

2. 能力元数据和模型契约分开,白名单化。 超时预算、并发分类、UI 渲染意图放在定义上但绝不发给模型;schemas() 只放三个字段。这样加元数据永远不会污染 prompt。

3. 保守的默认值,让分类器抛错也安全。 isConcurrencySafe 把"没声明/非 true/抛错/工具不存在"全归到 exclusive。任何取巧都会让调度更保守,而不是更危险。

4. 可扩展的 waterfall 之外,再放一层能否决不能授权的单调 guard。 waterfall 能被短路,所以它适合协作型扩展,不适合安全边界。"任何一层能拒绝,没有一层能翻转拒绝"让这类检查可以任意叠加。

5. 环绕器可以替换 signal,但框架要把原始信号焊回去。 只给"可替换"不给"必合并",一个环绕器就能悄悄摘掉调用方的取消能力。合并时记得 caller === wrapper 走快路径,abort 后立刻摘监听。

6. 派发可以乱序,提交必须有序。 提交游标只沿连续已完成前缀前进。这是"并发执行 + 确定性日志"唯一稳的组合方式,代价是队头阻塞。

7. 每个被宣布的调用都必须有结果。 取消时给未启动的调用写合成的失败结果——因为 provider 契约要求调用和结果配对。跳过它们会让下一次请求非法。

8. 但调度器自己坏了的时候绝不伪造结果。 此时"跑了没有"已经不可知了,编一个结果就是往可重放日志里写谎。留下孤立的 call 事件,让修复路径去处理,是更诚实的形状。

9. 用一个布尔("体跑没跑")区分取消结果码。 ABORTEDABORTED_BEFORE_DISPATCH 回答的是"副作用可能发生了吗"。恢复逻辑和模型都需要这个答案。

10. 拒绝理由要保留区别。 人说"不行"和"审批通道没配"对模型意味着完全不同的下一步。为了少写几行而合成一条 denied,就把这个区别送给了熵。

11. 拒绝改写已经记录过的输入。 参数已经进了日志、已经在 UI 上显示过。允许策略改写它,日志就不再是执行过的事实。


8. 本章源码位置

位置 内容
packages/core/tools/src/index.ts:141 tools/pre-execute:waterfall、scope 过滤、异步门必须观察 signal
packages/core/tools/src/index.ts:150 tools/execute:环绕,只能改 signal,框架会重新焊回调用方信号
packages/core/tools/src/index.ts:159 tools/post-execute:accept / replace / enrich / block
packages/core/tools/src/index.ts:171 tools/code-dispatch-log:只改日志副本,程序已收到完整值
packages/core/tools/src/index.ts:187 tools/result:观察冻结的最终结果,监听器失败被隔离
packages/core/tools/src/index.ts:195 tools/change:刻意做 scope 过滤
packages/core/tools/src/index.ts:211 ToolOutputDefinition:schema + render + presentationMeta
packages/core/tools/src/index.ts:229 ToolDefinition:output 强制
packages/core/tools/src/index.ts:243 finalizeContent:启动时快照、恰好一次、必须 total
packages/core/tools/src/index.ts:256 timeoutMs:永不发给模型,声明它即承诺协作式取消
packages/core/tools/src/index.ts:269 isConcurrencySafe:只有 true 才加入并行
packages/core/tools/src/index.ts:314 presentCall / presentResult:纯函数,因为要在重放时调用
packages/core/tools/src/index.ts:343 ToolExecutionToken:不透明身份,不暴露可变状态
packages/core/tools/src/index.ts:376 parent token:既是等待句柄,也是"这是子派发"的标记
packages/core/tools/src/index.ts:469 TOOL_ABORTED / TOOL_ABORTED_BEFORE_DISPATCH
packages/core/tools/src/index.ts:492 ToolNotFoundError:名字可见但不可直呼时带上正确路线
packages/core/tools/src/index.ts:512 ToolOutputError:违反自己声明的输出
packages/core/tools/src/index.ts:555 ToolExecutionSuccess.value:刻意不进日志
packages/core/tools/src/index.ts:583 PreToolDecision:刻意没有"改写参数"
packages/core/tools/src/index.ts:592 PostToolDecision:content / value 互斥用类型表达
packages/core/tools/src/index.ts:604 errorMessage():连恶意抛出值也不能让归一化失败
packages/core/tools/src/index.ts:711 ToolGuard:同步、单调、能否决不能授权
packages/core/tools/src/index.ts:1043 register():output 校验、timeoutMs 校验、run_code 无条件保留
packages/core/tools/src/index.ts:1110 guard():全局或 scoped,返回精确 disposer
packages/core/tools/src/index.ts:1119 guardReason():全局优先,再沿 scope 链最远先
packages/core/tools/src/index.ts:1160 executionMode():四种情况全归 exclusive
packages/core/tools/src/index.ts:1462 prepareExecution():有序门的完整顺序
packages/core/tools/src/index.ts:1519 cancellationResult():靠 bodyInvoked 选码
packages/core/tools/src/index.ts:1530 dispatchToolBody():焊回调用方信号、置位 bodyInvoked、finally 复原
packages/core/tools/src/index.ts:1629 finishScheduledExecution():两层 try,物化失败也要产出合法结果
packages/core/tools/src/index.ts:1663 notifyResult():先冻结,再逐个隔离观察者
packages/core/tools/src/index.ts:1689 serviceAsk():可选 seam,四条不同的拒绝理由
packages/core/tools/src/index.ts:1735 postExecute():block 丢弃工具体 deferred context
packages/core/tools/src/index.ts:1793 createSuccessResult():schema 校验 → render → meta(仅顶层)
packages/core/tools/src/index.ts:1889 fuseToolSignals():同信号快路径、abort 后立刻摘监听
packages/core/agent-loop/src/tool-calls.ts:1 模块注释:调度契约的完整声明
packages/core/agent-loop/src/tool-calls.ts:60 executeToolCalls():分组、提交后重新分类
packages/core/agent-loop/src/tool-calls.ts:100 parseArguments():空串→{},非法 JSON 原样传下去
packages/core/agent-loop/src/tool-calls.ts:117 runGroup():滚动池、commitReady()、三处取消检查
packages/core/agent-loop/src/tool-calls.ts:245 appendSkippedToolCall():被宣布的调用必须有结果
packages/core/agent-loop/src/tool-calls.ts:265 appendToolResult()sourceEventSeqs: [callSeq] 建立 provenance