工具管线

工具注册表干两件事:按作用域解析模型可见的工具声明,以及把一次调用推过一条有序的执行管线。 对应实现:src/Tether.Core.Tools/,契约在 src/Tether.Core.Contracts/。

注册工具

工具本身是 Microsoft.Extensions.AI 的 AIFunction,注册时可以附带不暴露给模型的执行元数据。

IDisposable subscription = tools.Register(
    aiFunction,
    scope: null,                       // null 表示全局可见
    options: new ToolRegistrationOptions
    {
        // 只有显式返回 true 才允许与兄弟调用重叠
        IsConcurrencySafe = args => args.TryGetProperty("readOnly", out var v) && v.GetBoolean(),
    });

返回的 IDisposable 移除这一次确切的注册。按 Cordis 的注册即回收纪律,插件里应当把它挂到作用域上。

作用域可见性

AgentPipelineScope 是一个独立于 Cordis effect 归属的可见性层,用引用身份区分,可以有父层(还可以携带 SessionId/Session/Owner 身份,子层只能继承不能替换父层 owner):

var child = new AgentPipelineScope(parent: rootScope);

GetDeclarations(scope) 返回从该作用域看得见的声明,按工具名的序数序排序,传 null 表示全局可见性。声明本身是脱离的 ToolSchemaSnapshot:模型可见的名字、描述,以及描述参数的 JSON Schema(必须是 JSON 对象)。

并发分类

ToolExecutionMode mode = tools.Classify(call, view);

view 是绑定当前 step 的密封目录视图(ISealedToolCatalogView),分类按视图中可见工具的 注册元数据判定。

模式含义
Exclusive独占执行,并且构成一个排序屏障。
Parallel可以与其它显式声明并行的调用重叠。

并行是显式选入,不是默认

只有可见工具通过 IsConcurrencySafe 显式返回 true 才会被判为 Parallel。没配置、返回 false、或抛异常都落到 Exclusive。 默认安全是这里的设计取向。

四段管线

一次调用依次经过四个阶段,前三段都是 waterfall(监听器包在终局行为外层,必须调用续延才继续),第四段是通知。

前三段是环绕式 waterfall,不调用续延即短路 调用 pre-execute 策略决策 守卫 单调 execute 包裹工具本体 post-execute 结果决策 result 仅通知 Deny 拒绝 Block 带稳定码的失败结果 DENIED / BLOCKED 最后一段拿到的是已定型的结果,只能观察不能改动;它的失败由注册表容纳。
三条红色分支都在工具本体之前或之后截断调用。DENIED 意味着本体从未运行,因此没有需要补偿的副作用。
阶段事件名形态能做什么
pre-executetools/pre-executewaterfall派发前放行、拒绝或要求批准
executetools/executewaterfall环绕派发,包裹工具本体
post-executetools/post-executewaterfall对已定型的结果接受、替换或阻断
resulttools/result通知观察最终结果,不能改变它

对应的注册方法分别是 RegisterPreExecuteListener、RegisterExecuteListener、RegisterPostExecuteListener、RegisterResultListener,都接受 scope(null 表示对每次调用都可见)与 prepend。

tools.RegisterPreExecuteListener(null, async (execution, next) =>
{
    if (execution.Name == "shell")
    {
        return ToolPreDecision.Deny("这个环境禁止执行 shell");
    }

    return await next();   // 不调用 next 就短路了后续监听器与终局策略
});

单调守卫

除了 pre-execute 监听器,还有一类更简单的检查:

public delegate string? ToolGuard(ToolExecution execution);

tools.RegisterGuard(scope, execution =>
    execution.Arguments.TryGetProperty("path", out _) ? null : "缺少 path 参数");

守卫是单调的:它可以拒绝,但无法强制放行。返回拒绝原因表示否决,返回 null 表示弃权。这个不对称保证了叠加更多守卫只会让策略更严,不会因为顺序不同而放松。

决策类型

pre-execute 的决策有三种:

决策含义
ToolPreDecision.Allow继续走单调守卫与派发。
ToolPreDecision.Deny(reason)派发前拒绝,原因必填且会回给模型。
ToolPreDecision.Ask(reason?)请求批准;没有批准能力时失败关闭。

批准能力由审批与用户交互提供。普通策略拒绝和 approval Rejected 对应 DENIED;Unavailable(answerer 缺失、卸载或抛错归一成的未知结果) 对应 UNAVAILABLE;caller 取消对应 ABORTED_BEFORE_DISPATCH。 这些分支都会失败关闭,但不会把“拒绝”和“批准能力不可用”混成同一个诊断。

post-execute 的决策也有三种:

决策含义
ToolPostDecision.Accept保留已定型的结果。
ToolPostDecision.Replace(value)替换成功结果的 JSON 值。
ToolPostDecision.Block(reason)把结果换成一个策略失败,原因必填。

执行期的可变与不可变

区分得很清楚,避免中间层偷偷改掉调用身份:

ToolExecution 是不可变的准备好的执行:CallId、Name、Scope、Cancellation,以及脱离的 Arguments; 它还固定了派发前捕获的 Owner、ParentCallId 与 InvocationContext(若有)。

参数解析失败时也有值

ToolExecution.Arguments 是解析后的参数;当解析失败时,它是把原始 JSON 文本当作一个 JSON 字符串放进去的结果。 因此策略监听器总能看到模型到底给了什么,而不是面对一个空值。

ToolDispatch 是环绕派发阶段的可变状态,但可变的只有一样东西:包装器在调用续延之前只允许替换 CancellationToken。这样就能实现超时之类的包装,同时无法篡改参数或身份。

结果与失败码

ToolExecutionOutcome.Success(execution, jsonValue);
ToolExecutionOutcome.Failed(execution, ToolFailureCodes.ToolError, "读取文件失败");

结果是脱离的:CallId、Name、Value(返回给模型的 JSON)、Failure,以及便捷的 IsError。失败时模型看到的值是 { "message": ... }。ToFunctionResult() 把它投影成与调用关联的 FunctionResultContent。

失败码是稳定的机器可读常量:

常量含义
UNKNOWN_TOOL请求的名字解析不到可见工具。
INVALID_ARGS模型参数不是合法的调用对象。
TOOL_ERROR工具本体或某个执行阶段失败。
ABORTED已派发的调用被取消。
ABORTED_BEFORE_DISPATCH已声明的调用在本体开始前被取消。
DENIEDpre-execute 策略或单调守卫拒绝了调用。
UNAVAILABLE调用依赖的批准能力缺失或 provider 失败。
BLOCKEDpost-execute 策略阻断了已定型的结果。

ABORTED 与 ABORTED_BEFORE_DISPATCH 分开是有意的:后者意味着工具本体从未运行,不存在需要补偿的副作用。

批次执行

模型一个 step 里可能声明多个调用,注册表按批处理。

IReadOnlyList<ToolExecutionOutcome> outcomes = await tools.ExecuteBatchAsync(
    calls,                 // 按模型声明顺序
    view,                  // 绑定当前 step 的密封目录视图
    maxParallelism: 4,     // 并行工具本体的重叠上限
    sink: durableSink,     // 可选:持久化交接
    cancellation: token);

返回的结果按模型声明顺序排列,与实际执行顺序无关。这让调用方可以直接把结果按序写回消息历史。

持久化交接由 IToolBatchSink 承担,两个回调的时机是明确的:

  • CallDeclaredAsync(call) —— 在该调用的本体开始之前;
  • OutcomeReadyAsync(outcome) —— 按模型声明顺序交出最终结果。

这正好对应会话日志里的 tool/call 与 tool/result 两个事件,详见会话与事件溯源。

可见性变更通知

工具集合发生可见性变化时会发出 tools/change(常量 CoreEvents.ToolsChange),供依赖声明列表的组件重新取值,而不必轮询。

下一步

在 GitHub 上编辑此页