工具系统:注册表与五阶段执行流水线
四个 waterfall、单调 guard、乱序派发有序提交
本章基准:dsh
0.1.1-rc.2,commitb150a551b8d465e31e418e1b2eaf5e79bbb7d28e主要源码:
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
1. 注册一个工具要交出什么
ToolDefinition(index.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 投影成模型可见内容。
这个分离值不少钱:
同一个值,三个消费者。 模型要 content(render 的产物);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 的卡片不存在日志里,日志里只有 args 和 meta——卡片是重放时算出来的。
value 刻意不进日志。 ToolExecutionSuccess.value 的注释:Execution-local canonical value; deliberately omitted from durable events。日志里存的是 content 和 meta。因为 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.signalto 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[]; … }
content 和 value 互斥用类型表达(两个重载各把对方标成 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)。
注意 startCall 里 prepare 是 await 的: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 属于有序阶段,不属于派发阶段。这就是 dispatch 和 finalize 必须拆成两个 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/result(appendSkippedToolCall,tool-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/callevents 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() 之前那一行置位。所以这个区分是精确的:副作用可能已经发生了吗? 一个被 ABORTED 的 write 可能已经写了文件;一个被 ABORTED_BEFORE_DISPATCH 的 write 一定没有。恢复逻辑和模型都需要这个区别。
还有一处:取消只替换成功的结果。
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. 用一个布尔("体跑没跑")区分取消结果码。 ABORTED 和 ABORTED_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 |