工具管线
工具注册表干两件事:按作用域解析模型可见的工具声明,以及把一次调用推过一条有序的执行管线。
对应实现: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(监听器包在终局行为外层,必须调用续延才继续),第四段是通知。
DENIED 意味着本体从未运行,因此没有需要补偿的副作用。| 阶段 | 事件名 | 形态 | 能做什么 |
|---|---|---|---|
| pre-execute | tools/pre-execute | waterfall | 派发前放行、拒绝或要求批准 |
| execute | tools/execute | waterfall | 环绕派发,包裹工具本体 |
| post-execute | tools/post-execute | waterfall | 对已定型的结果接受、替换或阻断 |
| result | tools/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 | 已声明的调用在本体开始前被取消。 |
DENIED | pre-execute 策略或单调守卫拒绝了调用。 |
UNAVAILABLE | 调用依赖的批准能力缺失或 provider 失败。 |
BLOCKED | post-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),供依赖声明列表的组件重新取值,而不必轮询。