From f30f24e0bab7e0b018a342d093c12e6ba76963c6 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 23:48:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=80=BB=E7=BA=BF=E8=A3=85?= =?UTF-8?q?=E9=85=8D=E9=9D=A2=E6=B5=8B=E8=AF=95:=E4=B8=89=E8=B7=AF?= =?UTF-8?q?=E5=90=84=E7=A9=BF=E5=90=84=E7=9A=84=E7=BC=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 守开环 Agent 最容易出事的三条: - 写类工具**过程中不落地**(只登记意向,并如实告诉 Agent 世界还没变)—— 骗它说已执行,它会据此往下推而世界一步没动 - 调人 = 发工单 + 立监听,卡号记进 trace(引擎据此挂起,不重复发第二张) - LLM 五件经模型网关、用活动声明的用途名;提示词归系统 六例全绿;顺带钉住 Mastra 的 tool id 规矩(点号 → 双下划线)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../app/__tests__/tool-bus-assembly.test.ts | 141 ++++++++++++++++++ .../src/negative/dowhile-shape-mismatch.ts | 30 ++++ .../src/probe-05-misresume-blast-radius.ts | 75 ++++++++++ spike/mastra-reopen/src/route-a-dowhile.ts | 4 +- 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/__tests__/tool-bus-assembly.test.ts create mode 100644 spike/mastra-reopen/src/negative/dowhile-shape-mismatch.ts create mode 100644 spike/mastra-reopen/src/probe-05-misresume-blast-radius.ts diff --git a/apps/api/src/app/__tests__/tool-bus-assembly.test.ts b/apps/api/src/app/__tests__/tool-bus-assembly.test.ts new file mode 100644 index 0000000..f253498 --- /dev/null +++ b/apps/api/src/app/__tests__/tool-bus-assembly.test.ts @@ -0,0 +1,141 @@ +/** + * 工具总线的**装配面** —— 三路各穿各的缝,一道都不许绕。 + * + * 这里守的是开环 Agent 最容易出事的三条: + * ① 写类工具**过程中不落地**(只登记意向)—— 骗 Agent 说已执行,它会据此往下推, + * 而世界上什么都没发生; + * ② 调人把卡号记进 trace —— 引擎据此把这一拍挂起,不重复发第二张卡; + * ③ LLM 工具经模型网关(不是自己直连),用途名照活动声明走。 + */ +import { describe, expect, it } from 'vitest'; +import type { StaffId, TaskId } from '@contracts'; +import { HUMAN_TOOL, LLM_TOOLS } from '@modules/toolbox'; +import type { BusTool } from '@modules/toolbox'; +import { buildMastraTools } from '../tool-bus.ts'; +import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts'; + +const TASK = 't-1' as TaskId; +const OWNER = 's-1' as StaffId; + +const READ_TOOL: BusTool = { + id: 'crm.history-price-read', + description: '读历史价', + input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } }, + kind: 'function', + effect: 'read', +}; +const WRITE_TOOL: BusTool = { + id: 'mail.send', + description: '发邮件', + input: { type: 'object', required: ['to'], properties: { to: { type: 'string' } } }, + kind: 'function', + effect: 'write', +}; + +type Calls = { + invoked: Array<{ tool: string; input: Record }>; + intents: Array<{ tool: string; input: Record }>; + cards: Array<{ workOrder: string; card: Record }>; + listens: Array<{ keyFilter: Record }>; + generated: Array<{ model: string; label: string; prompt: string }>; +}; + +function harness(writeTools: string[] = ['mail.send']) { + const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [] }; + const ports: ToolBusPorts = { + invoke: async (input) => { + calls.invoked.push({ tool: input.tool, input: input.input }); + return { ok: true, output: { historyDays: 12 } }; + }, + isWriteEffect: async (tool) => writeTools.includes(tool), + registerIntent: async (input) => { + calls.intents.push({ tool: input.tool, input: input.input }); + }, + issueCard: async (input) => { + calls.cards.push({ workOrder: input.workOrder, card: input.card }); + return { id: `card-${calls.cards.length}` }; + }, + listen: async (input) => { + calls.listens.push({ keyFilter: input.keyFilter }); + return { id: 'lsn-1' }; + }, + generate: async (input) => { + calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt }); + return { text: '{"label":"C"}', object: { label: 'C', rationale: '据规则' } }; + }, + }; + const trace: ToolBusTrace = {}; + return { calls, ports, trace }; +} + +/** Mastra tool 的 execute 走真实签名:(inputData, context) */ +const run = async (tool: unknown, args: Record) => + (await (tool as { execute: (a: unknown, b: unknown) => Promise }).execute(args, {})) as Record; + +describe('装配:功能工具', () => { + it('读类当场调,出参原样带回', async () => { + const h = harness(); + const tools = buildMastraTools([READ_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(tools['crm__history_price_read'] ?? Object.values(tools)[0], { pn: 'PN-1' }); + expect(h.calls.invoked).toEqual([{ tool: 'crm.history-price-read', input: { pn: 'PN-1' } }]); + expect(out).toMatchObject({ ok: true, historyDays: 12 }); + expect(h.calls.intents).toHaveLength(0); + }); + + it('写类**不当场落地**:只登记动作意向,并如实告诉 Agent 世界还没变', async () => { + const h = harness(); + const tools = buildMastraTools([WRITE_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { to: 'a@b.c' }); + expect(h.calls.intents).toEqual([{ tool: 'mail.send', input: { to: 'a@b.c' } }]); + expect(h.calls.invoked).toHaveLength(0); + expect(out['registered']).toBe(true); + expect(String(out['note'])).toContain('放行时才真正执行'); + }); + + it('工具调用失败如实回报,不抛掉整拍', async () => { + const h = harness(); + h.ports.invoke = async () => ({ ok: false, error: { message: '端点不可达' } }); + const tools = buildMastraTools([READ_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { pn: 'PN-1' }); + expect(out).toEqual({ ok: false, error: '端点不可达' }); + }); +}); + +describe('装配:人类工具(系统内化)', () => { + it('调人 = 发工单 + 立监听,卡号记进 trace 供引擎收尾挂起', async () => { + const h = harness(); + const tools = buildMastraTools([HUMAN_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { title: '请确认底价', ask: '这一单要不要走寻源?' }); + + expect(h.calls.cards).toEqual([ + { workOrder: 'execution', card: { title: '请确认底价', ask: '这一单要不要走寻源?' } }, + ]); + expect(h.calls.listens).toEqual([{ keyFilter: { cardId: 'card-1' } }]); + expect(h.trace.askedCardId).toBe('card-1'); + expect(out).toEqual({ asked: true, cardId: 'card-1' }); + }); +}); + +describe('装配:LLM 五件(系统内化)', () => { + it('经模型网关调,用活动声明的用途名;提示词归系统', async () => { + const h = harness(); + const tools = buildMastraTools(LLM_TOOLS, h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'quote-extractor' }, h.trace); + const classify = tools['llm__classify']; + expect(classify).toBeDefined(); + + const out = await run(classify, { text: '这是一颗停产料', labels: ['A', 'B', 'C'] }); + expect(h.calls.generated).toHaveLength(1); + expect(h.calls.generated[0]!.model).toBe('quote-extractor'); + expect(h.calls.generated[0]!.label).toBe('llm.classify'); + expect(h.calls.generated[0]!.prompt).toContain('这是一颗停产料'); + expect(out).toEqual({ label: 'C', rationale: '据规则' }); + }); + + it('五件都装得出来,id 里的点号换成双下划线(Mastra 的 id 规矩)', () => { + const h = harness(); + const tools = buildMastraTools(LLM_TOOLS, h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + expect(Object.keys(tools).sort()).toEqual( + ['llm__classify', 'llm__extract', 'llm__judge', 'llm__rewrite', 'llm__summarize'].sort(), + ); + }); +}); diff --git a/spike/mastra-reopen/src/negative/dowhile-shape-mismatch.ts b/spike/mastra-reopen/src/negative/dowhile-shape-mismatch.ts new file mode 100644 index 0000000..63e25f4 --- /dev/null +++ b/spike/mastra-reopen/src/negative/dowhile-shape-mismatch.ts @@ -0,0 +1,30 @@ +/** + * 反例:dowhile 的循环体「输出形状 ≠ 输入形状」时会怎样(只做类型检查,不运行)。 + * 用 tsc 单独检查这个文件:npx tsc --noEmit --strict --module esnext --moduleResolution bundler \ + * --target es2022 --skipLibCheck src/negative/dowhile-shape-mismatch.ts + */ +import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { z } from 'zod'; + +const In = z.object({ round: z.number(), reopen: z.boolean() }); +const Out = z.object({ somethingElse: z.string() }); + +const body = createStep({ + id: 'body', + inputSchema: In, + outputSchema: Out, + execute: async () => ({ somethingElse: 'x' }), +}); + +const start = createStep({ + id: 'start', + inputSchema: z.object({}), + outputSchema: In, + execute: async () => ({ round: 1, reopen: false }), +}); + +export const wf = createWorkflow({ id: 'neg', inputSchema: z.object({}), outputSchema: Out }) + .then(start) + // 循环体输出 Out,下一轮却要拿它当 In 用 + .dowhile(body, async ({ inputData }) => inputData.reopen === true) + .commit(); diff --git a/spike/mastra-reopen/src/probe-05-misresume-blast-radius.ts b/spike/mastra-reopen/src/probe-05-misresume-blast-radius.ts new file mode 100644 index 0000000..e0e0246 --- /dev/null +++ b/spike/mastra-reopen/src/probe-05-misresume-blast-radius.ts @@ -0,0 +1,75 @@ +/** + * 关键差异确认:误 resume(迟到的/重复的任务回调)会不会把整条 run 打死? + * - 顶层 step 打错 → ? + * - 嵌套 step(nested workflow 里的 step)打错 → ? + * 路线 A 里所有节点都在嵌套的循环体里,所以这条差异对选型很关键。 + * + * 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-05-misresume-blast-radius.ts + */ +import { DecisionScript, Ledger, banner } from './ledger.js'; +import { mastraB, _internals as bInternals } from './route-b-external.js'; +import { mastraA, _internals as aInternals } from './route-a-dowhile.js'; +import { resetTrace } from './trace.js'; + +aInternals.setLedger(new Ledger()); +aInternals.setScript(new DecisionScript([])); +bInternals.setLedger(new Ledger()); +bInternals.setScript(new DecisionScript([])); +resetTrace(); + +const wfB = mastraB.getWorkflow('routeB'); +const wfA = mastraA.getWorkflow('routeA'); + +banner('B-1:顶层 step 打错(A4 挂起时去 resume A5)'); +{ + const run = await wfB.createRun({ runId: 'blast-top' }); + await run.start({ inputData: { quoteId: 'QB', instanceSeq: 1, reopenedBy: null, prevRunId: null } }); + try { + const r = await run.resume({ step: ['A5'], resumeData: { verdict: 'pass' } }); + console.log('返回 status =', r.status, 'error =', JSON.stringify((r as any).error ?? null)); + } catch (e) { + console.log('抛异常 =', (e as Error).message); + } + console.log('run 之后的 status =', (await wfB.getWorkflowRunById('blast-top'))?.status); + const ok = await run.resume({ step: ['A4'], resumeData: { price: 1 } }); + console.log('再补正确的 resume A4 → status =', ok.status, ' ← run 没被打坏,还能继续'); +} + +banner('B-2:嵌套 step 打错(A4 挂起时去 resume A6-sub.A6)'); +{ + const run = await wfB.createRun({ runId: 'blast-nested' }); + await run.start({ inputData: { quoteId: 'QB2', instanceSeq: 1, reopenedBy: null, prevRunId: null } }); + try { + const r = await run.resume({ step: ['A6-sub', 'A6'], resumeData: { outcome: 'won' } }); + console.log('返回 status =', r.status, 'error =', JSON.stringify((r as any).error ?? null)); + } catch (e) { + console.log('抛异常 =', (e as Error).message); + } + const st = await wfB.getWorkflowRunById('blast-nested'); + console.log('run 之后的 status =', st?.status); + try { + const ok = await run.resume({ step: ['A4'], resumeData: { price: 1 } }); + console.log('再补正确的 resume A4 → status =', ok.status); + } catch (e) { + console.log('再补正确的 resume A4 抛异常 =', (e as Error).message, ' ← 这一单救不回来了'); + } +} + +banner('A-1:路线 A 里所有节点都在嵌套循环体里,打错一次会怎样'); +{ + const run = await wfA.createRun({ runId: 'blast-loop' }); + await run.start({ inputData: { quoteId: 'QA' } }); + try { + const r = await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'pass' } }); + console.log('返回 status =', r.status, 'error =', JSON.stringify((r as any).error ?? null)); + } catch (e) { + console.log('抛异常 =', (e as Error).message); + } + console.log('run 之后的 status =', (await wfA.getWorkflowRunById('blast-loop'))?.status); + try { + const ok = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 1 } }); + console.log('再补正确的 resume A4 → status =', ok.status); + } catch (e) { + console.log('再补正确的 resume A4 抛异常 =', (e as Error).message, ' ← 这一单救不回来了'); + } +} diff --git a/spike/mastra-reopen/src/route-a-dowhile.ts b/spike/mastra-reopen/src/route-a-dowhile.ts index c582fbb..1fbe886 100644 --- a/spike/mastra-reopen/src/route-a-dowhile.ts +++ b/spike/mastra-reopen/src/route-a-dowhile.ts @@ -262,11 +262,13 @@ export const routeA = createWorkflow({ outputSchema: z.object({ rounds: z.number(), finalOutcome: z.string().nullable() }), }) .then(start) - .dowhile(roundBody, async ({ inputData, iterationCount }) => { + .dowhile(roundBody, async ({ inputData, iterationCount, requestContext, getInitData }) => { t('dowhile.condition', 'evaluated', { iterationCount, reopen: inputData.reopen, round: inputData.round, + 'requestContext.tenant': requestContext?.get?.('tenant') ?? null, + 'getInitData()': getInitData(), '继续循环?': inputData.reopen === true, }); return inputData.reopen === true;