Hydra-ai(Tambo)Provider-Managed Skill Tool Calls 抑制方案:在源头抑制,而非下游到处修补
【免费下载链接】hydra-aiGenerative UI SDK for React项目地址: https://gitcode.com/GitHub_Trending/hy/hydra-ai
本文聚焦 Tambo(hydra-ai)后端在 AI SDK 流式管线中处理 Anthropic / OpenAI 等厂商托管 Skill 工具调用的架构决策。核心结论是:当厂商内部工具事件(
code_execution、shell)混入用户注册的工具调用流时,正确做法是在ai-sdk-client.ts的流式入口处用布尔标志 + 三个break直接抑制,而不是为每个下游消费者添加特判。读完本文,你将理解该问题产生的四个具体故障、两种失败方案的教训、最终 O(1) 级修复的源码实现,以及未来新增厂商工具时的扩展步骤。
背景:厂商托管的 Skill 工具事件为何会泄漏
Tambo 的架构中,Skill(技能)既可以通过 packages/backend/src/tambo-backend.ts 注入决策循环,也可以直接交给大模型厂商在安全容器环境内执行。当 Anthropic / OpenAI 执行 Skill 时,它们会通过 AI SDK 的流式管线发出工具调用事件(code_execution、shell)。这类事件是厂商内部实现细节,不应该流入 Tambo 自身的工具调用管线——因为 Tambo 管线语义是"用户注册的、需要 Tambo 转发的工具"。
问题出在:从流式管线的角度看,这些厂商事件与用户注册的工具调用长得一模一样(都是tool-input-start→tool-input-delta→tool-call事件序列),纳管它们会立刻引发连锁故障。
问题:放行厂商工具事件的四个具体故障
原文档列出了四个典型症状,均可从源码结构得到印证:
- UI 把
code_execution当作工具名展示:前端拿到TOOL_CALL_*事件后直接渲染toolCallName,厂商内部工具名(如code_execution)会原样出现在界面上。 - 内部路径泄漏:
/skills/my-skill/SKILL.md这类 Skill 文件路径会作为工具参数暴露给终端用户,破坏封装边界。 - 刷新后工具调用消失:厂商执行的工具在
tool-result时被清空(见下文tool-result分支),导致页面刷新后工具调用时有时无。 - 同一轮内多个工具调用互相覆盖:每条消息只承载一个
toolCallRequest,多条厂商工具调用会互相覆盖。
从源码看,handleStreamingResponse中的accumulatedToolCall是一个单一累加器(ai-sdk-client.ts),每条消息只能 yield 一个tool_calls,这正是第 4 点故障的机制根源。
尝试过的方案(以及为何失败)
方案一:重命名 + 清理(过于脆弱)
把code_execution改名为skill、清理参数、跨调用累加 Skill 名称。这个方案需要在7 个文件里打特判补丁:
- 流式处理器:5 个跟踪变量、协调事件发射;
- 决策循环:用条件展开(conditional spreads)跨 yield 保留数据;
- 工具服务:对未知工具提前 return;
- Threads 服务:约 40 行检测块;
- V1 转换:跳过 skill tool_use 块;
- 可观测性 UI:把 skill 卡片重排到文本之前;
- Core 包:新增共享常量。
文档的结论很直白:每个修复都会在下游制造一个新边界用例,修补的成本随下游消费者数量线性增长。
方案二:基于元数据的跟踪(仍然过度设计)
把 Skill 执行记录为metadata._tambo.skillExecutions而不是工具调用。更干净了,但仍然要新增:
LLMStreamItem和DecisionStreamItem接口上的新字段;- 用正则解析提取 Skill 名称;
- Threads 服务中的元数据存储;
- 渲染 Skill 徽章的新 UI 组件;
- Core 包中的
isSkillToolName辅助函数。
文档对此的点评一针见血:"为了一个没人需要看的厂商工具调用"。
方案三:直接抑制(正确答案)
一个布尔标志 + 在ai-sdk-client.ts中加三个break。Skill 工具事件对工具调用累加器完全不可见,厂商执行完 Skill 后由 LLM 在文本回复里描述结果(通过系统提示词强制)。
最终方案源码详解
步骤 1:按厂商解析 Skill 工具名
AISdkClient顶部维护了一张"厂商 → Skill 工具名"的映射表(ai-sdk-client.ts):
/** * Provider-specific tool names used for skill execution. * Each provider uses a different tool name for its skill container. * Only the tool name for the active provider is suppressed, so a user * tool named "shell" on Anthropic (or "code_execution" on OpenAI) is * not accidentally swallowed. */ const PROVIDER_SKILL_TOOL_NAME: Record<string, string> = { anthropic: "code_execution", openai: "shell", };这段注释本身就是设计的关键约束:只抑制"当前活跃厂商注入的那个工具名"。这样当用户在 Anthropic 上自定义了一个名为shell的工具(或在 OpenAI 上自定义了code_execution),不会因为名字撞车而被误吞。
skillToolName的解析发生在complete()的流式分支(ai-sdk-client.ts):
const skillToolName = params.providerSkills?.skills.length ? PROVIDER_SKILL_TOOL_NAME[providerKey] : undefined;即:只有当本次请求确实携带了 providerSkills 时才启用抑制,否则skillToolName为undefined,所有事件照常处理。
步骤 2:流式循环中的三个break
handleStreamingResponse维护一个isProviderSkillTool标志(ai-sdk-client.ts),在三个事件分支中执行抑制:
case "tool-input-start": { // Only suppress the specific tool name injected by the active // provider for skills. This avoids silently swallowing a user // tool that shares a name with a different provider's skill tool // (e.g. user tool "shell" on Anthropic, or "code_execution" on OpenAI). isProviderSkillTool = !!skillToolName && delta.toolName === skillToolName; if (isProviderSkillTool) { // Skill tools are fully handled by the provider. Ignore. // Clear accumulated tool state to prevent stale data from a // previous tool call leaking if tool-result doesn't fire. componentTracker = undefined; accumulatedToolCall.name = undefined; accumulatedToolCall.arguments = ""; accumulatedToolCall.id = undefined; break; } // ... 正常工具处理(含 show_component_* 组件跟踪)... } case "tool-input-delta": if (isProviderSkillTool) break; // ... 正常参数累加 / 组件 JSON 增量解析 ... case "tool-call": if (isProviderSkillTool) break; // ... google thoughtSignature 元数据处理 ... // ... 组件 finalize / TOOL_CALL_END 发射 ...三个break分别在"开始"、"增量"、"完成"三个事件上拦截,且只在tool-input-start判断标志,后续事件靠状态延续。注意tool-input-start分支里还清空了accumulatedToolCall和componentTracker,防止上一个工具调用残留的脏数据泄漏(例如tool-result未触发的情况)。
步骤 3:tool-result对厂商执行结果的兜底
即使有上述拦截,厂商执行完成的tool-result事件仍可能出现在流中。源码在tool-result分支做了显式断言与清理(ai-sdk-client.ts):
case "tool-result": // Provider-managed tools (e.g. OpenAI shell for skills) return results // inline in the stream. These are handled by the provider, not Tambo. if (!("providerExecuted" in delta) || !delta.providerExecuted) { throw new Error( "Tool result should not be emitted during streaming", ); } // Clear accumulated tool call so subsequent chunks don't carry // the provider-executed tool as an unresolved client tool call. accumulatedToolCall.name = undefined; accumulatedToolCall.arguments = ""; accumulatedToolCall.id = undefined; break;这印证了原文档中"厂商执行的工具会在tool-result时被清空"的故障描述——厂商工具的结果是内联返回的(providerExecuted: true),不属于 Tambo 的转发职责。
步骤 4:文本恢复时复用同一消息 ID
Skill 工具执行期间 LLM 暂停生成文本,完成后在同一流中继续。text-start分支专门处理这种情况(ai-sdk-client.ts):如果已有未结束的文本消息且刚刚结束的是一个 Skill 工具调用,则复用textMessageId并追加"\n\n"分隔符,客户端收到重复TEXT_MESSAGE_START时只更新流式状态、不新建气泡。
// Text resumed after a provider-managed skill tool call. // This branch only triggers for skills because regular tool // calls end the stream entirely (the decision loop restarts // a new complete() call for the next turn). Only provider- // executed tools (skills) return results inline and let the // LLM continue generating text in the same stream.这正是普通工具与 Skill 工具在流式行为上的本质差异:普通工具调用会终止本轮流(决策循环重新发起complete()),而 Skill 工具内联返回结果并让 LLM 在同一流中继续说话。
前置:Skill 如何被装配进请求
抑制方案依赖"仅在有 providerSkills 时生效",理解装配链路有助于你调试:
- 类型定义:
ProviderSkillConfig与ProviderSkillReference定义在 packages/core/src/skills.ts,providerSkills结构为{ providerName, skills: [{ skillId, version }] }; - 客户端接口:
LLMClient.complete/StreamingCompleteParams在 llm-client.ts 中声明了providerSkills?: ProviderSkillConfig; - 决策循环:
runDecisionLoop接收providerSkills参数并透传给llmClient.complete(decision-loop-service.ts),模型级支持在ensureProviderSkillsForRun上游校验,传入的 skills 对该模型一定有效; - 厂商装配:
mergeProviderSkills是真正的注入点(ai-sdk-client.ts)——OpenAI 侧添加shell工具(containerAuto环境 +skillReference列表)并切换到responses API模型;Anthropic 侧添加codeExecution_20260120()工具并在providerOptions.anthropic.container.skills下注入custom类型的 Skill 引用。两处都会在覆盖同名工具时打印警告。
系统提示词:让 LLM 自己交代 Skill 做了什么
抑制工具事件后,用户如何感知 Skill 的执行?答案是:由 LLM 在文本回复中描述。决策循环系统提示词中新增了### Skills (Internal Implementation Detail)段落(decision-loop-prompts.ts):
### Skills (Internal Implementation Detail) You may have access to skills that run in a secure container environment. These skills are an internal implementation detail and must be treated as opaque. - Do NOT mention skill file names, skill IDs, SKILL.md files, or any internal skill structure in your responses or thinking. - Do NOT describe how skills are loaded, structured, or executed. - When you use a skill, briefly mention which skill you are using by its name (e.g. "Using the contenteditable="false">【免费下载链接】hydra-aiGenerative UI SDK for React
项目地址: https://gitcode.com/GitHub_Trending/hy/hydra-ai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考