// 助手工具注册中心:把各工具包的 bundle 合成一份工具面,并在此统一治理。 // // 为什么治理沉在这里而不是各工具包自己抄一遍(工程铁律:知识沉通用资产): // 错误信封、结果字节帽、遥测这三件事对每个工具都必须成立,一旦让工具包各写各的, // 新增一个工具就多一次漏写的机会。业务侧只管声明能力,限额与降级由系统统一负责。 // // 组装序固定 knowledge → ai-api → host-declared:先内置后宿主,重名时报错信息里 // 先出现的包就是"占位者",与 AiApiRegistry 的文件名排序一样是可复现的确定顺序。 import type { Tool, ToolSet } from 'ai'; import { AiApiError, AiErrorCodes } from '../../ai-shared/AiError'; import { AI_LIMITS, utf8ByteLength } from '../../ai-shared/AiLimits'; import type { AiRuntimeConfig } from '../../ai-shared/AiRuntimeConfig'; import type { AssistantToolDisplay, AssistantToolkit, AssistantToolkitContext, } from './AssistantToolkit'; /** 兼容知识库 camelCase 与 func 的 kebab-case;64 上限对齐 AI_FUNC_PATTERN。 */ export const ASSISTANT_TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; export interface AssistantToolRegistryContext { config: AiRuntimeConfig; hostDir: string; requestOrigin: string; signal?: AbortSignal; } export interface AssistantToolSurface { tools: ToolSet; /** 工具名 → Widget 三态文案。 */ display: Record; /** 各包贡献的提示词能力段,按组装序。 */ capabilities: string[]; /** 工具名 → 来源工具包名,只用于遥测与报错定位,不下发给模型或前端。 */ origins: Record; } export function isAssistantToolkitEnabled( config: AiRuntimeConfig, name: string, defaultEnabled = true, ): boolean { return config.assistant.toolkits[name]?.enabled ?? defaultEnabled; } export async function assembleAssistantTools( toolkits: readonly AssistantToolkit[], context: AssistantToolRegistryContext, ): Promise { const tools: ToolSet = {}; const display: Record = {}; const capabilities: string[] = []; const origins: Record = {}; for (const toolkit of toolkits) { if (!isAssistantToolkitEnabled(context.config, toolkit.name, toolkit.defaultEnabled ?? true)) { continue; } const toolkitContext: AssistantToolkitContext = { config: context.config, hostDir: context.hostDir, options: context.config.assistant.toolkits[toolkit.name]?.options ?? {}, requestOrigin: context.requestOrigin, signal: context.signal, }; const bundle = await toolkit.create(toolkitContext); for (const [name, tool] of Object.entries(bundle.tools)) { if (!ASSISTANT_TOOL_NAME_PATTERN.test(name)) { throw declarationError(`工具名不合法(${toolkit.name}):${name}`); } if (name in tools) { throw declarationError( `工具重名:${name} 同时来自 ${origins[name]} 与 ${toolkit.name};请改名或关闭其中一个来源`, ); } tools[name] = governTool(name, toolkit.name, tool); origins[name] = toolkit.name; const toolDisplay = bundle.display?.[name]; if (toolDisplay) { display[name] = toolDisplay; } } const capability = bundle.systemPrompt?.trim(); if (capability) { capabilities.push(capability); } } const total = Object.keys(tools).length; if (total > AI_LIMITS.assistantTools) { throw declarationError( `助手工具总数 ${total} 超过上限 ${AI_LIMITS.assistantTools};` + '请用 options.assistantTool: false 或 assistant.toolkits.<包>.enabled: false 收敛', ); } return { tools, display, capabilities, origins }; } /** * 统一包装单个工具的 execute:异常回错误信封、结果过字节帽、每次调用记一行遥测。 * 工具自身抛错永远不炸流——模型看到信封后可以改参重试或如实说明,比整条流断掉可用得多。 */ function governTool(toolName: string, toolkitName: string, tool: Tool): Tool { const execute = (tool as { execute?: ToolExecute }).execute; if (typeof execute !== 'function') { return tool; } const governed: ToolExecute = async (input, options) => { const startedAt = performance.now(); try { const output = await execute(input, options); const bytes = measureToolResult(output); if (bytes > AI_LIMITS.toolResultBytes) { // 刻意不截断:半截 JSON 进上下文会让模型把残缺数据当事实。 return finish(toolName, toolkitName, startedAt, bytes, toolErrorEnvelope(new AiApiError( AiErrorCodes.AI_OUTPUT_INVALID, `工具结果超过 ${Math.floor(AI_LIMITS.toolResultBytes / 1024)}KB,请缩小查询范围后重试`, ))); } return finish(toolName, toolkitName, startedAt, bytes, output); } catch (error) { if (isAbortLike(error)) { throw error; } return finish(toolName, toolkitName, startedAt, 0, toolErrorEnvelope(error)); } }; return { ...tool, execute: governed } as Tool; } export interface AssistantToolErrorEnvelope { error: { code: number; message: string }; } export function toolErrorEnvelope(error: unknown): AssistantToolErrorEnvelope { if (error instanceof AiApiError) { return { error: { code: error.code, message: error.message } }; } return { error: { code: AiErrorCodes.AI_UPSTREAM_ERROR, message: '工具执行失败' } }; } type ToolExecute = (input: unknown, options: unknown) => unknown; function finish( toolName: string, toolkitName: string, startedAt: number, bytes: number, output: unknown, ): unknown { const failed = isErrorEnvelope(output); logToolCall({ tool: toolName, toolkit: toolkitName, durationMs: Math.round(performance.now() - startedAt), bytes: failed ? 0 : bytes, code: failed ? output.error.code : 0, }); return output; } interface AssistantToolCallRecord { tool: string; toolkit: string; durationMs: number; bytes: number; code: number; } /** 遥测只记可聚合的标量,不记入参与结果内容(隐私面与 token 纪律)。 */ function logToolCall(record: AssistantToolCallRecord): void { if (process.env.SOMA_AI_TOOL_LOG === 'off') { return; } console.info( `[soma-ai][助手] ${record.toolkit}/${record.tool} ` + `${record.durationMs}ms ${record.bytes}B code=${record.code}`, ); } function measureToolResult(output: unknown): number { if (output === undefined) { return 0; } try { return utf8ByteLength(JSON.stringify(output) ?? ''); } catch { // 循环引用等不可序列化结果:交给字节帽以外的信封路径处理不了,这里直接判超帽。 return AI_LIMITS.toolResultBytes + 1; } } function isErrorEnvelope(value: unknown): value is AssistantToolErrorEnvelope { return Boolean(value) && typeof value === 'object' && typeof (value as AssistantToolErrorEnvelope).error?.code === 'number'; } /** * 中断/超时属于会话级事件,必须原样上抛让流按既有纪律收口,不能吞成工具错误。 * 按 name 判定而不是 instanceof:DOMException 在不同运行时(node / jsdom / undici) * 的原型链并不一致,认名字才稳。 */ function isAbortLike(error: unknown): boolean { const name = (error as { name?: unknown } | null)?.name; return name === 'AbortError' || name === 'TimeoutError'; } function declarationError(message: string): AiApiError { return new AiApiError(AiErrorCodes.AI_TOOL_DECL_INVALID, message); }