/** * 工具总线的**装配面** —— 声明清单 → 可调用的 Mastra tools(编码规范 §6.2: * `@mastra` 只准出现在 adapter 与 app)。 * * 声明面(纯逻辑,`toolbox/internal/domain/tool-bus.ts`)决定**能调什么**; * 这里决定**怎么调** —— 三路各自穿过它该穿的那道缝,一道都不许绕: * · `function` → **工具调用一缝**(`ToolInvoker`):白名单、录制回放、契约校验; * 写类**不当场落地**,只登记动作意向,放行那一刻才由系统执行; * · `human` → 人类工具:发工单到负责人工位 + 立监听听回传,本回合就此等待; * · `llm` → **模型网关一缝**(`ModelGateway`):四元绑定、录制回放、用量计预算。 * * 每件工具的入参 schema 由清单里的 JSON Schema 现译成 zod —— 模型照它填参, * 填错当场被 Mastra 拦下,不会带着脏参数穿过缝。 */ import { createTool } from '@mastra/core/tools'; import type { ToolsInput } from '@mastra/core/agent'; import { z } from 'zod'; import type { StaffId, TaskId } from '@contracts'; import type { BusTool } from '@modules/toolbox'; /** 三路缝的最小口子 —— 各自只要这一点点能力,不把整个服务拖进来 */ export type ToolBusPorts = { /** 功能工具:读类当场调;写类先登记意向(放行才落地) */ readonly invoke: (input: { taskId: TaskId; tool: string; input: Record; }) => Promise<{ ok: boolean; output?: Record; error?: { message: string } }>; readonly isWriteEffect: (tool: string) => Promise; readonly registerIntent: (input: { taskId: TaskId; tool: string; input: Record; label: string; }) => Promise; /** 人类工具:发工单 */ readonly issueCard: (input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; workOrder: 'execution' | 'acceptance'; card: Record; }) => Promise<{ id: string }>; /** 人类工具:立监听听人 */ readonly listen: (input: { taskId: TaskId; eventType: 'data' | 'time' | 'human'; keyFilter: Record; deadline: string; }) => Promise<{ id: string }>; /** LLM 工具:经模型网关 */ readonly generate: (input: { model: string; system?: string; prompt: string; outputSchema?: Record; taskId: TaskId; label: string; }) => Promise<{ text: string; object?: unknown }>; }; export type ToolBusContext = { readonly taskId: TaskId; readonly ownerId: StaffId; readonly humanCallCap: number; /** 活动声明的模型用途名(第一个)—— LLM 工具用它,活动不见厂商型号 */ readonly model: string; }; /** 本回合调人的痕:总线把它交回引擎,引擎据此把这一拍收成 awaiting */ export type ToolBusTrace = { /** 调人发出的卡(有值即本回合要等人) */ askedCardId?: string; }; /** * JSON Schema → zod(仅译总线用得上的那一小撮:object/string/number/integer/ * boolean/array/enum)。译不出的类型退化成 `z.unknown()` —— 宁可放宽, * 也不假装校验过:真正的契约校验在缝里(`ToolInvoker.validate`),这里只为让模型会填。 */ function toZod(schema: Record | undefined): z.ZodTypeAny { if (schema === undefined) return z.object({}).passthrough(); const type = schema['type']; const describe = (t: z.ZodTypeAny) => typeof schema['description'] === 'string' ? t.describe(schema['description']) : t; if (Array.isArray(schema['enum'])) { const values = (schema['enum'] as unknown[]).map(String); return describe(values.length > 0 ? z.enum(values as [string, ...string[]]) : z.string()); } if (type === 'string') return describe(z.string()); if (type === 'number') return describe(z.number()); if (type === 'integer') return describe(z.number().int()); if (type === 'boolean') return describe(z.boolean()); if (type === 'array') { const items = schema['items']; return describe(z.array(toZod(typeof items === 'object' && items !== null ? (items as Record) : undefined))); } if (type === 'object') { const props = schema['properties']; if (typeof props !== 'object' || props === null) return describe(z.object({}).passthrough()); const required = new Set(Array.isArray(schema['required']) ? (schema['required'] as string[]) : []); const shape: Record = {}; for (const [key, value] of Object.entries(props as Record)) { const field = toZod(typeof value === 'object' && value !== null ? (value as Record) : undefined); shape[key] = required.has(key) ? field : field.optional(); } return describe(z.object(shape)); } return describe(z.unknown()); } /** LLM 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */ function llmPrompt(id: string, args: Record): { system: string; prompt: string } { const j = (v: unknown) => JSON.stringify(v ?? null); switch (id) { case 'llm.extract': return { system: '你是抽取器。只从原文里抽,抽不到的字段留空 —— **不许编**。只回 JSON。', prompt: `字段:${j(args['fields'])}\n提示:${String(args['hint'] ?? '无')}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"values": {字段: 值}}`, }; case 'llm.classify': return { system: '你是归类器。只能从给定类目里选一个;判不了就回 "unknown"。只回 JSON。', prompt: `类目:${j(args['labels'])}\n提示:${String(args['hint'] ?? '无')}\n内容:\n${String(args['text'] ?? '')}\n\n回 {"label": "...", "rationale": "一句话理由"}`, }; case 'llm.summarize': return { system: '你是摘要器。只压缩已有内容,不添事实、不作判断。只回 JSON。', prompt: `重点:${String(args['focus'] ?? '无')}\n字数上限:${String(args['maxWords'] ?? 200)}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"summary": "..."}`, }; case 'llm.judge': return { system: '你是裁断器。给出是/否与理由,并诚实给出把握度(0-1)。**你的结论只作参考,不代替人的放行裁决**。只回 JSON。', prompt: `问题:${String(args['question'] ?? '')}\n判据:\n${String(args['basis'] ?? '')}\n\n回 {"verdict": true/false, "confidence": 0-1, "rationale": "理由"}`, }; default: return { system: '你是改写器。只改表达方式,**不添事实、不删事实**。只回 JSON。', prompt: `要求:${String(args['instruction'] ?? '')}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"text": "改写后"}`, }; } } /** * 把总线清单装配成 Mastra tools。 * * `trace` 是回传口:调人这件事在 Agent 眼里是「调了个工具」,在引擎眼里是 * 「本回合要挂起等人」—— 工具执行时把卡号记进 trace,引擎读它收尾。 */ export function buildMastraTools( bus: readonly BusTool[], ports: ToolBusPorts, context: ToolBusContext, trace: ToolBusTrace, ): ToolsInput { const out: ToolsInput = {}; for (const tool of bus) { // Mastra 的 tool id 不收点号以外的怪字符,统一把点号换成双下划线,回调时换回来 const key = tool.id.replace(/[^a-zA-Z0-9_]/g, '__'); out[key] = createTool({ id: key, description: tool.description, inputSchema: toZod(tool.input) as never, execute: async (inputData: unknown) => { const args = (inputData ?? {}) as Record; if (tool.kind === 'human') { const card = await ports.issueCard({ taskId: context.taskId, ownerId: context.ownerId, humanCallCap: context.humanCallCap, workOrder: 'execution', card: args, }); await ports.listen({ taskId: context.taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D', }); trace.askedCardId = card.id; return { asked: true, cardId: card.id }; } if (tool.kind === 'llm') { const { system, prompt } = llmPrompt(tool.id, args); const result = await ports.generate({ model: context.model, system, prompt, ...(tool.output === undefined ? {} : { outputSchema: tool.output }), taskId: context.taskId, label: tool.id, }); return (result.object ?? { text: result.text }) as Record; } /* * 功能工具:**写类不当场落地**(OS 06「过程不落地」)—— * 过程中只冻结入参登记为动作意向,放行那一刻由系统执行。 * 骗 Agent 说「已执行」是最坏的选项:它会据此往下推,而世界上什么都没发生。 */ if (await ports.isWriteEffect(tool.id)) { await ports.registerIntent({ taskId: context.taskId, tool: tool.id, input: args, label: tool.id }); return { registered: true, note: '写类工具已登记为动作意向 —— 放行时才真正执行,现在世界上什么都没变' }; } const result = await ports.invoke({ taskId: context.taskId, tool: tool.id, input: args }); if (!result.ok) return { ok: false, error: result.error?.message ?? '工具调用失败' }; return { ok: true, ...(result.output ?? {}) }; }, }); } return out; }