Genkit Dart 审批恢复后为何找不到工具
作者为 MCP 工具加入人工审批后,发现订单操作在获批恢复时触发工具不存在错误,尽管该工具此前已被发现。排查这一问题最终促成了作者向 Genkit Dart 提交首个 PR。
AI 深度解读
Agent 问「能买这个零件吗」,人点了同意,系统却返回 Tool order_part not found。工具明明刚用过,为什么批准后就找不到了?作者在一个纯 Dart 调度助手里,定位到了 Genkit Dart 中断恢复分支漏传工具映射的问题。
这篇排障记录的价值,是把「首次执行正常、恢复执行失败」拆成了可验证的调用路径。正在给 Agent 加人工审批的开发者,可以直接拿它检查自己的恢复逻辑和测试边界。
工具没消失,是恢复时查错了名字
作者为一家虚构的小型现场服务公司开发调度助手:查询工单、预约技术人员可以直接执行,order_part 涉及花钱,需要人工同意。这里的「免费」指业务操作不产生采购费用,原文没有讨论模型调用成本。
工具由 MCP server 提供,通过 mcpClient.getActiveTools(ai) 获取,再交给 ai.generate。toolApproval(approved: runWithoutApproval) 控制审批,其中 runWithoutApproval 包含除 order_part 外的工具。
首次请求遇到采购操作后,返回 FinishReason.interrupted。应用保存中断状态,展示批准或拒绝卡片。用户同意后,应用携带 held.messages,并通过 interruptRestart: [held.interrupt.restart({'tool-approved': true})] 恢复执行。
故障就发生在第二次 ai.generate。
MCP 工具注册时的完整名称是 work-orders/order_part,但模型看到的是短名称 order_part。Genkit 的 toToolDefinition 会截取名称最后一段,把完整名称放入 metadata.originalName。
因此,模型调用 order_part 完全符合收到的工具定义。框架需要负责把短名称找回对应的完整工具。
正常执行时,_resolveTools 会构建以短名称为键的 toolMap,_executeTools 优先查这张表。直接查 registry 则会失败,因为 registry 认识的是 work-orders/order_part。
首次执行和恢复执行,走了两条不一致的工具查找路径。

修复的关键,是恢复时也重新解析工具
作者定位到 package:genkit 1.0.1 的 coreGenerate:当 resumeRestart != null 时,这个分支直接调用 _executeTools,没有提供 toolMap。
于是执行器退回普通 registry 查询,用短名称寻找只按完整名称注册的工具,最终返回 FinishReason.failed 和 Tool order_part not found。
PR #658 的修复是在恢复分支也调用 _resolveTools,再把解析结果传给执行器:
// coreGenerate,节选自 PR #658
if (resumeRestart != null) {
_assertResumable(opts.messages);
final restartTools = await _resolveTools(
generateRegistry,
opts.tools,
resolvedMiddleware,
);
execution = await _executeTools(
restartTools.registry,
resumeRestart.cast<ToolRequestPart>().toList(),
c.context,
cancel: c.cancel,
middleware: resolvedMiddleware,
toolMap: restartTools.toolMap,
);
}
重点看最后的 toolMap: restartTools.toolMap,以及首个参数使用的 restartTools.registry。恢复执行拿到了同一轮解析产生的 registry 和名称映射。
作者还发现了同一分支中的另一个问题:恢复后的工具结果写回历史时,只复制了 output,没有保留 content 和 metadata。工具即使执行成功,返回的文本、图片内容或元数据仍可能丢失。PR 同时补上了这两项,但作者明确说,这部分还没有独立测试。
不调用真实模型,也能验证批准后的执行
作者的回归测试不需要 API key。假模型请求短名称 order_part,实际注册的工具仍叫 work-orders/order_part。
工具第一次执行返回 .interrupt('CONFIRM_ORDER'),测试断言结果是 FinishReason.interrupted;接着构建消息历史,通过 interruptRestart 恢复同一请求。第二次执行返回 .response('OrderedPart')。
最后检查两件事:toolCallCount 等于 2,以及假模型记录的 observedToolOutput 等于 OrderedPart。这既验证工具被重新执行,也验证结果确实回到了模型。
复现在 #657,完整测试位于 packages/genkit/test/ai/interrupt_test.dart。按原文记录,#658 当时仍处于开放状态,Google CLA 检查通过,Dart 和 Flutter CI 还在等待维护者批准首次运行,不能据此认定修复已经发布。
等待正式修复期间,作者使用一个裸名称的本地工具转发 MCP 调用:
final mcpOrderPart = mcpTools.singleWhere((tool) => tool.name.endsWith('/order_part'));
final orderPart = ai.defineTool<PartOrder, String>(
name: 'order_part',
description: mcpOrderPart.description ?? 'Orders a spare part for one work order.',
inputSchema: PartOrder.$schema,
fn: (input, _) async => .response('${(await mcpOrderPart.call(input.toJson())).output}'),
);
final tools = [...mcpTools.where((tool) => tool != mcpOrderPart), orderPart];
关键是 name: 'order_part':让本地注册名称与模型调用名称一致。最后一行还移除了原 MCP 工具,再加入适配工具。业务逻辑仍由 MCP server 执行,但这段适配只取 .output 并转成字符串,并没有完整转发所有返回字段。
我的判断
作者的定位和修复有明确的代码依据,回归测试也直接覆盖了触发条件。相比反复修改 prompt 或更换模型,先检查工具定义、名称映射和执行分支,更符合这次故障的证据。
但它有具体前提:带命名空间的 MCP 工具、模型使用短名称,以及通过 interruptRestart 恢复执行。不能把所有审批失败都归到这个 bug 上。
「要测恢复路径」这个建议成立,但测试范围仍有限。假模型能稳定验证框架内部行为,原文没有给出真实模型与 MCP server 的完整联调结果;content、metadata 的修复也缺少独立测试。两部分的验证力度应分开看。
本地裸名称适配适合临时绕过问题,却会增加维护成本。若多个 MCP server 都有 order_part,还需要处理短名称冲突。原文没有说明这种情况,也没有提供其他方案的对照数据。
能拿来做什么
- 给 Agent 增加审批功能时,把「点击批准后恢复执行」列为独立验收场景,同时检查工具执行结果是否回到消息历史和模型。
- 遇到
Tool … not found时,分别记录模型请求名称、工具注册名称和映射结果,再检查正常分支与恢复分支传入的参数是否一致。 - 在日常 AI 编程中,用假模型和会中断一次的工具构造最小复现,先验证框架行为,减少真实模型调用带来的不确定性。
- 如果采用临时转发工具,明确它保留了哪些返回字段;依赖文本、图片或元数据的业务,应分别验证
output、content和metadata。