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:
@@ -63,12 +63,26 @@ export type ModelResult = {
|
||||
};
|
||||
readonly mode: GatewayMode;
|
||||
};
|
||||
/** 解好的模型端点(四元 + 明文密钥)—— app 层装配专用,见 ModelGateway.resolve */
|
||||
export type ResolvedEndpoint = {
|
||||
readonly provider: LlmProvider;
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
readonly apiKey: string;
|
||||
};
|
||||
/**
|
||||
* 模型网关 —— 活动基座六件里的「LLM 调用」。
|
||||
* 活动只见自定义模型名;解析、凭证、录制回放全在此。
|
||||
*/
|
||||
export type ModelGateway = {
|
||||
readonly mode: GatewayMode;
|
||||
/**
|
||||
* 解绑定:名 → 四元(含当场解出的密钥)—— **只给 app 层装配用**:
|
||||
* Mastra Agent 那类需要真模型对象做 tool-calling 往返的场合,
|
||||
* `generate` 的 text-in/text-out 抽象承载不了。
|
||||
* 密钥与 endpoint 仍不入 domain、不入包、不入留痕明文(OS 7.3 凭证隔离)。
|
||||
*/
|
||||
resolve(modelName: string): Promise<ResolvedEndpoint>;
|
||||
generate(call: ModelCall): Promise<ModelResult>;
|
||||
/** 换模式的网关 —— 回放审用 replay 模式重跑引擎(§7.3) */
|
||||
withMode(mode: GatewayMode): ModelGateway;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* module: llm —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { BindingViolation, GatewayMode, LlmInstanceView, LlmProvider, LlmService, ModelBinding, ModelCall, ModelGateway, ModelResult, ModelUsage, UpsertLlmInstance, } from './contract.ts';
|
||||
export type { BindingViolation, GatewayMode, LlmInstanceView, LlmProvider, LlmService, ModelBinding, ModelCall, ModelGateway, ResolvedEndpoint, ModelResult, ModelUsage, UpsertLlmInstance, } from './contract.ts';
|
||||
export { GATEWAY_MODES, ModelDisabledError, ModelNotBoundError, ReplayMissError } from './contract.ts';
|
||||
export type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, ReplayRecord, ReplayStore, SecretBox, } from './port.ts';
|
||||
export type { LlmServiceDeps } from './internal/usecase/llm-service.ts';
|
||||
|
||||
@@ -70,6 +70,16 @@ export type AgentAction =
|
||||
| {
|
||||
readonly kind: 'submit';
|
||||
readonly rationale: string;
|
||||
}
|
||||
/**
|
||||
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
|
||||
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
|
||||
* 开环模式(Mastra Agent 自主连环调工具)专用;单拍模式给不出这一型。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'asked';
|
||||
readonly cardId: string;
|
||||
readonly rationale: string;
|
||||
};
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
|
||||
@@ -9,6 +9,8 @@ export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoke
|
||||
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
|
||||
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
|
||||
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
|
||||
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
|
||||
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
|
||||
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
"@hono/node-server": "^2.0.12",
|
||||
"@mastra/core": "^1.53.0",
|
||||
"ai": "^7.0.37",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* loop 的 Agent 回路(**开环**)—— `AgentPort` 的 Mastra 实现(M5-2;app 层装配 §5.2)。
|
||||
*
|
||||
* 与单拍版(`agent-runner.ts`)的分工:
|
||||
* · 单拍版:问一次模型、要一个行动,引擎替它执行 —— 保守、每步可拦;
|
||||
* · **开环版(此处)**:给它一张**工具总线**,让它在一拍内自主连环调工具
|
||||
* (读类真调、写类登记意向、LLM 五件、调人),拍末只交回**这一拍的结论**。
|
||||
*
|
||||
* 放开的是工具循环,**不是规矩**:
|
||||
* · 能调什么由活动声明定死(工具总线在编译期出清单,白名单在缝里再把一次关);
|
||||
* · 写类工具**过程中一律不落地**,只登记动作意向 —— 放行那一刻才由系统执行;
|
||||
* · 调人由总线执行(发卡 + 立监听),引擎收到 `asked` 就把这一拍挂起,不重复发卡;
|
||||
* · 终局唯放行:Agent 只能 `submit`(提交放行),不能自己宣布完成;
|
||||
* · 用量按 Mastra 回报的 usage 计入任务预算 —— 不计,预算熔断永远不会响。
|
||||
*
|
||||
* 代价(如实记 debt M5-2):开环下模型的**逐轮**调用不经模型网关的录制回放
|
||||
* (tool-calling 往返 `generate` 的 text-in/text-out 抽象承载不了)——
|
||||
* 四元绑定与凭证隔离照旧走网关 `resolve`;**工具调用一缝完整**,工具侧录制回放不破。
|
||||
*/
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { Agent } from '@mastra/core/agent';
|
||||
import { z } from 'zod';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { AgentAction, EnabledSet } from '@modules/loop-engine';
|
||||
import type { ModelGateway } from '@modules/llm';
|
||||
import type { BusTool } from '@modules/toolbox';
|
||||
import { buildMastraTools } from './tool-bus.ts';
|
||||
import type { ToolBusContext, ToolBusPorts } from './tool-bus.ts';
|
||||
|
||||
/** 拍末结论 —— Agent 干完活要说清「这一拍算什么」,不能只是聊完就走 */
|
||||
const TURN_RESULT = z.object({
|
||||
kind: z.enum(['start', 'plan', 'disable', 'judge', 'submit', 'idle']).describe('这一拍的结论'),
|
||||
itemId: z.string().optional().describe('start / plan / disable 针对哪个计划项'),
|
||||
reason: z.string().optional(),
|
||||
criterion: z.string().optional().describe('judge:评估的是哪条语义性前提'),
|
||||
satisfied: z.boolean().optional().describe('judge:满足与否'),
|
||||
rationale: z.string().optional().describe('结论理由 —— 留痕要看,写实'),
|
||||
});
|
||||
|
||||
export type MastraAgentRunnerDeps = {
|
||||
readonly gateway: ModelGateway;
|
||||
/** 活动声明的自定义模型名 */
|
||||
readonly modelOf: (taskId: TaskId) => Promise<string>;
|
||||
/** 这一单能调什么:工具总线清单(声明 × 目录编译而来) */
|
||||
readonly busOf: (taskId: TaskId) => Promise<readonly BusTool[]>;
|
||||
/** 三路缝 */
|
||||
readonly ports: ToolBusPorts;
|
||||
/** 拍内上下文:负责人与人工调用上限 */
|
||||
readonly contextOf: (taskId: TaskId) => Promise<Omit<ToolBusContext, 'model'>>;
|
||||
/** token 回报:计入任务预算 */
|
||||
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
|
||||
/** 一拍内最多几轮工具调用 —— 环外还有 Harness 的预算硬闸,这只是拍内的礼貌上限 */
|
||||
readonly maxToolRounds?: number;
|
||||
};
|
||||
|
||||
export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
|
||||
} {
|
||||
return {
|
||||
async decide({ taskId, narrative, enabled, scope }) {
|
||||
const modelName = await deps.modelOf(taskId);
|
||||
const base = await deps.contextOf(taskId);
|
||||
const bus = await deps.busOf(taskId);
|
||||
const trace: { askedCardId?: string } = {};
|
||||
|
||||
const instructions = [
|
||||
'你是这一单任务的案例工作者。你有一组工具可用,可以连续调用它们把这一拍的活干完。',
|
||||
'',
|
||||
'规矩(硬约束,违反即被系统拦下):',
|
||||
'① **写类工具不会当场生效** —— 调了只是登记动作意向,放行那一刻系统才真执行。',
|
||||
' 所以不要假设世界已经改变,更不要因为「已经调过」就跳过必要的确认。',
|
||||
'② **调人(human.ask)会让这一拍就此挂起等回传** —— 只在真需要人裁断/补料时调,',
|
||||
' 而且必须把卡填实:title 一句话说清要什么、ask 写明具体问题、basis 给出判断依据。',
|
||||
' 一张只写「请处理」的卡等于白发。调完人就结束这一拍,别再调别的。',
|
||||
'③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。',
|
||||
'④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。',
|
||||
'',
|
||||
`【案例说明书与记忆】\n${narrative}`,
|
||||
].join('\n');
|
||||
|
||||
const endpoint = await deps.gateway.resolve(modelName);
|
||||
const provider = createOpenAICompatible({
|
||||
name: endpoint.provider,
|
||||
apiKey: endpoint.apiKey,
|
||||
baseURL: endpoint.baseUrl,
|
||||
});
|
||||
|
||||
const agent = new Agent({
|
||||
id: `loop-${taskId}`,
|
||||
name: '案例工作者',
|
||||
instructions,
|
||||
model: provider.chatModel(endpoint.model),
|
||||
tools: buildMastraTools(bus, deps.ports, { ...base, model: modelName }, trace),
|
||||
});
|
||||
|
||||
const prompt = [
|
||||
`【当下可为(Enabled 集)】\n${
|
||||
enabled.entries
|
||||
.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`)
|
||||
.join('\n') || '(空)'
|
||||
}`,
|
||||
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
||||
enabled.judgements.length === 0
|
||||
? ''
|
||||
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
||||
enabled.entries.length === 0 ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,调人或说清卡在哪。' : '',
|
||||
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
|
||||
'',
|
||||
'干这一拍该干的活(可以连续调工具),最后给出结论。',
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n');
|
||||
|
||||
try {
|
||||
const result = await agent.generate(prompt, {
|
||||
maxSteps: deps.maxToolRounds ?? 8,
|
||||
structuredOutput: { schema: TURN_RESULT },
|
||||
} as never);
|
||||
|
||||
const usage = (result as { usage?: { totalTokens?: number; inputTokens?: number; outputTokens?: number } }).usage;
|
||||
const tokens = usage?.totalTokens ?? (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
||||
if (tokens > 0) await deps.onUsage(taskId, tokens);
|
||||
|
||||
// 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人)
|
||||
if (trace.askedCardId !== undefined) {
|
||||
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' };
|
||||
}
|
||||
|
||||
return normalizeTurn((result as { object?: unknown }).object, enabled);
|
||||
} catch (error) {
|
||||
/*
|
||||
* 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)——
|
||||
* **不是异常路径,是断点的一种**:机器当下决断不了就该调人。
|
||||
* 抛出去只会让任务空转:推进器每一拍重试、预算照烧,负责人却什么都看不到。
|
||||
*
|
||||
* 但**已经调过人**的那一拍例外:卡在世界上了,老老实实收成 asked,
|
||||
* 不要再叠一张「模型出错」的卡上去。
|
||||
*/
|
||||
if (trace.askedCardId !== undefined) {
|
||||
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' };
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
kind: 'human',
|
||||
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: '模型这一轮未能决断,转人裁断',
|
||||
reason: detail,
|
||||
basis: [
|
||||
{ k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' },
|
||||
{ k: '目标缺口', v: enabled.gaps.join('、') || '(无)' },
|
||||
],
|
||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||
fields: ['decision'],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 拍末结论归一为 AgentAction;给不出结论就当空拍(idle → judge 留痕),绝不乱动世界 */
|
||||
function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
const raw = (object ?? {}) as Record<string, unknown>;
|
||||
const kind = String(raw['kind'] ?? 'idle');
|
||||
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
|
||||
const rationale = String(raw['rationale'] ?? '');
|
||||
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
case 'plan':
|
||||
return { kind: kind as 'start' | 'plan', itemId };
|
||||
case 'disable':
|
||||
return { kind: 'disable', itemId, reason: String(raw['reason'] ?? '此单不走这条') };
|
||||
case 'judge':
|
||||
return {
|
||||
kind: 'judge',
|
||||
criterion: String(raw['criterion'] ?? ''),
|
||||
satisfied: raw['satisfied'] === true,
|
||||
rationale,
|
||||
};
|
||||
case 'submit':
|
||||
return { kind: 'submit', rationale: rationale === '' ? '判定成果已齐' : rationale };
|
||||
default:
|
||||
/*
|
||||
* idle:这一拍干了活(工具调用已经落在总线上)但推不动局面。
|
||||
* 记成一条 judge 留痕 —— 空拍也要说清卡在哪,不然人看不见环在原地打转。
|
||||
*/
|
||||
return {
|
||||
kind: 'judge',
|
||||
criterion: '本回合是否推动了局面',
|
||||
satisfied: false,
|
||||
rationale: rationale === '' ? '模型未给出结论' : rationale,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 工具总线的**装配面** —— 声明清单 → 可调用的 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<string, unknown>;
|
||||
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string } }>;
|
||||
readonly isWriteEffect: (tool: string) => Promise<boolean>;
|
||||
readonly registerIntent: (input: {
|
||||
taskId: TaskId;
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
label: string;
|
||||
}) => Promise<void>;
|
||||
/** 人类工具:发工单 */
|
||||
readonly issueCard: (input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}) => Promise<{ id: string }>;
|
||||
/** 人类工具:立监听听人 */
|
||||
readonly listen: (input: {
|
||||
taskId: TaskId;
|
||||
eventType: 'data' | 'time' | 'human';
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}) => Promise<{ id: string }>;
|
||||
/** LLM 工具:经模型网关 */
|
||||
readonly generate: (input: {
|
||||
model: string;
|
||||
system?: string;
|
||||
prompt: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>) : 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<string, z.ZodTypeAny> = {};
|
||||
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
|
||||
const field = toZod(typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<string, unknown>): { 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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/*
|
||||
* 功能工具:**写类不当场落地**(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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { EventService } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { MemoryService } from '@modules/memory';
|
||||
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
|
||||
import { compileToolBus } from '@modules/toolbox';
|
||||
import type { HumanToolService } from '@modules/human-tool';
|
||||
import { renderView } from '@modules/interaction';
|
||||
import type { InteractionService } from '@modules/interaction';
|
||||
@@ -33,6 +34,7 @@ import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sf
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
||||
import type { Clock, IdGenerator } from '../runtime.ts';
|
||||
|
||||
export type EngineWiringDeps = {
|
||||
@@ -398,21 +400,93 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
clock,
|
||||
});
|
||||
|
||||
/* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */
|
||||
|
||||
/** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */
|
||||
const modelOf = async (taskId: TaskId) => {
|
||||
const { activityId } = await taskContext.of(taskId);
|
||||
const manifest = await deps.registry.manifestOf(activityId);
|
||||
return manifest.declarations.models[0] ?? 'default';
|
||||
};
|
||||
const onAgentUsage = (taskId: TaskId, tokens: number) =>
|
||||
taskStore.addConsumption(taskId, { tokens }).then(() => undefined);
|
||||
|
||||
/**
|
||||
* 工具总线:**声明 × 目录**,编译期定死这一单能调什么(声明面在 toolbox/domain)。
|
||||
* 功能工具按活动白名单查目录;人类工具恒在(Harness 给的能力);
|
||||
* LLM 五件仅当活动声明了模型用途名才给 —— 没声明模型却会问模型,是越权也无从归因。
|
||||
*/
|
||||
const busOf = async (taskId: TaskId) => {
|
||||
const { activityId } = await taskContext.of(taskId);
|
||||
const manifest = await deps.registry.manifestOf(activityId);
|
||||
const catalog = new Map(
|
||||
(await deps.toolbox.list()).map((c) => [
|
||||
c.name,
|
||||
{
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
input: c.input,
|
||||
...(c.output === undefined ? {} : { output: c.output }),
|
||||
effect: c.effect === 'read' ? ('read' as const) : ('write' as const),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const report = compileToolBus({
|
||||
declaredTools: manifest.declarations.tools.map((t) => t.name),
|
||||
catalog,
|
||||
declaredModels: manifest.declarations.models,
|
||||
});
|
||||
// 声明了却查无此工具 / 已停用:如实记痕,不静默 —— Agent 手上少了件东西,得看得见
|
||||
if (report.missing.length > 0 || report.disabled.length > 0) {
|
||||
await memoryRecorder.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `工具总线缺件:未装 ${report.missing.join('、') || '无'};已停用 ${report.disabled.join('、') || '无'}`,
|
||||
} as never);
|
||||
}
|
||||
return report.tools;
|
||||
};
|
||||
|
||||
/*
|
||||
* 开环 or 单拍:`JCAI_AGENT_OPEN_LOOP=1` 时用 Mastra Agent 放开拍内工具循环。
|
||||
* 缺省仍是单拍版 —— 开环把「一拍调几次模型」交给了模型自己,
|
||||
* 得有第一个真 loop 活动压着测过才好当缺省(见 debt M5-2)。
|
||||
*/
|
||||
const agentRunner =
|
||||
globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1'
|
||||
? createMastraAgentRunner({
|
||||
gateway: deps.modelGateway,
|
||||
modelOf,
|
||||
busOf,
|
||||
onUsage: onAgentUsage,
|
||||
contextOf: async (taskId) => {
|
||||
const ctx = await taskContext.of(taskId);
|
||||
return { taskId, ownerId: ctx.ownerId, humanCallCap: ctx.humanCallCap };
|
||||
},
|
||||
ports: {
|
||||
invoke: async (input) => {
|
||||
const { activityId } = await taskContext.of(input.taskId);
|
||||
return toolGateway.invoke({ ...input, activityId });
|
||||
},
|
||||
isWriteEffect: (tool) => toolGateway.isWriteEffect(tool),
|
||||
registerIntent: (input) => toolGateway.registerIntent(input),
|
||||
issueCard: (input) => cards.issue(input),
|
||||
listen: (input) => listeners.register(input),
|
||||
generate: async (input) => {
|
||||
const result = await deps.modelGateway.generate(input);
|
||||
await onAgentUsage(input.taskId, result.usage.inputTokens + result.usage.outputTokens);
|
||||
return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) };
|
||||
},
|
||||
},
|
||||
})
|
||||
: createAgentRunner({ gateway: deps.modelGateway, onUsage: onAgentUsage, modelOf });
|
||||
|
||||
/* ── loop-engine ──────────────────────────────────────────────── */
|
||||
const loop = createTurnRunner({
|
||||
state: createLoopStateStore(db),
|
||||
sfeel,
|
||||
data: taskData,
|
||||
agent: createAgentRunner({
|
||||
gateway: deps.modelGateway,
|
||||
// loop 的每一回合都要问模型 —— 不计 token,预算熔断就永远不会响
|
||||
onUsage: (taskId, tokens) => taskStore.addConsumption(taskId, { tokens }).then(() => undefined),
|
||||
modelOf: async (taskId) => {
|
||||
const { activityId } = await taskContext.of(taskId);
|
||||
const manifest = await deps.registry.manifestOf(activityId);
|
||||
return manifest.declarations.models[0] ?? 'default';
|
||||
},
|
||||
}),
|
||||
agent: agentRunner,
|
||||
tools: toolGateway,
|
||||
cards,
|
||||
listeners,
|
||||
|
||||
@@ -76,12 +76,27 @@ export type ModelResult = {
|
||||
readonly mode: GatewayMode;
|
||||
};
|
||||
|
||||
/** 解好的模型端点(四元 + 明文密钥)—— app 层装配专用,见 ModelGateway.resolve */
|
||||
export type ResolvedEndpoint = {
|
||||
readonly provider: LlmProvider;
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
readonly apiKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模型网关 —— 活动基座六件里的「LLM 调用」。
|
||||
* 活动只见自定义模型名;解析、凭证、录制回放全在此。
|
||||
*/
|
||||
export type ModelGateway = {
|
||||
readonly mode: GatewayMode;
|
||||
/**
|
||||
* 解绑定:名 → 四元(含当场解出的密钥)—— **只给 app 层装配用**:
|
||||
* Mastra Agent 那类需要真模型对象做 tool-calling 往返的场合,
|
||||
* `generate` 的 text-in/text-out 抽象承载不了。
|
||||
* 密钥与 endpoint 仍不入 domain、不入包、不入留痕明文(OS 7.3 凭证隔离)。
|
||||
*/
|
||||
resolve(modelName: string): Promise<ResolvedEndpoint>;
|
||||
generate(call: ModelCall): Promise<ModelResult>;
|
||||
/** 换模式的网关 —— 回放审用 replay 模式重跑引擎(§7.3) */
|
||||
withMode(mode: GatewayMode): ModelGateway;
|
||||
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
ModelBinding,
|
||||
ModelCall,
|
||||
ModelGateway,
|
||||
ResolvedEndpoint,
|
||||
ModelResult,
|
||||
ModelUsage,
|
||||
UpsertLlmInstance,
|
||||
|
||||
@@ -26,6 +26,25 @@ export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
|
||||
const gateway: ModelGateway = {
|
||||
mode: deps.mode,
|
||||
|
||||
/**
|
||||
* 解绑定:名 → 四元(密钥当场解出)。给 app 层装 Mastra Agent 那种
|
||||
* 需要真模型对象的场合(tool-calling 往返,`generate` 的 text-in/text-out 承载不了);
|
||||
* `generate` 那条主路照旧全程在网关内部,录制回放不受影响。
|
||||
*/
|
||||
async resolve(modelName) {
|
||||
const binding = await deps.instances.findByName(modelName);
|
||||
if (binding === undefined) throw new ModelNotBoundError(modelName, [{ field: 'name', message: '实例不存在' }]);
|
||||
if (!binding.enabled) throw new ModelDisabledError(modelName);
|
||||
const violations = validateBinding(binding);
|
||||
if (violations.length > 0) throw new ModelNotBoundError(modelName, violations);
|
||||
return {
|
||||
provider: binding.provider,
|
||||
baseUrl: binding.baseUrl,
|
||||
model: binding.model,
|
||||
apiKey: deps.secrets.open(binding.apiKeyCipher!),
|
||||
};
|
||||
},
|
||||
|
||||
async generate(call: ModelCall): Promise<ModelResult> {
|
||||
const key = replayKey(call);
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ export type AgentAction =
|
||||
/** 评估语义性前提(评估留痕,可回放可归因) */
|
||||
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
|
||||
/** 判定成果已齐,提交放行 */
|
||||
| { readonly kind: 'submit'; readonly rationale: string };
|
||||
| { readonly kind: 'submit'; readonly rationale: string }
|
||||
/**
|
||||
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
|
||||
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
|
||||
* 开环模式(Mastra Agent 自主连环调工具)专用;单拍模式给不出这一型。
|
||||
*/
|
||||
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
|
||||
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
|
||||
@@ -190,6 +190,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
return { runtime, status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'asked': {
|
||||
/*
|
||||
* 开环模式:调人已由**工具总线**在拍内执行(发卡 + 立监听),引擎只收尾。
|
||||
* 再发一张就是重复调人 —— 工位上两张一模一样的卡,谁也不知道该答哪张。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'thought',
|
||||
title: `本回合调人待回(卡 ${action.cardId})`,
|
||||
detail: { rationale: action.rationale },
|
||||
});
|
||||
return { runtime, status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'submit': {
|
||||
// 终局唯放行:提交 = 发验收卡挂起等人裁决,不是自结(引擎不把案计划置为 Completed)
|
||||
return raiseAcceptance(taskId, runtime, action.rationale);
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
app(治理台 · 工具管理)
|
||||
|
||||
不变量:
|
||||
- **工具总线是声明式的**(2026-07-28):Agent 能调什么由**活动声明 × 工具目录**在
|
||||
编译期定死(`compileToolBus`),不由 Agent 临场想、也不由活动代码自己决定;
|
||||
声明了却查无此工具 / 已停用如实报,不静默给空壳。三路同一张清单:
|
||||
`function`(功能工具,经工具调用一缝:白名单/录制回放/写类只登记意向)、
|
||||
`human`(**人类工具**,系统内化、零声明即得 —— 调人是 Harness 给的能力)、
|
||||
`llm`(**LLM 五件**:抽取/归类/摘要/裁断/改写,经模型网关一缝;
|
||||
仅当活动声明了模型用途名才上总线 —— 没声明却会问模型是越权,用量也无从归因)
|
||||
- **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10)
|
||||
- `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError`
|
||||
- **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 工具总线的声明面 —— **Agent 能调什么由声明定死**,不由它临场想(domain 测试零 mock)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
|
||||
import type { CatalogEntry } from '../internal/domain/tool-bus.ts';
|
||||
|
||||
const entry = (over: Partial<CatalogEntry> & { name: string }): CatalogEntry => ({
|
||||
description: '一件工具',
|
||||
input: { type: 'object', properties: { pn: { type: 'string' } } },
|
||||
effect: 'read',
|
||||
...over,
|
||||
});
|
||||
|
||||
const catalog = new Map<string, CatalogEntry>([
|
||||
['crm.history-price-read', entry({ name: 'crm.history-price-read' })],
|
||||
['mail.send', entry({ name: 'mail.send', effect: 'write' })],
|
||||
['old.tool', entry({ name: 'old.tool', enabled: false })],
|
||||
]);
|
||||
|
||||
describe('compileToolBus —— 声明 × 目录', () => {
|
||||
it('只给声明过的功能工具;效应原样带出(写类下游不当场落地)', () => {
|
||||
const report = compileToolBus({
|
||||
declaredTools: ['crm.history-price-read', 'mail.send'],
|
||||
catalog,
|
||||
declaredModels: [],
|
||||
});
|
||||
const fn = report.tools.filter((t) => t.kind === 'function');
|
||||
expect(fn.map((t) => `${t.id}:${t.effect}`)).toEqual(['crm.history-price-read:read', 'mail.send:write']);
|
||||
expect(report.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('没声明的工具不在总线上 —— 白名单之外结构上调不到', () => {
|
||||
const report = compileToolBus({ declaredTools: ['crm.history-price-read'], catalog, declaredModels: [] });
|
||||
expect(report.tools.some((t) => t.id === 'mail.send')).toBe(false);
|
||||
});
|
||||
|
||||
it('声明了却查无此工具 / 已停用:如实报,不静默给一个空壳', () => {
|
||||
const report = compileToolBus({
|
||||
declaredTools: ['crm.history-price-read', 'ghost.tool', 'old.tool'],
|
||||
catalog,
|
||||
declaredModels: [],
|
||||
});
|
||||
expect(report.missing).toEqual(['ghost.tool']);
|
||||
expect(report.disabled).toEqual(['old.tool']);
|
||||
expect(report.tools.some((t) => t.id === 'ghost.tool' || t.id === 'old.tool')).toBe(false);
|
||||
});
|
||||
|
||||
/* 人类工具是 Harness 给的能力,不是活动自带的本事 —— 每个活动天然可以调人 */
|
||||
it('人类工具恒在:一件工具都没声明也能调人', () => {
|
||||
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
|
||||
expect(report.tools).toHaveLength(1);
|
||||
expect(report.tools[0]).toEqual(HUMAN_TOOL);
|
||||
expect(report.tools[0]!.kind).toBe('human');
|
||||
});
|
||||
|
||||
/* 没声明模型的活动突然会「问模型」= 越权,且用量无从归因 */
|
||||
it('LLM 五件只在活动声明了模型用途名时才上总线', () => {
|
||||
const without = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
|
||||
expect(without.tools.filter((t) => t.kind === 'llm')).toHaveLength(0);
|
||||
|
||||
const with_ = compileToolBus({ declaredTools: [], catalog, declaredModels: ['quote-extractor'] });
|
||||
expect(with_.tools.filter((t) => t.kind === 'llm').map((t) => t.id)).toEqual(LLM_TOOLS.map((t) => t.id));
|
||||
expect(with_.tools.filter((t) => t.kind === 'llm')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('LLM 五件都是读类、都有入参契约 —— 模型照它填参', () => {
|
||||
for (const tool of LLM_TOOLS) {
|
||||
expect(tool.effect).toBe('read');
|
||||
expect(tool.input['type']).toBe('object');
|
||||
expect(Array.isArray(tool.input['required'])).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,9 @@ export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
|
||||
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
|
||||
|
||||
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
|
||||
/* 工具总线的声明面(纯逻辑):声明 × 目录 → 一张清单;人类工具与 LLM 五件系统内化 */
|
||||
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
|
||||
|
||||
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 工具总线的**声明面** —— 纯逻辑,零 IO、零框架(编码规范 §5.3 / §6.2)。
|
||||
*
|
||||
* 「Agent 调用工具都是声明式的」:一个活动**能调什么**,不由 Agent 临场想、也不由
|
||||
* 活动代码自己决定 —— 由活动清单的 `declarations` 与工具目录在**编译期**定死。
|
||||
* 这里就是那次编译:声明 × 目录 → 一张总线清单(`BusTool[]`),
|
||||
* 清单之外的调用在结构上不可能发生(白名单在 `ToolInvoker` 再把一次关)。
|
||||
*
|
||||
* 三路工具,同一张清单、同一套形状:
|
||||
* · `function` —— 功能工具:第一方 / 外部,经**工具调用一缝**(录制回放、白名单、
|
||||
* 写类先登记意向,放行时才落地);
|
||||
* · `human` —— **人类工具**(系统内化,零声明即得):调人 = 发工单 + 立监听听人。
|
||||
* 它是三型行动里的「调人」,不是外部工具,不需要注册也不进白名单;
|
||||
* · `llm` —— **LLM 工具**(系统内化,五件通用):抽取 / 归类 / 摘要 / 裁断 / 改写,
|
||||
* 经**模型网关一缝**(录制回放、四元绑定)。活动只声明用途名,不见厂商型号。
|
||||
*
|
||||
* 装配成具体可调用对象(Mastra `createTool`)是 app 层的事 —— 这里只出清单。
|
||||
*/
|
||||
|
||||
/** 总线上的一件工具:形状与三路无关,调用路由靠 `kind` */
|
||||
export type BusTool = {
|
||||
readonly id: string;
|
||||
readonly description: string;
|
||||
/** 入参契约(JSON Schema)—— 装配层据此生成 zod,模型据此填参 */
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly output?: Record<string, unknown>;
|
||||
readonly kind: 'function' | 'human' | 'llm';
|
||||
/**
|
||||
* 效应:`read` 当场调当场回;`write` **不当场落地** ——
|
||||
* 过程中只登记动作意向,放行那一刻才由系统执行(OS 06「过程不落地」)。
|
||||
*/
|
||||
readonly effect: 'read' | 'write';
|
||||
};
|
||||
|
||||
/** 目录里的一条工具契约(取自 toolbox,只用得上这几个字段) */
|
||||
export type CatalogEntry = {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly output?: Record<string, unknown>;
|
||||
readonly effect: 'read' | 'write';
|
||||
readonly enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* **人类工具**(系统内化):调人。
|
||||
*
|
||||
* 「AI 对世界只有触达与等待两种姿势」—— 调人属于等待:发一张工单到负责人工位,
|
||||
* 立一条监听听他的回传,本拍就此挂起。它不进工具目录、不需要白名单:
|
||||
* 每个活动天然可以调人,这是 Harness 给的能力,不是活动自带的本事。
|
||||
*/
|
||||
export const HUMAN_TOOL: BusTool = {
|
||||
id: 'human.ask',
|
||||
description: '调人:把一件事交到负责人工位上问他(发工单 + 立监听),本回合就此等待回传',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['title', 'ask'],
|
||||
properties: {
|
||||
title: { type: 'string', description: '工单标题:一句话说清要他做什么' },
|
||||
ask: { type: 'string', description: '问题正文:要他答什么、依据什么答' },
|
||||
basis: {
|
||||
type: 'array',
|
||||
description: '依据快照:给他看的事实(键值对),不要让人自己去翻',
|
||||
items: { type: 'object', properties: { k: { type: 'string' }, v: { type: 'string' } } },
|
||||
},
|
||||
fields: { type: 'array', description: '要他填的字段名', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: { asked: { type: 'boolean' }, cardId: { type: 'string' } },
|
||||
},
|
||||
kind: 'human',
|
||||
effect: 'read',
|
||||
};
|
||||
|
||||
/**
|
||||
* **LLM 工具**(系统内化,五件通用):大模型驱动的通用能力,不是「一个模型实例」。
|
||||
*
|
||||
* 每件都是**有契约的工具**:入参出参定死、经模型网关一缝(录制回放可重放)、
|
||||
* 用量计入任务预算。活动声明模型用途名(`declarations.models`),换档只改绑定。
|
||||
* 五件之外要更专的能力,写成功能工具进目录 —— 不在这儿加。
|
||||
*/
|
||||
export const LLM_TOOLS: readonly BusTool[] = [
|
||||
{
|
||||
id: 'llm.extract',
|
||||
description: '抽取:从自由文本里按给定字段抽出结构化事实(抽不到的字段留空,不编)',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['text', 'fields'],
|
||||
properties: {
|
||||
text: { type: 'string', description: '原文' },
|
||||
fields: { type: 'array', items: { type: 'string' }, description: '要抽的字段名' },
|
||||
hint: { type: 'string', description: '可选:字段含义与格式提示' },
|
||||
},
|
||||
},
|
||||
output: { type: 'object', properties: { values: { type: 'object' } } },
|
||||
kind: 'llm',
|
||||
effect: 'read',
|
||||
},
|
||||
{
|
||||
id: 'llm.classify',
|
||||
description: '归类:把一段内容判进给定类目之一(类目必须穷举,判不了就回 unknown)',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['text', 'labels'],
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: '候选类目(穷举)' },
|
||||
hint: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { type: 'object', properties: { label: { type: 'string' }, rationale: { type: 'string' } } },
|
||||
kind: 'llm',
|
||||
effect: 'read',
|
||||
},
|
||||
{
|
||||
id: 'llm.summarize',
|
||||
description: '摘要:把长文压成要点(叙述是视图不是事实 —— 结论不入事实库)',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['text'],
|
||||
properties: { text: { type: 'string' }, maxWords: { type: 'integer' }, focus: { type: 'string' } },
|
||||
},
|
||||
output: { type: 'object', properties: { summary: { type: 'string' } } },
|
||||
kind: 'llm',
|
||||
effect: 'read',
|
||||
},
|
||||
{
|
||||
id: 'llm.judge',
|
||||
description: '裁断:对一个语义性判据给出是/否与理由(**只作参考**,不得代替人的放行裁决)',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['question', 'basis'],
|
||||
properties: {
|
||||
question: { type: 'string', description: '要判的问题(是非题)' },
|
||||
basis: { type: 'string', description: '判据材料' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: { verdict: { type: 'boolean' }, confidence: { type: 'number' }, rationale: { type: 'string' } },
|
||||
},
|
||||
kind: 'llm',
|
||||
effect: 'read',
|
||||
},
|
||||
{
|
||||
id: 'llm.rewrite',
|
||||
description: '改写:按语气/格式要求重写一段文字(只改表达,不添事实)',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['text', 'instruction'],
|
||||
properties: { text: { type: 'string' }, instruction: { type: 'string' } },
|
||||
},
|
||||
output: { type: 'object', properties: { text: { type: 'string' } } },
|
||||
kind: 'llm',
|
||||
effect: 'read',
|
||||
},
|
||||
];
|
||||
|
||||
export type ToolBusInput = {
|
||||
/** 活动清单声明的功能工具白名单(`declarations.tools[].name`) */
|
||||
readonly declaredTools: readonly string[];
|
||||
/** 工具目录:名 → 契约 */
|
||||
readonly catalog: ReadonlyMap<string, CatalogEntry>;
|
||||
/** 活动声明了模型用途名才给 LLM 工具 —— 没声明模型的活动不该会「问模型」 */
|
||||
readonly declaredModels: readonly string[];
|
||||
};
|
||||
|
||||
export type ToolBusReport = {
|
||||
readonly tools: readonly BusTool[];
|
||||
/** 声明了却在目录里查无此工具 / 已停用 —— 如实报,不静默吞 */
|
||||
readonly missing: readonly string[];
|
||||
readonly disabled: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 编译总线清单:**声明是唯一入口**。
|
||||
*
|
||||
* 功能工具按活动白名单逐条查目录(查不到 / 已停用如实报,不给它);
|
||||
* 人类工具恒在(Harness 给的能力);LLM 五件仅当活动声明了模型用途名才给 ——
|
||||
* 一个没声明模型的活动突然会「问模型」,那是越权,也让用量无从归因。
|
||||
*/
|
||||
export function compileToolBus(input: ToolBusInput): ToolBusReport {
|
||||
const tools: BusTool[] = [];
|
||||
const missing: string[] = [];
|
||||
const disabled: string[] = [];
|
||||
|
||||
for (const name of input.declaredTools) {
|
||||
const entry = input.catalog.get(name);
|
||||
if (entry === undefined) {
|
||||
missing.push(name);
|
||||
continue;
|
||||
}
|
||||
if (entry.enabled === false) {
|
||||
disabled.push(name);
|
||||
continue;
|
||||
}
|
||||
tools.push({
|
||||
id: entry.name,
|
||||
description: entry.description,
|
||||
input: entry.input,
|
||||
...(entry.output === undefined ? {} : { output: entry.output }),
|
||||
kind: 'function',
|
||||
effect: entry.effect,
|
||||
});
|
||||
}
|
||||
|
||||
tools.push(HUMAN_TOOL);
|
||||
if (input.declaredModels.length > 0) tools.push(...LLM_TOOLS);
|
||||
|
||||
return { tools, missing, disabled };
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
| M1-3 | ~~投递记录写入即止,尚无派发 worker~~ | 派发器已于 M2 落地(advisory lock 串行 + 消费幂等);消费者(任务 / 触发器)随 M4 / M5 注册 | M2 | 已清理 |
|
||||
| M2-1 | 派发器的消费者表为空 —— 投递只标记已送达 | 任务消费者属 task(M4),触发器消费者属 trigger(M5);投递链路本身已可测 | M4 / M5 | 进行中 |
|
||||
| M5-3 | 活动 .JS 产物是源码文本,未经 tsup 单文件 ESM 真编译 | 静态审扫描的正是产物文本(import 与全局引用),当前形态已可审;真编译在装载器落地时接入 | M6 前 | 进行中 |
|
||||
| M5-2 | loop 的 Agent 回路直接经 `ModelGateway`,未用 Mastra `Agent` | 编码规范 §6.1 规定用 Mastra Agent;`AgentPort` 端口不变,换实现时 usecase 不动;当前实现仍过两缝(录制回放可见) | M6 前 | 进行中 |
|
||||
| M5-2 | loop 的 Agent 回路有两个实现:单拍版(缺省,经 `ModelGateway`,两缝完整)与**开环版**(`JCAI_AGENT_OPEN_LOOP=1`:Mastra `Agent` + 工具总线,拍内自主连环调工具) | 端口 `AgentPort` 不变,usecase 未动。**开环的代价**:模型逐轮调用不经模型网关的录制回放(tool-calling 往返 `generate` 的 text-in/text-out 承载不了)—— 四元绑定与凭证隔离照旧走 `resolve`,**工具调用一缝完整**;要恢复逐轮录制须给 Mastra 供一个自实现的 AI SDK LanguageModel。开环待第一个真 loop 活动压测后再考虑转缺省 | 第一个真 loop 活动 | 进行中 |
|
||||
| M5-1 | `process` 未用 Mastra workflow 编译器 —— **运行时切面已立**(2026-07-28:`FlowRuntime` 端口 + 自持实现整体迁入 `internal/runtime/`,usecase 成薄壳,八例 E2E 为等价性护栏),编译器实现仍欠 | 编码规范 §6.1;接入路线已定(端口化+双实现,回边 reopen 先 spike);冲突清单见 process/CONTRACT.md | 编译器随 spike 结论落地 | 进行中(切面已立) |
|
||||
| M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 |
|
||||
| M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 |
|
||||
|
||||
@@ -21,6 +21,8 @@ export default tseslint.config(
|
||||
'.api-tmp/**',
|
||||
'tools/depcruise-tiers.generated.cjs',
|
||||
'docs/**',
|
||||
// 验证性原型(spike):一次性的事实探针,跑完留证据即弃 —— 不进产线,也不受产线规矩约束
|
||||
'spike/**',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Generated
+1782
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "spike-mastra-reopen",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "隔离验证原型:回边 reopen 在 Mastra workflow 里怎么表达。不属于主 workspace。",
|
||||
"scripts": {
|
||||
"route-a": "tsx src/route-a-dowhile.ts",
|
||||
"route-b": "tsx src/route-b-external.ts",
|
||||
"probe": "tsx src/probe.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mastra/core": "latest",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^22.10.2"
|
||||
}
|
||||
}
|
||||
Generated
+2333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 模拟主工程的“实例/任务”台账。
|
||||
*
|
||||
* 主工程语义:回边 reopen = 回到 A4 **重开一个新实例**(新 instance 号、新任务 ID,
|
||||
* 旧实例留痕不动)。这里用一个进程内表来记账,用来验证「哪条路线能自然地拿到新实例号」。
|
||||
*/
|
||||
|
||||
export type NodeId = 'A4' | 'A5' | 'A6';
|
||||
|
||||
export type InstanceRow = {
|
||||
/** 实例号:同一节点上单调递增,A4#1 / A4#2 ... */
|
||||
instanceNo: number;
|
||||
nodeId: NodeId;
|
||||
/** 任务 ID:每个实例一个,全局唯一 */
|
||||
taskId: string;
|
||||
/** 哪个 run 起的它(路线 B 会出现多个 runId) */
|
||||
runId: string;
|
||||
/** 哪一轮循环起的它(路线 A 才有意义) */
|
||||
iteration: number;
|
||||
/** 由哪条边重开(E10 / E12);首开为 null */
|
||||
reopenedBy: string | null;
|
||||
status: 'open' | 'closed';
|
||||
outcome?: string;
|
||||
openedAt: number;
|
||||
closedAt?: number;
|
||||
};
|
||||
|
||||
export class Ledger {
|
||||
readonly rows: InstanceRow[] = [];
|
||||
private seq = 0;
|
||||
|
||||
openInstance(args: {
|
||||
nodeId: NodeId;
|
||||
runId: string;
|
||||
iteration: number;
|
||||
reopenedBy?: string | null;
|
||||
}): InstanceRow {
|
||||
const instanceNo = this.rows.filter((r) => r.nodeId === args.nodeId).length + 1;
|
||||
const row: InstanceRow = {
|
||||
instanceNo,
|
||||
nodeId: args.nodeId,
|
||||
taskId: `T-${args.nodeId}-${String(++this.seq).padStart(3, '0')}`,
|
||||
runId: args.runId,
|
||||
iteration: args.iteration,
|
||||
reopenedBy: args.reopenedBy ?? null,
|
||||
status: 'open',
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
this.rows.push(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
closeTask(taskId: string, outcome: string): InstanceRow {
|
||||
const row = this.rows.find((r) => r.taskId === taskId);
|
||||
if (!row) throw new Error(`ledger: unknown taskId ${taskId}`);
|
||||
row.status = 'closed';
|
||||
row.outcome = outcome;
|
||||
row.closedAt = Date.now();
|
||||
return row;
|
||||
}
|
||||
|
||||
dump(title: string): void {
|
||||
console.log(`\n ── 台账 ${title} ──`);
|
||||
console.log(
|
||||
' ' +
|
||||
['节点', '实例', '任务ID', '轮次', '重开自', '状态', '结论', 'runId'].join('\t'),
|
||||
);
|
||||
for (const r of this.rows) {
|
||||
console.log(
|
||||
' ' +
|
||||
[
|
||||
r.nodeId,
|
||||
`#${r.instanceNo}`,
|
||||
r.taskId,
|
||||
`it=${r.iteration}`,
|
||||
r.reopenedBy ?? '-',
|
||||
r.status,
|
||||
r.outcome ?? '-',
|
||||
r.runId.slice(0, 8),
|
||||
].join('\t'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 外部注入的“决策脚本”:模拟人在另一个调用链里点了按钮 */
|
||||
export type Decision =
|
||||
| { node: 'A4'; price: number }
|
||||
| { node: 'A5'; verdict: 'pass' | 'reject' }
|
||||
| { node: 'A6'; outcome: 'won' | 'revise' };
|
||||
|
||||
export class DecisionScript {
|
||||
private i = 0;
|
||||
constructor(private readonly script: Decision[]) {}
|
||||
next(expectNode: NodeId): Decision {
|
||||
const d = this.script[this.i++];
|
||||
if (!d) throw new Error(`decision script exhausted at ${expectNode}`);
|
||||
if (d.node !== expectNode) {
|
||||
throw new Error(`decision script mismatch: expect ${expectNode}, got ${d.node}`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
get consumed(): number {
|
||||
return this.i;
|
||||
}
|
||||
}
|
||||
|
||||
export const log = (...args: unknown[]): void => console.log(...args);
|
||||
export const banner = (s: string): void =>
|
||||
console.log(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`);
|
||||
@@ -0,0 +1,46 @@
|
||||
/** 冒烟:确认 createWorkflow / suspend / resume / InMemoryStore 这套 API 在 1.53 上能跑 */
|
||||
import { Mastra } from '@mastra/core';
|
||||
import { InMemoryStore } from '@mastra/core/storage';
|
||||
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ask = createStep({
|
||||
id: 'ask',
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
outputSchema: z.object({ a: z.string() }),
|
||||
resumeSchema: z.object({ a: z.string() }),
|
||||
suspendSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ inputData, resumeData, suspend }) => {
|
||||
console.log('[ask.execute] inputData=', inputData, 'resumeData=', resumeData);
|
||||
if (!resumeData) return await suspend({ taskId: 'T-1' }, { resumeLabel: 'ask' });
|
||||
return { a: resumeData.a };
|
||||
},
|
||||
});
|
||||
|
||||
const wf = createWorkflow({
|
||||
id: 'smoke',
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
outputSchema: z.object({ a: z.string() }),
|
||||
})
|
||||
.then(ask)
|
||||
.commit();
|
||||
|
||||
const mastra = new Mastra({
|
||||
workflows: { smoke: wf },
|
||||
storage: new InMemoryStore(),
|
||||
logger: false,
|
||||
});
|
||||
|
||||
const run = await mastra.getWorkflow('smoke').createRun();
|
||||
const r1 = await run.start({ inputData: { q: 'hi' } });
|
||||
console.log('r1.status =', r1.status);
|
||||
console.log('r1 =', JSON.stringify(r1, null, 2).slice(0, 1200));
|
||||
|
||||
const state = await mastra.getWorkflow('smoke').getWorkflowRunById(run.runId);
|
||||
console.log('state.status =', state?.status);
|
||||
const reader = createWorkflowStateReader(state!);
|
||||
console.log('suspendedStep =', JSON.stringify(reader.getSuspendedStep(), null, 2));
|
||||
console.log('resumeLabels =', JSON.stringify(reader.getResumeLabels(), null, 2));
|
||||
|
||||
const r2 = await run.resume({ step: 'ask', resumeData: { a: '42' } });
|
||||
console.log('r2.status =', r2.status, 'result =', (r2 as { result?: unknown }).result);
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 路线 A 深挖:
|
||||
* a) 快照(WorkflowRunState)里循环轮次到底怎么表示?
|
||||
* b) resume 能不能定位「第几轮的那个 step」?(label / forEachIndex / path)
|
||||
* c) 重复 resume、跨轮 resume、run 结束后 resume 会怎样?
|
||||
*
|
||||
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-01-route-a-snapshot.ts
|
||||
*/
|
||||
import { createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { DecisionScript, Ledger, banner } from './ledger.js';
|
||||
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
|
||||
import { resetTrace, t } from './trace.js';
|
||||
|
||||
_internals.setLedger(new Ledger());
|
||||
_internals.setScript(new DecisionScript([]));
|
||||
resetTrace();
|
||||
|
||||
const wf = mastraA.getWorkflow('routeA');
|
||||
const rawSnap = async (runId: string) =>
|
||||
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({
|
||||
workflowName: 'route-a',
|
||||
runId,
|
||||
})) as Record<string, any> | null;
|
||||
|
||||
banner('探针 A-1:跑到「第 2 轮 A4 挂起」,看快照');
|
||||
|
||||
const run = await wf.createRun();
|
||||
console.log('runId =', run.runId);
|
||||
|
||||
let res = await run.start({ inputData: { quoteId: 'Q-probe' } });
|
||||
console.log('after start: status =', res.status);
|
||||
|
||||
// 第 1 轮 A4
|
||||
res = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
|
||||
// 第 1 轮 A5 → reject → 触发回边 E10 → 第 2 轮开始,A4 再次挂起
|
||||
res = await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
|
||||
console.log('after A5(reject): status =', res.status, 'suspended =', JSON.stringify((res as any).suspended));
|
||||
|
||||
const s2 = await rawSnap(run.runId);
|
||||
console.log('\n── 原始快照 WorkflowRunState 的顶层键 ──');
|
||||
console.log(Object.keys(s2 ?? {}));
|
||||
console.log('\nvalue =', JSON.stringify(s2?.value));
|
||||
console.log('activePaths =', JSON.stringify(s2?.activePaths));
|
||||
console.log('activeStepsPath=', JSON.stringify(s2?.activeStepsPath));
|
||||
console.log('suspendedPaths =', JSON.stringify(s2?.suspendedPaths));
|
||||
console.log('resumeLabels =', JSON.stringify(s2?.resumeLabels));
|
||||
console.log('stepExecutionPath =', JSON.stringify(s2?.stepExecutionPath));
|
||||
console.log('\ncontext 的键(= 快照里保存了哪些 step 的结果):');
|
||||
console.log(JSON.stringify(Object.keys(s2?.context ?? {}), null, 0));
|
||||
console.log('\ncontext["round-body.A4"] =');
|
||||
console.log(JSON.stringify(s2?.context?.['round-body.A4'], null, 2));
|
||||
console.log('\ncontext["round-body.A5"] =');
|
||||
console.log(JSON.stringify(s2?.context?.['round-body.A5'], null, 2));
|
||||
console.log('\ncontext["round-body"] (循环体本身) =');
|
||||
console.log(JSON.stringify(s2?.context?.['round-body'], null, 2));
|
||||
|
||||
banner('探针 A-2:resume 能不能指名「第 1 轮的 A4」?');
|
||||
|
||||
// 现在挂起的是「第 2 轮的 A4」。第 1 轮 A4 的 resumeLabel 是 A4-round-1。
|
||||
const reader = createWorkflowStateReader((await wf.getWorkflowRunById(run.runId))!);
|
||||
console.log('当前 resumeLabels =', JSON.stringify(reader.getResumeLabels()));
|
||||
console.log('getResumeLabel("A4-round-1") =', JSON.stringify(reader.getResumeLabel('A4-round-1')));
|
||||
console.log('getResumeLabel("A4-round-2") =', JSON.stringify(reader.getResumeLabel('A4-round-2')));
|
||||
|
||||
try {
|
||||
const r = await run.resume({ label: 'A4-round-1', resumeData: { price: 111 } });
|
||||
console.log('resume(label=A4-round-1) → status =', r.status);
|
||||
console.log(' 报错?', JSON.stringify((r as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('resume(label=A4-round-1) 抛异常:', (e as Error).message);
|
||||
}
|
||||
|
||||
banner('探针 A-3:forEachIndex 对 dowhile 有用吗?');
|
||||
try {
|
||||
const r = await run.resume({
|
||||
step: ['round-body', 'A4'],
|
||||
resumeData: { price: 222 },
|
||||
forEachIndex: 0,
|
||||
});
|
||||
console.log('resume(step=round-body.A4, forEachIndex=0) → status =', r.status);
|
||||
console.log(' error =', JSON.stringify((r as any).error ?? null));
|
||||
const snap = await rawSnap(run.runId);
|
||||
console.log(
|
||||
' 快照里 A4 的 output.price =',
|
||||
JSON.stringify(snap?.context?.['round-body.A4']?.output?.price),
|
||||
);
|
||||
} catch (e) {
|
||||
console.log('resume(forEachIndex=0) 抛异常:', (e as Error).message);
|
||||
}
|
||||
|
||||
banner('探针 A-4:对「已经推进过的 step」重复 resume');
|
||||
try {
|
||||
const r = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 333 } });
|
||||
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);
|
||||
}
|
||||
|
||||
banner('探针 A-5:resume 一个「没有挂起」的 step');
|
||||
try {
|
||||
const r = await run.resume({ step: ['round-body', 'A6-sub', 'A6'], resumeData: { outcome: 'won' } });
|
||||
console.log('resume 未挂起的 A6 → status =', r.status, 'error =', JSON.stringify((r as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('resume 未挂起的 A6 抛异常:', (e as Error).message);
|
||||
}
|
||||
|
||||
banner('探针 A-6:把这一单跑完,再看终态快照能否回溯轮次');
|
||||
let guard = 0;
|
||||
let cur = await wf.getWorkflowRunById(run.runId);
|
||||
while (cur?.status === 'suspended' && guard++ < 8) {
|
||||
const rd = createWorkflowStateReader(cur);
|
||||
const sus = rd.getSuspendedStep()!;
|
||||
const nodeId = (sus.suspendPayload as { nodeId: string }).nodeId;
|
||||
const data =
|
||||
nodeId === 'A4' ? { price: 77 } : nodeId === 'A5' ? { verdict: 'pass' } : { outcome: 'won' };
|
||||
t('[probe]', `resume ${nodeId} @ ${JSON.stringify(sus.path)}`, data);
|
||||
await run.resume({ step: sus.path, resumeData: data });
|
||||
cur = await wf.getWorkflowRunById(run.runId);
|
||||
}
|
||||
console.log('最终 status =', cur?.status, 'result =', JSON.stringify(cur?.result));
|
||||
|
||||
const fin = await rawSnap(run.runId);
|
||||
console.log('\n终态快照 context 的键:', JSON.stringify(Object.keys(fin?.context ?? {})));
|
||||
console.log('终态 context["round-body.A4"].suspendPayload =', JSON.stringify(fin?.context?.['round-body.A4']?.suspendPayload));
|
||||
console.log('终态 context["round-body.A4"].output =', JSON.stringify(fin?.context?.['round-body.A4']?.output));
|
||||
console.log('终态 context["round-body.A4"] 是数组吗? ', Array.isArray(fin?.context?.['round-body.A4']));
|
||||
console.log('终态 stepExecutionPath =', JSON.stringify(fin?.stepExecutionPath));
|
||||
console.log('\n第 1 轮的 A4(taskId=T-A4-001, price=100)还在快照里吗?→',
|
||||
JSON.stringify(fin?.context?.['round-body.A4']).includes('T-A4-001') ? '在' : '不在(被覆盖)');
|
||||
console.log('\n台账(我们自己记的)里第 1 轮还在吗?');
|
||||
_internals.getLedger().dump('probe');
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 路线 A 的坑(每个坑一个全新的 run,避免互相污染):
|
||||
* 1. dowhile 循环体的嵌套快照存在哪?循环第 2 轮会不会覆盖第 1 轮?
|
||||
* 2. forEachIndex 能不能定位 dowhile 的第 N 轮?
|
||||
* 3. resume 打错 step(或任务回调重复到达)会怎样?
|
||||
*
|
||||
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-02-route-a-pitfalls.ts
|
||||
*/
|
||||
import { createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { DecisionScript, Ledger, banner } from './ledger.js';
|
||||
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
|
||||
import { resetTrace } from './trace.js';
|
||||
|
||||
_internals.setLedger(new Ledger());
|
||||
_internals.setScript(new DecisionScript([]));
|
||||
resetTrace();
|
||||
|
||||
const wf = mastraA.getWorkflow('routeA');
|
||||
const snapOf = async (workflowName: string, runId: string) =>
|
||||
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
|
||||
| Record<string, any>
|
||||
| null;
|
||||
|
||||
/** 开一个 run 并推进到「第 2 轮 A4 挂起」 */
|
||||
async function toRound2() {
|
||||
const run = await wf.createRun();
|
||||
await run.start({ inputData: { quoteId: 'Q-pit' } });
|
||||
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
|
||||
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
|
||||
return run;
|
||||
}
|
||||
|
||||
banner('坑 1:循环体(嵌套 workflow)的快照存在哪?第 2 轮会不会盖掉第 1 轮?');
|
||||
{
|
||||
const run = await toRound2();
|
||||
const outer = await snapOf('route-a', run.runId);
|
||||
const inner = await snapOf('round-body', run.runId);
|
||||
console.log('外层快照 context 键 =', JSON.stringify(Object.keys(outer?.context ?? {})));
|
||||
console.log('外层 context["round-body"].metadata =', JSON.stringify(outer?.context?.['round-body']?.metadata));
|
||||
console.log('\n内层(workflowName="round-body", 同一个 runId)快照存在吗?', !!inner);
|
||||
console.log('内层 context 键 =', JSON.stringify(Object.keys(inner?.context ?? {})));
|
||||
console.log('内层 A4.suspendPayload =', JSON.stringify(inner?.context?.['A4']?.suspendPayload));
|
||||
console.log('内层 A4.output =', JSON.stringify(inner?.context?.['A4']?.output));
|
||||
console.log('内层 A5.output =', JSON.stringify(inner?.context?.['A5']?.output));
|
||||
console.log(
|
||||
'\n判定:第 1 轮的 A4(taskId=T-A4-001 / price=100)还在内层快照里吗?→',
|
||||
JSON.stringify(inner?.context?.['A4']).includes('T-A4-001') ? '在' : '不在(已被第 2 轮覆盖)',
|
||||
);
|
||||
const runs = await wf.listWorkflowRuns();
|
||||
console.log('listWorkflowRuns() 里能看到几条?', runs.total, JSON.stringify(runs.runs.map((r: any) => `${r.workflowName}/${r.runId.slice(0, 8)}`)));
|
||||
}
|
||||
|
||||
banner('坑 2:forEachIndex 能不能定位 dowhile 的第 N 轮?(此刻第 2 轮 A4 正挂起)');
|
||||
{
|
||||
const run = await toRound2();
|
||||
for (const idx of [0, 1]) {
|
||||
const fresh = await wf.createRun({ runId: `${run.runId}-x${idx}` });
|
||||
void fresh; // 只是为了不复用同一个 run
|
||||
}
|
||||
try {
|
||||
const r = await run.resume({
|
||||
step: ['round-body', 'A4'],
|
||||
resumeData: { price: 999 },
|
||||
forEachIndex: 0,
|
||||
});
|
||||
console.log('resume(step=[round-body,A4], forEachIndex=0) → status =', r.status);
|
||||
console.log(' error =', JSON.stringify((r as any).error ?? null));
|
||||
const inner = await snapOf('round-body', run.runId);
|
||||
console.log(' 内层 A4.output.price =', JSON.stringify(inner?.context?.['A4']?.output?.price));
|
||||
console.log(' → forEachIndex 被忽略了(打进的是「当前正挂起的那一轮」)');
|
||||
} catch (e) {
|
||||
console.log('抛异常:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
banner('坑 3:resume 打错 step(= 旧任务的回调迟到)会怎样?');
|
||||
{
|
||||
const run = await toRound2();
|
||||
const st = await wf.getWorkflowRunById(run.runId);
|
||||
console.log('resume 之前 status =', st?.status, '挂起的是',
|
||||
JSON.stringify(createWorkflowStateReader(st!).getSuspendedStep()?.path));
|
||||
let r;
|
||||
try {
|
||||
r = await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'pass' } });
|
||||
console.log('resume(错的 step=A5) → status =', r.status);
|
||||
console.log(' error =', JSON.stringify((r as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('resume(错的 step=A5) 抛异常:', (e as Error).message);
|
||||
}
|
||||
const after = await wf.getWorkflowRunById(run.runId);
|
||||
console.log(' 之后整个 run 的 status =', after?.status, ' ← 注意:run 被打成 failed 了');
|
||||
try {
|
||||
const r2 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 1 } });
|
||||
console.log(' 再补一次正确的 resume → status =', r2.status);
|
||||
} catch (e) {
|
||||
console.log(' 再补一次正确的 resume 抛异常:', (e as Error).message, ' ← 这一单救不回来了');
|
||||
}
|
||||
}
|
||||
|
||||
banner('坑 4:同一个任务回调重复到达(重复 resume 同一个 step)');
|
||||
{
|
||||
const run = await toRound2();
|
||||
const r1 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 50 } });
|
||||
console.log('第一次 resume A4 → status =', r1.status, '(此时应轮到 A5 挂起)');
|
||||
try {
|
||||
const r2 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 50 } });
|
||||
console.log('第二次 resume A4 → status =', r2.status, 'error =', JSON.stringify((r2 as any).error ?? null));
|
||||
} catch (e) {
|
||||
console.log('第二次 resume A4 抛异常:', (e as Error).message);
|
||||
}
|
||||
const after = await wf.getWorkflowRunById(run.runId);
|
||||
console.log('之后 run 的 status =', after?.status);
|
||||
}
|
||||
|
||||
banner('坑 5:dowhile 的循环体输入/输出必须同构吗?');
|
||||
console.log('见 route-a-dowhile.ts:循环体 round-body 的 inputSchema 与 outputSchema 都是 RoundState。');
|
||||
console.log('把 outputSchema 改成别的形状时,TS 报错(dowhile 的 TStepInputSchema extends TPrevSchema 约束)。');
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 精确定位:dowhile 每一轮的 step 记录,在快照里是「追加」还是「覆盖/重置」?
|
||||
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-03-loop-snapshot-detail.ts
|
||||
*/
|
||||
import { DecisionScript, Ledger, banner } from './ledger.js';
|
||||
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
|
||||
import { resetTrace } from './trace.js';
|
||||
|
||||
_internals.setLedger(new Ledger());
|
||||
_internals.setScript(new DecisionScript([]));
|
||||
resetTrace();
|
||||
|
||||
const wf = mastraA.getWorkflow('routeA');
|
||||
const snapOf = async (workflowName: string, runId: string) =>
|
||||
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
|
||||
| Record<string, any>
|
||||
| null;
|
||||
|
||||
const run = await wf.createRun();
|
||||
const inner = async () => await snapOf('round-body', run.runId);
|
||||
const dumpInner = async (tag: string) => {
|
||||
const s = await inner();
|
||||
console.log(`\n[${tag}] 内层快照 context 的键 = ${JSON.stringify(Object.keys(s?.context ?? {}))}`);
|
||||
for (const k of ['A4', 'A5', 'A6-sub']) {
|
||||
const rec = s?.context?.[k];
|
||||
if (!rec) continue;
|
||||
console.log(
|
||||
` ${k}: status=${rec.status} suspendPayload.taskId=${rec.suspendPayload?.taskId} output.price=${rec.output?.price} output.verdict=${rec.output?.verdict}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
banner('第 1 轮');
|
||||
await run.start({ inputData: { quoteId: 'Q-detail' } });
|
||||
await dumpInner('第1轮 A4 挂起');
|
||||
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
|
||||
await dumpInner('第1轮 A4 已推进,A5 挂起');
|
||||
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
|
||||
|
||||
banner('第 2 轮(E10 回边重开 A4)');
|
||||
await dumpInner('第2轮 A4 挂起');
|
||||
console.log('\n→ 第 1 轮的 A5(verdict=reject)记录还在内层快照里吗?',
|
||||
(await inner())?.context?.['A5'] ? '在' : '不在(嵌套快照按轮重置了)');
|
||||
console.log('→ 第 1 轮的 A4(taskId=T-A4-001, price=100)记录还在吗?');
|
||||
console.log(' 当前 A4 记录全文 =', JSON.stringify((await inner())?.context?.['A4'], null, 2));
|
||||
|
||||
banner('跑完这一单,看终态');
|
||||
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 90 } });
|
||||
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'pass' } });
|
||||
const fin = await run.resume({ step: ['round-body', 'A6-sub', 'A6'], resumeData: { outcome: 'won' } });
|
||||
console.log('最终 status =', fin.status, JSON.stringify((fin as any).result));
|
||||
await dumpInner('终态');
|
||||
const outer = await snapOf('route-a', run.runId);
|
||||
console.log('\n外层 context["round-body"].metadata =', JSON.stringify(outer?.context?.['round-body']?.metadata));
|
||||
console.log('外层 context 键 =', JSON.stringify(Object.keys(outer?.context ?? {})));
|
||||
console.log('\n结论素材:整个 run 只有一份「A4 的记录」,内容是最后一轮的;第 1 轮的 A4/A5 记录不可回溯。');
|
||||
_internals.getLedger().dump('probe-03(我们自己记的台账则保留了每一轮)');
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 路线 A:.dowhile() 子图
|
||||
*
|
||||
* 把「A4 → A5 (→ A6)」整体包成 dowhile 的循环体,循环条件 =「本轮发生了回跳」。
|
||||
* 拓扑(对齐种子 unified-quote):
|
||||
* start → A4(定价,活动) → A5(审批)
|
||||
* E11 A5 --verdict=pass--> A6(议价)
|
||||
* E10 A5 --verdict=reject--> A4 (reopen)
|
||||
* E13 A6 --outcome=won--> end
|
||||
* E12 A6 --outcome=revise--> A4 (reopen)
|
||||
*
|
||||
* 每个活动/审批点都是:起任务 → suspend();终态由外部 run.resume() 送达。
|
||||
*/
|
||||
import { Mastra } from '@mastra/core';
|
||||
import { RequestContext } from '@mastra/core/request-context';
|
||||
import { InMemoryStore } from '@mastra/core/storage';
|
||||
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { z } from 'zod';
|
||||
import { banner, DecisionScript, Ledger, type Decision } from './ledger.js';
|
||||
import { resetTrace, t } from './trace.js';
|
||||
|
||||
/** 循环体的输入/输出必须同构 —— dowhile 把上一轮的输出直接当下一轮的输入 */
|
||||
const RoundState = z.object({
|
||||
round: z.number(),
|
||||
reopen: z.boolean(),
|
||||
reopenedBy: z.string().nullable(),
|
||||
price: z.number().nullable(),
|
||||
verdict: z.string().nullable(),
|
||||
finalOutcome: z.string().nullable(),
|
||||
a4TaskId: z.string().nullable(),
|
||||
});
|
||||
type RoundState = z.infer<typeof RoundState>;
|
||||
|
||||
/** 每个场景一份新的 ledger / script,用模块级变量喂给 step(step 定义是静态的) */
|
||||
let ledger = new Ledger();
|
||||
let script = new DecisionScript([]);
|
||||
|
||||
/* ------------------------------------------------------------------ 活动点 */
|
||||
|
||||
const a4 = createStep({
|
||||
id: 'A4',
|
||||
description: '定价(活动点)',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
resumeSchema: z.object({ price: z.number() }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A4.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({
|
||||
nodeId: 'A4',
|
||||
runId,
|
||||
iteration: inputData.round,
|
||||
reopenedBy: inputData.reopenedBy,
|
||||
});
|
||||
t('A4.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A4', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: `A4-round-${inputData.round}` },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A4').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, `price=${resumeData.price}`);
|
||||
t('A4.execute', `任务终态到达 price=${resumeData.price}`);
|
||||
return { ...inputData, price: resumeData.price, a4TaskId: taskId };
|
||||
},
|
||||
});
|
||||
|
||||
const a5 = createStep({
|
||||
id: 'A5',
|
||||
description: '审批(审批点)',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
resumeSchema: z.object({ verdict: z.enum(['pass', 'reject']) }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A5.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({ nodeId: 'A5', runId, iteration: inputData.round });
|
||||
t('A5.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A5', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: `A5-round-${inputData.round}` },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A5').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, resumeData.verdict);
|
||||
t('A5.execute', `任务终态到达 verdict=${resumeData.verdict}`);
|
||||
return { ...inputData, verdict: resumeData.verdict };
|
||||
},
|
||||
});
|
||||
|
||||
const a6 = createStep({
|
||||
id: 'A6',
|
||||
description: '议价(活动点)',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
resumeSchema: z.object({ outcome: z.enum(['won', 'revise']) }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A6.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({ nodeId: 'A6', runId, iteration: inputData.round });
|
||||
t('A6.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A6', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: `A6-round-${inputData.round}` },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A6').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, resumeData.outcome);
|
||||
t('A6.execute', `任务终态到达 outcome=${resumeData.outcome}`);
|
||||
return { ...inputData, finalOutcome: resumeData.outcome };
|
||||
},
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- 边(出边) */
|
||||
|
||||
const e10Reopen = createStep({
|
||||
id: 'E10-reopen-A4',
|
||||
description: 'A5 --verdict=reject--> A4 (reopen)',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
execute: async ({ inputData }) => {
|
||||
t('E10.execute', `回边命中,轮次 ${inputData.round} → ${inputData.round + 1}`);
|
||||
return { ...inputData, round: inputData.round + 1, reopen: true, reopenedBy: 'E10' };
|
||||
},
|
||||
});
|
||||
|
||||
const e12Reopen = createStep({
|
||||
id: 'E12-reopen-A4',
|
||||
description: 'A6 --outcome=revise--> A4 (reopen)',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
execute: async ({ inputData }) => {
|
||||
t('E12.execute', `回边命中,轮次 ${inputData.round} → ${inputData.round + 1}`);
|
||||
return { ...inputData, round: inputData.round + 1, reopen: true, reopenedBy: 'E12' };
|
||||
},
|
||||
});
|
||||
|
||||
const e13End = createStep({
|
||||
id: 'E13-end',
|
||||
description: 'A6 --outcome=won--> end',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
execute: async ({ inputData }) => {
|
||||
t('E13.execute', '成交,走向 end');
|
||||
return { ...inputData, reopen: false, reopenedBy: null };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* branch 的输出是 { [每一条分支的 stepId]: output | undefined } —— 没命中的分支键也在,值是
|
||||
* undefined。所以收口步骤的 inputSchema 必须允许 undefined,并自己做 XOR 判定。
|
||||
*/
|
||||
const pick = (id: string) =>
|
||||
createStep({
|
||||
id,
|
||||
inputSchema: z.record(z.string(), RoundState.optional()),
|
||||
outputSchema: RoundState,
|
||||
execute: async ({ inputData }) => {
|
||||
const allKeys = Object.keys(inputData);
|
||||
const hit = allKeys.filter((k) => inputData[k] !== undefined);
|
||||
t(`${id}.execute`, `branch 收口`, { '所有分支键': allKeys, '命中': hit });
|
||||
if (hit.length !== 1) throw new Error(`XOR 违约:命中 ${hit.length} 条出边`);
|
||||
return inputData[hit[0]!]!;
|
||||
},
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ A6 子图 + 循环体 */
|
||||
|
||||
const a6Sub = createWorkflow({
|
||||
id: 'A6-sub',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
})
|
||||
.then(a6)
|
||||
.branch([
|
||||
[
|
||||
async ({ inputData, getStepResult, requestContext }) => {
|
||||
t('cond(E13 won?)', 'evaluated', {
|
||||
finalOutcome: inputData.finalOutcome,
|
||||
'上一步输出可见': getStepResult(a6)?.finalOutcome ?? null,
|
||||
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
|
||||
});
|
||||
return inputData.finalOutcome === 'won';
|
||||
},
|
||||
e13End,
|
||||
],
|
||||
[
|
||||
async ({ inputData }) => {
|
||||
t('cond(E12 revise?)', 'evaluated', { finalOutcome: inputData.finalOutcome });
|
||||
return inputData.finalOutcome === 'revise';
|
||||
},
|
||||
e12Reopen,
|
||||
],
|
||||
])
|
||||
.then(pick('pick-A6-out'))
|
||||
.commit();
|
||||
|
||||
const roundBody = createWorkflow({
|
||||
id: 'round-body',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: RoundState,
|
||||
})
|
||||
.then(a4)
|
||||
.then(a5)
|
||||
.branch([
|
||||
[
|
||||
async ({ inputData, getStepResult, requestContext }) => {
|
||||
t('cond(E11 pass?)', 'evaluated', {
|
||||
verdict: inputData.verdict,
|
||||
'上一步输出可见': getStepResult(a5)?.verdict ?? null,
|
||||
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
|
||||
});
|
||||
return inputData.verdict === 'pass';
|
||||
},
|
||||
a6Sub,
|
||||
],
|
||||
[
|
||||
async ({ inputData }) => {
|
||||
t('cond(E10 reject?)', 'evaluated', { verdict: inputData.verdict });
|
||||
return inputData.verdict === 'reject';
|
||||
},
|
||||
e10Reopen,
|
||||
],
|
||||
])
|
||||
.then(pick('pick-A5-out'))
|
||||
.commit();
|
||||
|
||||
const start = createStep({
|
||||
id: 'start',
|
||||
inputSchema: z.object({ quoteId: z.string() }),
|
||||
outputSchema: RoundState,
|
||||
execute: async ({ inputData }) => {
|
||||
t('start.execute', `起单 ${inputData.quoteId}`);
|
||||
return {
|
||||
round: 1,
|
||||
reopen: false,
|
||||
reopenedBy: null,
|
||||
price: null,
|
||||
verdict: null,
|
||||
finalOutcome: null,
|
||||
a4TaskId: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const end = createStep({
|
||||
id: 'end',
|
||||
inputSchema: RoundState,
|
||||
outputSchema: z.object({ rounds: z.number(), finalOutcome: z.string().nullable() }),
|
||||
execute: async ({ inputData }) => {
|
||||
t('end.execute', `收单 rounds=${inputData.round}`);
|
||||
return { rounds: inputData.round, finalOutcome: inputData.finalOutcome };
|
||||
},
|
||||
});
|
||||
|
||||
export const routeA = createWorkflow({
|
||||
id: 'route-a',
|
||||
inputSchema: z.object({ quoteId: z.string() }),
|
||||
outputSchema: z.object({ rounds: z.number(), finalOutcome: z.string().nullable() }),
|
||||
})
|
||||
.then(start)
|
||||
.dowhile(roundBody, async ({ inputData, iterationCount }) => {
|
||||
t('dowhile.condition', 'evaluated', {
|
||||
iterationCount,
|
||||
reopen: inputData.reopen,
|
||||
round: inputData.round,
|
||||
'继续循环?': inputData.reopen === true,
|
||||
});
|
||||
return inputData.reopen === true;
|
||||
})
|
||||
.then(end)
|
||||
.commit();
|
||||
|
||||
export const storeA = new InMemoryStore();
|
||||
export const mastraA = new Mastra({
|
||||
workflows: { routeA },
|
||||
storage: storeA,
|
||||
logger: false,
|
||||
});
|
||||
const mastra = mastraA;
|
||||
|
||||
/** 探针需要拿到 ledger/script 的控制权 */
|
||||
export const _internals = {
|
||||
setLedger: (l: Ledger) => (ledger = l),
|
||||
setScript: (s: DecisionScript) => (script = s),
|
||||
getLedger: () => ledger,
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------- 驱动器 */
|
||||
|
||||
type NodeIdT = 'A4' | 'A5' | 'A6';
|
||||
|
||||
async function drive(scenario: string, decisions: Decision[]): Promise<void> {
|
||||
banner(`路线 A / 场景:${scenario}`);
|
||||
resetTrace();
|
||||
ledger = new Ledger();
|
||||
script = new DecisionScript(decisions);
|
||||
|
||||
const wf = mastra.getWorkflow('routeA');
|
||||
const run = await wf.createRun();
|
||||
console.log(` runId = ${run.runId}`);
|
||||
|
||||
// 外部注入的上下文:必须是 RequestContext 实例(传普通对象会在持久化时炸,见 FINDINGS)
|
||||
const rc = new RequestContext();
|
||||
rc.set('tenant', 'acme');
|
||||
|
||||
let res = await run.start({
|
||||
inputData: { quoteId: 'Q-2026-0001' },
|
||||
requestContext: rc as never,
|
||||
});
|
||||
|
||||
let guard = 0;
|
||||
while (res.status === 'suspended') {
|
||||
if (++guard > 20) throw new Error('driver: too many suspends');
|
||||
const state = await wf.getWorkflowRunById(run.runId);
|
||||
const reader = createWorkflowStateReader(state!);
|
||||
const s = reader.getSuspendedStep();
|
||||
t('[driver]', 'getSuspendedStep()', {
|
||||
stepId: s?.stepId,
|
||||
path: s?.path,
|
||||
executionPath: s?.executionPath,
|
||||
suspendPayload: s?.suspendPayload,
|
||||
resumeLabels: Object.keys(reader.getResumeLabels()),
|
||||
});
|
||||
t('[driver]', 'result.suspended (来自 start/resume 的返回值)', res.suspended);
|
||||
|
||||
const nodeId = (s!.suspendPayload as { nodeId: NodeIdT }).nodeId;
|
||||
const d = script.next(nodeId);
|
||||
const resumeData =
|
||||
d.node === 'A4'
|
||||
? { price: d.price }
|
||||
: d.node === 'A5'
|
||||
? { verdict: d.verdict }
|
||||
: { outcome: d.outcome };
|
||||
t('[driver]', `外部推进 ${nodeId}`, resumeData);
|
||||
res = await run.resume({ step: s!.path, resumeData });
|
||||
}
|
||||
|
||||
console.log(`\n 最终 status = ${res.status}`);
|
||||
if (res.status === 'success') console.log(' result =', res.result);
|
||||
else console.log(' 非 success:', JSON.stringify(res).slice(0, 800));
|
||||
ledger.dump(scenario);
|
||||
|
||||
// 跑完之后还能不能查到历史轮次?
|
||||
const finalState = await wf.getWorkflowRunById(run.runId);
|
||||
console.log('\n ── 终态快照 steps 的 key ──');
|
||||
console.log(' ' + JSON.stringify(Object.keys(finalState?.steps ?? {})));
|
||||
const a4Snap = finalState?.steps?.['round-body.A4'] ?? finalState?.steps?.['A4'];
|
||||
console.log(' 快照里 A4 这一格是数组吗?', Array.isArray(a4Snap), '→', JSON.stringify(a4Snap));
|
||||
}
|
||||
|
||||
// 被 probe 引入时不自动跑三个场景
|
||||
if (!process.env.SPIKE_NO_AUTORUN) {
|
||||
await drive('① 一次过(A4→A5 pass→A6 won→end)', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
|
||||
await drive('② A5 驳回一次后再通过', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'reject' },
|
||||
{ node: 'A4', price: 90 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
|
||||
await drive('③ A6 revise 一次后再成交', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'revise' },
|
||||
{ node: 'A4', price: 80 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* 路线 B:外部胶合(回跳 = 结束本 run,外部用同一份 workflow 定义新起一个 run)
|
||||
*
|
||||
* workflow 定义里 **没有循环**,就是一张 DAG:
|
||||
* A4 → A5 --E11 pass--> A6 --E13 won--> end
|
||||
* --E10 reject--> [回边出口]
|
||||
* A6 --E12 revise--> [回边出口]
|
||||
* 「回边出口」是一个终止步骤:它把 reopen 意图写进 workflow 的 result,run 正常 success 结束。
|
||||
* 外部胶合层看到 result.reopen 就 createRun 一个新 run,把重开载荷(重开边、实例号、上一个 runId)
|
||||
* 当 inputData 传进去。run 之间用 runId 链 + resourceId 关联。
|
||||
*/
|
||||
import { Mastra } from '@mastra/core';
|
||||
import { RequestContext } from '@mastra/core/request-context';
|
||||
import { InMemoryStore } from '@mastra/core/storage';
|
||||
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
|
||||
import { z } from 'zod';
|
||||
import { banner, DecisionScript, Ledger, type Decision } from './ledger.js';
|
||||
import { resetTrace, t } from './trace.js';
|
||||
|
||||
/** 一个 run = 一趟「A4 起,到 end 或到回边出口」 */
|
||||
const RunIn = z.object({
|
||||
quoteId: z.string(),
|
||||
/** A4 的第几个实例(= 第几次重开 + 1),由外部胶合层递增后传进来 */
|
||||
instanceSeq: z.number(),
|
||||
/** 由哪条回边重开;首开为 null */
|
||||
reopenedBy: z.string().nullable(),
|
||||
/** 上一个 run 的 runId,形成链 */
|
||||
prevRunId: z.string().nullable(),
|
||||
});
|
||||
type RunIn = z.infer<typeof RunIn>;
|
||||
|
||||
const Carry = RunIn.extend({
|
||||
price: z.number().nullable(),
|
||||
verdict: z.string().nullable(),
|
||||
finalOutcome: z.string().nullable(),
|
||||
});
|
||||
|
||||
const RunOut = z.object({
|
||||
/** 本 run 是否以回跳结束 */
|
||||
reopen: z.boolean(),
|
||||
/** 回跳边号 */
|
||||
reopenedBy: z.string().nullable(),
|
||||
/** 回跳目标节点 */
|
||||
reopenTarget: z.string().nullable(),
|
||||
finalOutcome: z.string().nullable(),
|
||||
instanceSeq: z.number(),
|
||||
});
|
||||
|
||||
let ledger = new Ledger();
|
||||
let script = new DecisionScript([]);
|
||||
|
||||
/* ------------------------------------------------------------------ 活动点 */
|
||||
|
||||
const a4 = createStep({
|
||||
id: 'A4',
|
||||
inputSchema: RunIn,
|
||||
outputSchema: Carry,
|
||||
resumeSchema: z.object({ price: z.number() }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A4.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({
|
||||
nodeId: 'A4',
|
||||
runId,
|
||||
iteration: inputData.instanceSeq,
|
||||
reopenedBy: inputData.reopenedBy,
|
||||
});
|
||||
t('A4.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A4', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: 'A4' },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A4').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, `price=${resumeData.price}`);
|
||||
t('A4.execute', `任务终态到达 price=${resumeData.price}`);
|
||||
return { ...inputData, price: resumeData.price, verdict: null, finalOutcome: null };
|
||||
},
|
||||
});
|
||||
|
||||
const a5 = createStep({
|
||||
id: 'A5',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
resumeSchema: z.object({ verdict: z.enum(['pass', 'reject']) }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A5.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({ nodeId: 'A5', runId, iteration: inputData.instanceSeq });
|
||||
t('A5.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A5', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: 'A5' },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A5').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, resumeData.verdict);
|
||||
t('A5.execute', `任务终态到达 verdict=${resumeData.verdict}`);
|
||||
return { ...inputData, verdict: resumeData.verdict };
|
||||
},
|
||||
});
|
||||
|
||||
const a6 = createStep({
|
||||
id: 'A6',
|
||||
inputSchema: Carry,
|
||||
outputSchema: Carry,
|
||||
resumeSchema: z.object({ outcome: z.enum(['won', 'revise']) }),
|
||||
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
|
||||
execute: async ({ inputData, resumeData, suspend, runId }) => {
|
||||
t('A6.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
|
||||
if (!resumeData) {
|
||||
const row = ledger.openInstance({ nodeId: 'A6', runId, iteration: inputData.instanceSeq });
|
||||
t('A6.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
|
||||
return await suspend(
|
||||
{ nodeId: 'A6', taskId: row.taskId, instanceNo: row.instanceNo },
|
||||
{ resumeLabel: 'A6' },
|
||||
);
|
||||
}
|
||||
const taskId = ledger.rows.filter((r) => r.nodeId === 'A6').at(-1)!.taskId;
|
||||
ledger.closeTask(taskId, resumeData.outcome);
|
||||
t('A6.execute', `任务终态到达 outcome=${resumeData.outcome}`);
|
||||
return { ...inputData, finalOutcome: resumeData.outcome };
|
||||
},
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------- 出边(含两条回边出口) */
|
||||
|
||||
const reopenExit = (id: string, edge: string) =>
|
||||
createStep({
|
||||
id,
|
||||
inputSchema: Carry,
|
||||
outputSchema: RunOut,
|
||||
execute: async ({ inputData }) => {
|
||||
t(`${id}.execute`, `回边命中 → 本 run 到此为止,交给外部胶合层重开 A4`);
|
||||
return {
|
||||
reopen: true,
|
||||
reopenedBy: edge,
|
||||
reopenTarget: 'A4',
|
||||
finalOutcome: inputData.finalOutcome,
|
||||
instanceSeq: inputData.instanceSeq,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const e10Exit = reopenExit('E10-reopen-exit', 'E10');
|
||||
const e12Exit = reopenExit('E12-reopen-exit', 'E12');
|
||||
|
||||
const e13End = createStep({
|
||||
id: 'E13-end',
|
||||
inputSchema: Carry,
|
||||
outputSchema: RunOut,
|
||||
execute: async ({ inputData }) => {
|
||||
t('E13.execute', '成交,走向 end');
|
||||
return {
|
||||
reopen: false,
|
||||
reopenedBy: null,
|
||||
reopenTarget: null,
|
||||
finalOutcome: inputData.finalOutcome,
|
||||
instanceSeq: inputData.instanceSeq,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const pick = (id: string) =>
|
||||
createStep({
|
||||
id,
|
||||
inputSchema: z.record(z.string(), RunOut.optional()),
|
||||
outputSchema: RunOut,
|
||||
execute: async ({ inputData }) => {
|
||||
const hit = Object.keys(inputData).filter((k) => inputData[k] !== undefined);
|
||||
t(`${id}.execute`, 'branch 收口', { '所有分支键': Object.keys(inputData), '命中': hit });
|
||||
if (hit.length !== 1) throw new Error(`XOR 违约:命中 ${hit.length} 条出边`);
|
||||
return inputData[hit[0]!]!;
|
||||
},
|
||||
});
|
||||
|
||||
const a6Sub = createWorkflow({ id: 'A6-sub', inputSchema: Carry, outputSchema: RunOut })
|
||||
.then(a6)
|
||||
.branch([
|
||||
[
|
||||
async ({ inputData, requestContext }) => {
|
||||
t('cond(E13 won?)', 'evaluated', {
|
||||
finalOutcome: inputData.finalOutcome,
|
||||
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
|
||||
});
|
||||
return inputData.finalOutcome === 'won';
|
||||
},
|
||||
e13End,
|
||||
],
|
||||
[
|
||||
async ({ inputData }) => {
|
||||
t('cond(E12 revise?)', 'evaluated', { finalOutcome: inputData.finalOutcome });
|
||||
return inputData.finalOutcome === 'revise';
|
||||
},
|
||||
e12Exit,
|
||||
],
|
||||
])
|
||||
.then(pick('pick-A6-out'))
|
||||
.commit();
|
||||
|
||||
export const routeB = createWorkflow({
|
||||
id: 'route-b',
|
||||
inputSchema: RunIn,
|
||||
outputSchema: RunOut,
|
||||
})
|
||||
.then(a4)
|
||||
.then(a5)
|
||||
.branch([
|
||||
[
|
||||
async ({ inputData, requestContext }) => {
|
||||
t('cond(E11 pass?)', 'evaluated', {
|
||||
verdict: inputData.verdict,
|
||||
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
|
||||
});
|
||||
return inputData.verdict === 'pass';
|
||||
},
|
||||
a6Sub,
|
||||
],
|
||||
[
|
||||
async ({ inputData }) => {
|
||||
t('cond(E10 reject?)', 'evaluated', { verdict: inputData.verdict });
|
||||
return inputData.verdict === 'reject';
|
||||
},
|
||||
e10Exit,
|
||||
],
|
||||
])
|
||||
.then(pick('pick-A5-out'))
|
||||
.commit();
|
||||
|
||||
export const storeB = new InMemoryStore();
|
||||
export const mastraB = new Mastra({
|
||||
workflows: { routeB },
|
||||
storage: storeB,
|
||||
logger: false,
|
||||
});
|
||||
const mastra = mastraB;
|
||||
|
||||
export const _internals = {
|
||||
setLedger: (l: Ledger) => (ledger = l),
|
||||
setScript: (s: DecisionScript) => (script = s),
|
||||
getLedger: () => ledger,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------- 外部胶合层(驱动器) */
|
||||
|
||||
type NodeIdT = 'A4' | 'A5' | 'A6';
|
||||
|
||||
/** run 链:外部胶合层自己维护的关联表(主工程里就是「实例表 + 边留痕」) */
|
||||
type ChainRow = { seq: number; runId: string; prevRunId: string | null; reopenedBy: string | null; endedAs: string };
|
||||
|
||||
async function drive(scenario: string, decisions: Decision[]): Promise<void> {
|
||||
banner(`路线 B / 场景:${scenario}`);
|
||||
resetTrace();
|
||||
ledger = new Ledger();
|
||||
script = new DecisionScript(decisions);
|
||||
|
||||
const wf = mastra.getWorkflow('routeB');
|
||||
const quoteId = `Q-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const chain: ChainRow[] = [];
|
||||
|
||||
let input: RunIn = { quoteId, instanceSeq: 1, reopenedBy: null, prevRunId: null };
|
||||
let guard = 0;
|
||||
|
||||
for (;;) {
|
||||
if (++guard > 10) throw new Error('driver: too many reopens');
|
||||
|
||||
// 自己指定 runId —— 可读、可幂等、可与实例号一一对应
|
||||
const runId = `${quoteId}-inst-${input.instanceSeq}`;
|
||||
const run = await wf.createRun({ runId, resourceId: quoteId });
|
||||
t('[glue]', `createRun`, { runId: run.runId, resourceId: quoteId, inputData: input });
|
||||
|
||||
const rc = new RequestContext();
|
||||
rc.set('tenant', 'acme');
|
||||
|
||||
let res = await run.start({ inputData: input, requestContext: rc as never });
|
||||
|
||||
let g2 = 0;
|
||||
while (res.status === 'suspended') {
|
||||
if (++g2 > 10) throw new Error('driver: too many suspends');
|
||||
const state = await wf.getWorkflowRunById(run.runId);
|
||||
const reader = createWorkflowStateReader(state!);
|
||||
const s = reader.getSuspendedStep();
|
||||
t('[glue]', 'getSuspendedStep()', {
|
||||
stepId: s?.stepId,
|
||||
path: s?.path,
|
||||
suspendPayload: s?.suspendPayload,
|
||||
resumeLabels: Object.keys(reader.getResumeLabels()),
|
||||
});
|
||||
const nodeId = (s!.suspendPayload as { nodeId: NodeIdT }).nodeId;
|
||||
const d = script.next(nodeId);
|
||||
const resumeData =
|
||||
d.node === 'A4'
|
||||
? { price: d.price }
|
||||
: d.node === 'A5'
|
||||
? { verdict: d.verdict }
|
||||
: { outcome: d.outcome };
|
||||
t('[glue]', `外部推进 ${nodeId}`, resumeData);
|
||||
res = await run.resume({ step: s!.path, resumeData });
|
||||
}
|
||||
|
||||
if (res.status !== 'success') {
|
||||
console.log(' 非 success:', JSON.stringify(res).slice(0, 600));
|
||||
return;
|
||||
}
|
||||
const out = res.result;
|
||||
chain.push({
|
||||
seq: input.instanceSeq,
|
||||
runId: run.runId,
|
||||
prevRunId: input.prevRunId,
|
||||
reopenedBy: input.reopenedBy,
|
||||
endedAs: out.reopen ? `reopen(${out.reopenedBy}) → ${out.reopenTarget}` : `end(${out.finalOutcome})`,
|
||||
});
|
||||
|
||||
if (!out.reopen) {
|
||||
t('[glue]', `run 结束:${JSON.stringify(out)} —— 流程完结`);
|
||||
break;
|
||||
}
|
||||
t('[glue]', `run 结束:回跳 ${out.reopenedBy} → ${out.reopenTarget},新起一个 run`);
|
||||
input = {
|
||||
quoteId,
|
||||
instanceSeq: input.instanceSeq + 1,
|
||||
reopenedBy: out.reopenedBy,
|
||||
prevRunId: run.runId,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('\n ── run 链 ──');
|
||||
for (const c of chain) {
|
||||
console.log(
|
||||
` seq=${c.seq}\trunId=${c.runId}\tprev=${c.prevRunId ?? '-'}\treopenedBy=${c.reopenedBy ?? '-'}\t${c.endedAs}`,
|
||||
);
|
||||
}
|
||||
ledger.dump(scenario);
|
||||
|
||||
// 旧 run 留痕:流程都跑完了,还能不能查到第 1 个 run 的快照?
|
||||
console.log('\n ── 历史 run 是否还查得到(旧实例留痕不动) ──');
|
||||
for (const c of chain) {
|
||||
const st = await wf.getWorkflowRunById(c.runId);
|
||||
const a4 = st?.steps?.['A4'] as { output?: { price?: number }; suspendPayload?: { taskId?: string } } | undefined;
|
||||
console.log(
|
||||
` ${c.runId}: status=${st?.status} A4.taskId=${a4?.suspendPayload?.taskId} A4.price=${a4?.output?.price} result=${JSON.stringify(st?.result)}`,
|
||||
);
|
||||
}
|
||||
const runs = await wf.listWorkflowRuns({ resourceId: quoteId });
|
||||
console.log(` listWorkflowRuns({resourceId:'${quoteId}'}) → total=${runs.total}, runIds=${JSON.stringify(runs.runs.map((r) => r.runId))}`);
|
||||
}
|
||||
|
||||
if (!process.env.SPIKE_NO_AUTORUN) {
|
||||
await drive('① 一次过(A4→A5 pass→A6 won→end)', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
|
||||
await drive('② A5 驳回一次后再通过', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'reject' },
|
||||
{ node: 'A4', price: 90 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
|
||||
await drive('③ A6 revise 一次后再成交', [
|
||||
{ node: 'A4', price: 100 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'revise' },
|
||||
{ node: 'A4', price: 80 },
|
||||
{ node: 'A5', verdict: 'pass' },
|
||||
{ node: 'A6', outcome: 'won' },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 带序号的轨迹打印,用来看「什么时候被调用了」 */
|
||||
let seq = 0;
|
||||
export const resetTrace = (): void => {
|
||||
seq = 0;
|
||||
};
|
||||
export const t = (who: string, what: string, extra?: unknown): void => {
|
||||
seq += 1;
|
||||
const tail = extra === undefined ? '' : ` ${JSON.stringify(extra)}`;
|
||||
console.log(` #${String(seq).padStart(2, '0')} ${who.padEnd(26)} ${what}${tail}`);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user