Archived
工具总线 + Agent 开环:声明式工具、人类工具与 LLM 五件系统内化(Mastra tool 形状)
用户定:Agent 调用工具一律**声明式**;人类工具与 LLM 工具要系统内化;
工具总线基于 Mastra tool 设计。
**声明面**(`toolbox/internal/domain/tool-bus.ts`,纯逻辑零框架):
`compileToolBus(声明 × 目录) → 一张清单` —— 编译期定死这一单能调什么,
清单之外结构上调不到(白名单在缝里再把一次关);缺件/停用如实报不静默。
三路同一形状:
· `function` 功能工具:按活动白名单查目录,带出效应(read/write)
· `human` **人类工具**(系统内化、零声明即得):调人 = 发工单 + 立监听听人 ——
这是 Harness 给的能力,不进目录、不需白名单,每个活动天然可以调人
· `llm` **LLM 五件**(系统内化):extract/classify/summarize/judge/rewrite,
各有入参出参契约、经模型网关一缝;**仅当活动声明了模型用途名才上总线**
(没声明却会问模型 = 越权,用量也无从归因);提示词归系统,活动改不了
**装配面**(`app/tool-bus.ts`,Mastra `createTool`):清单 → 可调用工具,
JSON Schema 现译 zod(模型照它填参,填错当场被拦);三路各穿各的缝:
写类工具**过程中一律不落地**,只登记动作意向(骗 Agent 说已执行是最坏选项);
调人把卡号记进 trace,引擎据此把这一拍挂起。
**Agent 开环**(`app/mastra-agent-runner.ts`,M5-2 第二实现,用户选的路线):
Mastra `Agent` + 工具总线,拍内自主连环调工具,拍末交回结构化结论。
放开的是工具循环不是规矩:写类不落地、终局唯放行、调人即挂起(新增
`AgentAction.asked` 型 —— 卡已由总线发出,引擎只收尾,不重复发卡)、
用量按 usage 计入预算。`JCAI_AGENT_OPEN_LOOP=1` 开启,缺省仍是单拍版。
`ModelGateway.resolve` 新增:名 → 四元(密钥当场解出,只给 app 层装配用)。
代价如实记 debt M5-2:开环下模型**逐轮**调用不经录制回放(tool-calling 往返
`generate` 的 text-in/text-out 承载不了)—— 凭证隔离与工具调用一缝完整。
测试:tool-bus 六例(白名单/缺件/停用/人类工具恒在/LLM 按声明/五件契约);
四闸(typecheck/lint/depcruise/test)全过;spike 目录排除出 lint(验证性原型)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 路线 B 的坑 + 一个额外候选(同 run 内 timeTravel):
|
||||
* 1. 自定义 runId 撞车会怎样?(重开用确定性 runId 时的幂等性)
|
||||
* 2. run 结束后再 resume / 重复 resume 会怎样?
|
||||
* 3. 旧 run 的快照会不会被新 run 影响?
|
||||
* 4. 回边目标如果不是图的入口节点,新 run 怎么从中间开始?—— 试 run.timeTravel()
|
||||
*
|
||||
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-04-route-b-pitfalls.ts
|
||||
*/
|
||||
import { createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { DecisionScript, Ledger, banner } from './ledger.js';
|
||||
import { mastraB, storeB, _internals } from './route-b-external.js';
|
||||
import { resetTrace } from './trace.js';
|
||||
|
||||
_internals.setLedger(new Ledger());
|
||||
_internals.setScript(new DecisionScript([]));
|
||||
resetTrace();
|
||||
|
||||
const wf = mastraB.getWorkflow('routeB');
|
||||
const snapOf = async (workflowName: string, runId: string) =>
|
||||
(await (storeB as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
|
||||
| Record<string, any>
|
||||
| null;
|
||||
|
||||
const drive = async (run: any, steps: Array<[string[], any]>) => {
|
||||
for (const [path, data] of steps) await run.resume({ step: path, resumeData: data });
|
||||
};
|
||||
|
||||
banner('坑 1:确定性 runId 撞车(重开时用 `${quoteId}-inst-N` 这种可读 ID)');
|
||||
{
|
||||
const runId = 'dup-run-1';
|
||||
const r1 = await wf.createRun({ runId });
|
||||
await r1.start({ inputData: { quoteId: 'Q1', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
|
||||
console.log('第一次 createRun+start → status =', (await wf.getWorkflowRunById(runId))?.status);
|
||||
const r2 = await wf.createRun({ runId });
|
||||
console.log('用同一个 runId 再 createRun → 没报错,拿到的是同一个 run?', r2.runId === runId);
|
||||
const st = await wf.getWorkflowRunById(runId);
|
||||
console.log(' 此时 status =', st?.status, ' 挂起的是',
|
||||
JSON.stringify(createWorkflowStateReader(st!).getSuspendedStep()?.path));
|
||||
try {
|
||||
const rr = await r2.start({
|
||||
inputData: { quoteId: 'Q1', instanceSeq: 99, reopenedBy: null, prevRunId: null },
|
||||
});
|
||||
console.log(' 对已挂起的 run 再 start() → status =', rr.status, 'error=', JSON.stringify((rr as any).error ?? null));
|
||||
const st2 = await wf.getWorkflowRunById(runId);
|
||||
console.log(' A4.suspendPayload 变了吗?', JSON.stringify((st2?.steps as any)?.A4?.suspendPayload));
|
||||
} catch (e) {
|
||||
console.log(' 对已挂起的 run 再 start() 抛异常:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
banner('坑 2:run 已经 success 之后再 resume');
|
||||
{
|
||||
const runId = 'done-run-1';
|
||||
const run = await wf.createRun({ runId });
|
||||
await run.start({ inputData: { quoteId: 'Q2', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
|
||||
await drive(run, [
|
||||
[['A4'], { price: 100 }],
|
||||
[['A5'], { verdict: 'pass' }],
|
||||
[['A6-sub', 'A6'], { outcome: 'won' }],
|
||||
]);
|
||||
console.log('status =', (await wf.getWorkflowRunById(runId))?.status);
|
||||
try {
|
||||
const r = await run.resume({ step: ['A6-sub', 'A6'], resumeData: { outcome: 'won' } });
|
||||
console.log('已完成的 run 再 resume → status =', r.status, JSON.stringify((r as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('已完成的 run 再 resume 抛异常:', (e as Error).message);
|
||||
}
|
||||
console.log('之后 status 还是 =', (await wf.getWorkflowRunById(runId))?.status, ' ← 老 run 没被打坏');
|
||||
}
|
||||
|
||||
banner('坑 3:重复 resume 同一个 step(任务回调重复到达)');
|
||||
{
|
||||
const runId = 'dupresume-1';
|
||||
const run = await wf.createRun({ runId });
|
||||
await run.start({ inputData: { quoteId: 'Q3', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
|
||||
await run.resume({ step: ['A4'], resumeData: { price: 100 } });
|
||||
try {
|
||||
const r = await run.resume({ step: ['A4'], resumeData: { price: 100 } });
|
||||
console.log('第二次 resume A4 → status =', r.status, 'error =', JSON.stringify((r as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('第二次 resume A4 抛异常:', (e as Error).message);
|
||||
}
|
||||
console.log('run 状态 =', (await wf.getWorkflowRunById(runId))?.status, ' ← 与路线 A 一样会被打成 failed');
|
||||
}
|
||||
|
||||
banner('坑 4:回边目标不是入口节点时,新 run 能不能「从中间开始」?试 run.timeTravel()');
|
||||
{
|
||||
const runId = 'tt-1';
|
||||
const run = await wf.createRun({ runId });
|
||||
await run.start({ inputData: { quoteId: 'Q4', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
|
||||
await run.resume({ step: ['A4'], resumeData: { price: 100 } });
|
||||
await run.resume({ step: ['A5'], resumeData: { verdict: 'reject' } });
|
||||
const st = await wf.getWorkflowRunById(runId);
|
||||
console.log('E10 回边出口跑完,run status =', st?.status, 'result =', JSON.stringify(st?.result));
|
||||
|
||||
// 用 timeTravel 在同一个 run 里回到 A5(模拟「回边目标是中间节点」)
|
||||
try {
|
||||
const r = await run.timeTravel({
|
||||
step: 'A5',
|
||||
inputData: {
|
||||
quoteId: 'Q4',
|
||||
instanceSeq: 2,
|
||||
reopenedBy: 'E10',
|
||||
prevRunId: null,
|
||||
price: 100,
|
||||
verdict: null,
|
||||
finalOutcome: null,
|
||||
},
|
||||
});
|
||||
console.log('timeTravel(step=A5) → status =', r.status);
|
||||
const st2 = await wf.getWorkflowRunById(runId);
|
||||
console.log(' A5.suspendPayload =', JSON.stringify((st2?.steps as any)?.A5?.suspendPayload));
|
||||
console.log(' A4 的记录还在吗? ', JSON.stringify((st2?.steps as any)?.A4?.output));
|
||||
console.log(' 第一次跑的 result 还在吗?', JSON.stringify(st2?.result));
|
||||
} catch (e) {
|
||||
console.log('timeTravel 抛异常:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
banner('坑 5:旧 run 的快照会不会被新 run 影响');
|
||||
{
|
||||
const q = 'Q5';
|
||||
const r1 = await wf.createRun({ runId: `${q}-inst-1`, resourceId: q });
|
||||
await r1.start({ inputData: { quoteId: q, instanceSeq: 1, reopenedBy: null, prevRunId: null } });
|
||||
await r1.resume({ step: ['A4'], resumeData: { price: 100 } });
|
||||
await r1.resume({ step: ['A5'], resumeData: { verdict: 'reject' } });
|
||||
|
||||
const r2 = await wf.createRun({ runId: `${q}-inst-2`, resourceId: q });
|
||||
await r2.start({ inputData: { quoteId: q, instanceSeq: 2, reopenedBy: 'E10', prevRunId: `${q}-inst-1` } });
|
||||
await r2.resume({ step: ['A4'], resumeData: { price: 90 } });
|
||||
await r2.resume({ step: ['A5'], resumeData: { verdict: 'pass' } });
|
||||
await r2.resume({ step: ['A6-sub', 'A6'], resumeData: { outcome: 'won' } });
|
||||
|
||||
for (const id of [`${q}-inst-1`, `${q}-inst-2`]) {
|
||||
const s = await snapOf('route-b', id);
|
||||
console.log(
|
||||
`${id}: status=${s?.status} A4.taskId=${s?.context?.A4?.suspendPayload?.taskId} A4.price=${s?.context?.A4?.output?.price} result=${JSON.stringify(s?.result)}`,
|
||||
);
|
||||
}
|
||||
const runs = await wf.listWorkflowRuns({ resourceId: q });
|
||||
console.log('listWorkflowRuns({resourceId}) →', runs.total, JSON.stringify(runs.runs.map((r: any) => r.runId)));
|
||||
_internals.getLedger().dump('probe-04');
|
||||
}
|
||||
Reference in New Issue
Block a user