Archived
用户定: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
902 lines
37 KiB
TypeScript
902 lines
37 KiB
TypeScript
/**
|
|
* 范式引擎与任务运行时的装配 —— app 是唯一组装层(编码规范 §5.2)。
|
|
*
|
|
* 这里把 M4/M5 的模块用**端口**接起来:跨层协作一律经端口,模块之间不互相认识。
|
|
*/
|
|
import type { Db } from '@shared/db.ts';
|
|
import type { LandedFacts } from '@modules/task';
|
|
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';
|
|
import type { MessageService } from '@modules/message';
|
|
import type { OrgService } from '@modules/org';
|
|
import type { LlmService, ModelGateway } from '@modules/llm';
|
|
import type { ActivityRegistryService } from '@modules/activity-registry';
|
|
import type { SkillEngineService } from '@modules/skill-engine';
|
|
import type { ReleaseService } from '@modules/release';
|
|
|
|
import { createFlowRunner, createFlowStateStore } from '@modules/flow-engine';
|
|
import { createLoopStateStore, createTurnRunner } from '@modules/loop-engine';
|
|
import { createSkillEngineService } from '@modules/skill-engine';
|
|
import { createTaskService, createTaskStore } from '@modules/task';
|
|
import type { TaskService } from '@modules/task';
|
|
import { createProcessService, createProcessStore } from '@modules/process';
|
|
import type { ProcessService } from '@modules/process';
|
|
import { createTriggerService, createTriggerStore } from '@modules/trigger';
|
|
import type { TriggerService } from '@modules/trigger';
|
|
|
|
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
|
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 = {
|
|
readonly db: Db;
|
|
readonly clock: Clock;
|
|
readonly ids: IdGenerator;
|
|
readonly event: EventService;
|
|
readonly ledger: LedgerService;
|
|
readonly memory: MemoryService;
|
|
readonly toolbox: ToolboxService;
|
|
readonly toolInvoker: ToolInvoker;
|
|
readonly humanTool: HumanToolService;
|
|
readonly interaction: InteractionService;
|
|
readonly message: MessageService;
|
|
readonly org: OrgService;
|
|
readonly llm: LlmService;
|
|
readonly modelGateway: ModelGateway;
|
|
readonly registry: ActivityRegistryService;
|
|
readonly skills: SkillEngineService;
|
|
readonly release: ReleaseService;
|
|
readonly schemas: { validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] } };
|
|
};
|
|
|
|
export type EngineWiring = {
|
|
readonly task: TaskService;
|
|
readonly process: ProcessService;
|
|
readonly trigger: TriggerService;
|
|
};
|
|
|
|
function writeLog(line: string): void {
|
|
globalThis.process.stderr.write(`${line}\n`);
|
|
}
|
|
|
|
/** S-FEEL 三件套 —— 求值异常按「不成立」处置,不让流转停摆 */
|
|
const sfeel = {
|
|
condition: (expression: string, scope: Readonly<Record<string, unknown>>): boolean => {
|
|
try {
|
|
return evaluateCondition(expression, scope);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
expression: (expression: string, scope: Readonly<Record<string, unknown>>): unknown => {
|
|
try {
|
|
return evalSfeel(expression, scope);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
unaryTest: (cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean => {
|
|
try {
|
|
return evaluateUnaryTest(cell, value, scope);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
};
|
|
|
|
/** 延后绑定的流程层引用 —— 只用到「任务终态上报」这一件 */
|
|
function createDeferredProcess(): {
|
|
onTaskTerminal: ProcessService['onTaskTerminal'];
|
|
bind(real: ProcessService): void;
|
|
} {
|
|
let real: ProcessService | undefined;
|
|
return {
|
|
onTaskTerminal: async (taskId, terminal, ports) => real?.onTaskTerminal(taskId, terminal, ports),
|
|
bind(next) {
|
|
real = next;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|
const { db, clock, ids } = deps;
|
|
|
|
/* ── 任务上下文:活动 ID / 负责人 / 调人预算 —— 引擎共用 ─────────── */
|
|
const taskStore = createTaskStore(db);
|
|
const taskContext = {
|
|
async of(taskId: TaskId) {
|
|
const task = await taskStore.get(taskId);
|
|
if (task === undefined) throw new Error(`任务不存在:${taskId}`);
|
|
return { activityId: task.activityId, ownerId: task.ownerId, humanCallCap: task.caps.humanCalls };
|
|
},
|
|
};
|
|
|
|
/*
|
|
* 视图流控网关 —— 两个范式共用一份。
|
|
* 按名取模版 → 填数 → 推给前端。模版取自**该任务装载时的资产版本**(经 registry),
|
|
* 不是「当前最新」:在途任务按其启动时版本跑完,呈现也该跟着那一版。
|
|
*/
|
|
const viewGateway = {
|
|
async push({
|
|
taskId,
|
|
activityId,
|
|
template,
|
|
scope,
|
|
}: {
|
|
taskId: TaskId;
|
|
activityId: string;
|
|
template: string;
|
|
scope: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
const manifest = await deps.registry.manifestOf(activityId);
|
|
const found = manifest.assets.viewTemplates.find((t) => t.name === template);
|
|
if (found === undefined) return;
|
|
|
|
/*
|
|
* 视图作用域 = 任务视图 + **本单自己写下的行**。
|
|
* 两者要分得开:任务视图里的 `域.对象` 是正式版 + 我的草稿的合并结果,
|
|
* 拿它画「本单交付」会把别单放行过的记录一并列出来。
|
|
* 本单的行放在 `mine:域.对象` 下 —— 整键,取值不歧义。
|
|
*/
|
|
const own: Record<string, unknown[]> = {};
|
|
for (const draft of await deps.ledger.taskDrafts(taskId)) {
|
|
const key = `mine:${draft.domain}.${draft.object}`;
|
|
(own[key] ??= []).push({ ...draft.key, ...draft.patch });
|
|
}
|
|
await deps.interaction.pushViewHtml(taskId, renderView(found.html, { ...scope, ...own }));
|
|
},
|
|
};
|
|
|
|
/* ── 任务数据:引擎读**任务视图** = 正式版 + 本任务草稿层 ───────── */
|
|
const taskData = {
|
|
/**
|
|
* 任务视图 = 正式版 + 本任务草稿层(AM 4.2),再叠上起单入参。
|
|
*
|
|
* 作用域的**形状要对得上声明的写法**:步声明里写的是裸字段名
|
|
* (`input: { pn: 'pn' }`),不是 `域.对象.行号.字段`。所以这里三层铺开:
|
|
* ① 起单入参在底 —— 契约的 data 是这单的出发事实;
|
|
* ② 各对象**最新一行**的字段名铺到顶层 —— 过程工作区的数据形态即草稿层;
|
|
* ③ `域.对象` 仍保留整表,要整表的照样取得到。
|
|
* 顶层同名以「后写的赢」为准:草稿层是这一单最新的过程事实,盖过起单入参。
|
|
*/
|
|
async view(taskId: TaskId) {
|
|
const task = await taskStore.get(taskId);
|
|
if (task === undefined) throw new Error(`任务不存在:${taskId}`);
|
|
const objects = await deps.ledger.listObjects();
|
|
const scope: Record<string, unknown> = { ...task.data };
|
|
|
|
for (const address of objects) {
|
|
const page = await deps.ledger.taskView(taskId, {
|
|
address,
|
|
where: {},
|
|
orderBy: [],
|
|
limit: 200,
|
|
offset: 0,
|
|
});
|
|
scope[`${address.domain}.${address.object}`] = page.rows;
|
|
}
|
|
|
|
/*
|
|
* 铺到顶层的只能是**这一单自己写下的**事实(草稿层 = 读己之写)。
|
|
* 不能拿「该对象最新一行」来铺:那一行很可能是别的任务放行后的正式版记录,
|
|
* 一铺就把别人的结论当成了自己的输入 —— 打标会照着上一单的情报出结果。
|
|
* 别人的事实仍在 `域.对象` 整表里,要用得显式去取。
|
|
*/
|
|
for (const draft of await deps.ledger.taskDrafts(taskId)) {
|
|
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
|
|
if (value !== null && value !== undefined) scope[field] = value;
|
|
}
|
|
}
|
|
return scope;
|
|
},
|
|
async draftWrite(input: {
|
|
taskId: TaskId;
|
|
domain: string;
|
|
object: string;
|
|
key: Record<string, string | number>;
|
|
patch: Record<string, unknown>;
|
|
}) {
|
|
// 一切过程写入落草稿层 —— 放行前不落事实
|
|
await deps.ledger.draftWrite({
|
|
taskId: input.taskId,
|
|
address: { domain: input.domain, object: input.object },
|
|
key: input.key,
|
|
patch: input.patch,
|
|
});
|
|
},
|
|
};
|
|
|
|
const memoryRecorder = {
|
|
append: async (input: { taskId: TaskId; kind: string; title: string; detail?: Record<string, unknown> }) => {
|
|
await deps.memory.append({
|
|
taskId: input.taskId,
|
|
kind: input.kind as never,
|
|
title: input.title,
|
|
...(input.detail === undefined ? {} : { detail: input.detail }),
|
|
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
|
});
|
|
},
|
|
};
|
|
|
|
const listeners = {
|
|
async register(input: { taskId: TaskId; eventType: 'data' | 'time' | 'human'; keyFilter: Record<string, string>; deadline: string }) {
|
|
const listener = await deps.event.registerListener(input.taskId, {
|
|
origin: 'dynamic',
|
|
eventType: input.eventType,
|
|
keyFilter: input.keyFilter,
|
|
semantic: 'complete-step',
|
|
deadline: input.deadline,
|
|
escalation: ['nudge', 'escalate'],
|
|
});
|
|
return { id: listener.id };
|
|
},
|
|
};
|
|
|
|
const cards = {
|
|
async issue(input: {
|
|
taskId: TaskId;
|
|
ownerId: StaffId;
|
|
humanCallCap: number;
|
|
workOrder: 'execution' | 'acceptance';
|
|
card: Record<string, unknown>;
|
|
}) {
|
|
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
|
|
const title = String(input.card['title'] ?? '请处理');
|
|
|
|
/*
|
|
* 依据快照:优先用活动给的结构化 `basis`(`{k,v}[]`);没给才把卡面其余键铺成依据。
|
|
* 铺的时候要跳过 `ask`(它是问题不是依据)与空值,嵌套对象转 JSON ——
|
|
* 不然人看到的是一行 `[object Object]`,那还不如不给。
|
|
*/
|
|
const declared = input.card['basis'];
|
|
const basis = Array.isArray(declared)
|
|
? declared
|
|
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
|
.map((b) => ({ k: String(b.k), v: String(b.v) }))
|
|
: Object.entries(input.card)
|
|
.filter(([k, v]) => !['title', 'fields', 'ask', 'basis'].includes(k) && v !== null && v !== undefined && v !== '')
|
|
.map(([k, v]) => ({ k, v: typeof v === 'object' ? JSON.stringify(v) : String(v) }));
|
|
/*
|
|
* 卡面声明的字段 → 表单域。执行与验收共用这一份解析:
|
|
* 验收工单从前写死 `fields: []`,可 `acceptanceWorkOrderSchema` 本来就有 `fields` ——
|
|
* 是这里没填,不是设计上不要。放行时人要修订的值(如「这一单到底要不要选型」)
|
|
* 就靠它;答完经 `writeOutput` 落台账,覆盖上游步写的同一列。
|
|
*/
|
|
const declaredFields = Array.isArray(input.card['fields'])
|
|
? (input.card['fields'] as string[]).map((key) => ({ key, label: key, kind: 'text' as const, required: true }))
|
|
: undefined;
|
|
|
|
if (input.workOrder === 'acceptance') {
|
|
/*
|
|
* 选择组上卡:活动契约声明了 selective 出口的,放行必须**恰选一口**(AM 1.2)——
|
|
* 那一口就是这次运行的分支结论,下游异或网关判的正是它。
|
|
* 组不在步的卡面里声明(它是契约的事实),从 manifest 现读。
|
|
*/
|
|
const { activityId } = await taskContext.of(input.taskId);
|
|
const manifest = await deps.registry.manifestOf(activityId);
|
|
const selective = manifest.contract.output.selective[0];
|
|
const issued = await deps.humanTool.issueAcceptance(context, {
|
|
title,
|
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '步序声明' }],
|
|
deliverable: String(input.card['deliverable'] ?? 'deliverable') as never,
|
|
criteria: [{ label: '成果齐备', level: 'L5', pass: false }],
|
|
fields: declaredFields ?? [],
|
|
...(selective === undefined
|
|
? {}
|
|
: {
|
|
portChoice: {
|
|
group: selective.group,
|
|
ports: selective.ports.map((port) => ({ name: port.name, label: port.description })),
|
|
},
|
|
}),
|
|
});
|
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
|
return { id: issued.order.id };
|
|
}
|
|
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }];
|
|
const issued = await deps.humanTool.issueExecution(context, {
|
|
title,
|
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '步序声明' }],
|
|
ask: String(input.card['ask'] ?? input.card['reason'] ?? title),
|
|
fields,
|
|
});
|
|
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
|
return { id: issued.order.id };
|
|
},
|
|
};
|
|
|
|
const toolGateway = {
|
|
invoke: async (input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId; stepNo?: number }) => {
|
|
const result = await deps.toolInvoker.invoke({
|
|
tool: input.tool,
|
|
input: input.input,
|
|
activityId: input.activityId,
|
|
taskId: input.taskId,
|
|
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
|
|
});
|
|
return {
|
|
ok: result.ok,
|
|
...(result.output === undefined ? {} : { output: result.output }),
|
|
...(result.error === undefined ? {} : { error: { message: result.error.message, retryable: result.error.retryable } }),
|
|
};
|
|
},
|
|
isWriteEffect: async (tool: string) => (await deps.toolbox.get(tool))?.effect === 'write',
|
|
registerIntent: async (input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }) => {
|
|
// 写类动作登记为动作意向:入参冻结,放行后由系统执行落地
|
|
await deps.release.registerIntent(input.taskId, {
|
|
id: ids.next(),
|
|
tool: input.tool,
|
|
input: input.input,
|
|
idempotencyKey: `${input.taskId}:${input.tool}:${ids.next()}`,
|
|
label: input.label,
|
|
});
|
|
},
|
|
};
|
|
|
|
/* ── skill-engine ─────────────────────────────────────────────── */
|
|
const skills = createSkillEngineService({
|
|
catalog: {
|
|
skillsOf: async (activityId) => (await deps.registry.manifestOf(activityId)).assets.skills,
|
|
allowlistOf: (activityId) => deps.toolbox.allowlistOf(activityId),
|
|
},
|
|
tools: {
|
|
invoke: (input) =>
|
|
toolGateway.invoke({ ...input, ...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }) }),
|
|
},
|
|
memory: memoryRecorder as never,
|
|
clock,
|
|
});
|
|
void skills;
|
|
|
|
/* ── flow-engine ──────────────────────────────────────────────── */
|
|
const flow = createFlowRunner({
|
|
state: createFlowStateStore(db),
|
|
evaluate: sfeel.condition,
|
|
data: taskData,
|
|
tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) },
|
|
schemas: deps.schemas,
|
|
views: viewGateway,
|
|
llm: {
|
|
async generate(input) {
|
|
// LLM 在 flow 里只以「被调用的工具」出现,不是调度者
|
|
try {
|
|
const result = await deps.modelGateway.generate({
|
|
model: input.model,
|
|
prompt: input.prompt,
|
|
outputSchema: input.outputSchema,
|
|
taskId: input.taskId,
|
|
stepNo: input.stepNo,
|
|
label: 'flow-step',
|
|
});
|
|
// token 计入任务预算 —— 单位成本与预算熔断都靠它,不计就等于没有预算
|
|
await taskStore.addConsumption(input.taskId, {
|
|
tokens: result.usage.inputTokens + result.usage.outputTokens,
|
|
});
|
|
// 解析与校验归引擎(它才知道这一步声明了什么);这里只如实上交结果与原文
|
|
return {
|
|
ok: true,
|
|
...(result.object === undefined ? {} : { object: result.object }),
|
|
text: result.text,
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
},
|
|
},
|
|
cards,
|
|
listeners,
|
|
memory: memoryRecorder as never,
|
|
tasks: taskContext,
|
|
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: agentRunner,
|
|
tools: toolGateway,
|
|
cards,
|
|
listeners,
|
|
views: viewGateway,
|
|
skills: {
|
|
index: (activityId) => deps.skills.index(activityId),
|
|
stamp: (activityId, ids) => deps.skills.stamp(activityId, ids),
|
|
},
|
|
memory: {
|
|
append: memoryRecorder.append as never,
|
|
loadActivityMemory: async (activityId: string, limit: number) =>
|
|
(await deps.memory.loadActivityMemory(activityId, { limit })).map((m) => ({ layer: m.layer, statement: m.statement })),
|
|
// 任务自身的最近轨迹:窗口大小是活动声明的唯一 Harness 记忆配置(manifest.declarations.harness.memoryWindow)
|
|
window: async (taskId) => {
|
|
const { activityId } = await taskContext.of(taskId);
|
|
const size = (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow;
|
|
return (await deps.memory.window(taskId, size)).map((m) => ({ kind: m.kind, title: m.title }));
|
|
},
|
|
},
|
|
tasks: taskContext,
|
|
clock,
|
|
});
|
|
|
|
/**
|
|
* process 晚于 task 构造 —— 延后绑定(与 container 里 `TriggerMatcher` 同一手法)。
|
|
* 未绑定时静默不报:装配尚未完成的那一瞬没有任务会终态。
|
|
*/
|
|
const processRef = createDeferredProcess();
|
|
|
|
/**
|
|
* 任务的输出口载荷。
|
|
*
|
|
* 载荷来自**落地收敛的事实**(`facts`),不是从作用域里再捞一遍:
|
|
* 放行那一刻草稿层已合并并清空,此时再捞只剩起单入参,下游节点会因缺字段起不来。
|
|
* 输出口声明的属性名与台账列名可能不同形(驼峰 / 下划线),两种写法都认。
|
|
*/
|
|
function outputPortsOf(
|
|
manifest: {
|
|
contract: {
|
|
output: {
|
|
mandatory: ReadonlyArray<{ name: string; payload: unknown }>;
|
|
selective?: ReadonlyArray<{ group: string; ports: ReadonlyArray<{ name: string; payload: unknown }> }>;
|
|
};
|
|
};
|
|
},
|
|
landed: LandedFacts,
|
|
): Record<string, Record<string, unknown>> {
|
|
const snake = (name: string): string => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
const ports: Record<string, Record<string, unknown>> = {};
|
|
|
|
// 必交付口与选择组的口都要装配:活动可以只有选择组(如 A6 的成交/有异议/拒绝),
|
|
// 只看必交付口会得到一张空表,下游连分支判据都读不到。
|
|
const declaredPorts = [
|
|
...manifest.contract.output.mandatory,
|
|
...(manifest.contract.output.selective ?? []).flatMap((g) => g.ports),
|
|
];
|
|
|
|
for (const port of declaredPorts) {
|
|
const payload = port.payload as { properties?: Record<string, { type?: string }> };
|
|
const declared = Object.entries(payload.properties ?? {});
|
|
const picked: Record<string, unknown> = {};
|
|
|
|
for (const [field, spec] of declared) {
|
|
// 数组属性 = 这一单落地的那些行(逐行的键 + 值);标量属性 = 铺平后的事实
|
|
if (spec.type === 'array') {
|
|
picked[field] = landed.rows.map((r) => ({ ...r.key, ...r.values }));
|
|
continue;
|
|
}
|
|
const value = landed.flat[field] ?? landed.flat[snake(field)];
|
|
if (value !== undefined) picked[field] = value;
|
|
}
|
|
// 声明之外的落地事实一并带上:边映射也可能直接引用台账列名
|
|
ports[port.name] = { ...landed.flat, ...picked };
|
|
}
|
|
return ports;
|
|
}
|
|
|
|
/* ── task(L6):范式在此汇流,对上层不可区分 ──────────────────── */
|
|
const task = createTaskService({
|
|
store: taskStore,
|
|
activities: {
|
|
async load(activityId) {
|
|
const manifest = await deps.registry.manifestOf(activityId);
|
|
const envelope = await deps.registry.loadEnvelope(activityId);
|
|
return {
|
|
paradigm: manifest.identity.paradigm,
|
|
caps: manifest.contract.resources.budget,
|
|
envelope: { ...envelope },
|
|
inputSchema: manifest.contract.input.data,
|
|
businessModel: manifest.assets.businessModel,
|
|
stallAfter: manifest.declarations.harness.stallAfter,
|
|
};
|
|
},
|
|
},
|
|
schemas: deps.schemas,
|
|
// 范式在此汇流:两个引擎按统一的 ParadigmEngine 契约暴露给 task —— 对上层不可区分(§9)
|
|
engines: {
|
|
loop: {
|
|
load: (taskId, model) => loop.load(taskId, model as never).then(() => undefined),
|
|
advance: async (taskId) => {
|
|
const outcome = await loop.runTurn(taskId);
|
|
return {
|
|
status: outcome.status,
|
|
...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }),
|
|
// 终局唯放行:runTurn 依在途验收的裁决给出 finished 时,released/选口一并带回
|
|
...(outcome.released === undefined ? {} : { released: outcome.released }),
|
|
...(outcome.selectedPorts === undefined ? {} : { selectedPorts: outcome.selectedPorts }),
|
|
};
|
|
},
|
|
// 只记不跑:登记事件/收裁决即返,推进统一归 deliver 随后的 tick —— 预算一个口径
|
|
onEvent: async (taskId, event) => {
|
|
await loop.onEvent(taskId, event);
|
|
return { status: 'running' as const };
|
|
},
|
|
},
|
|
flow: {
|
|
load: (taskId, model) => flow.load(taskId, model as never).then(() => undefined),
|
|
advance: async (taskId) => {
|
|
const outcome = await flow.advance(taskId);
|
|
return {
|
|
status: outcome.status,
|
|
...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }),
|
|
// 谱走完时把放行裁决带上来 —— 完成只出自放行通过
|
|
...(outcome.released === undefined ? {} : { released: outcome.released }),
|
|
...(outcome.selectedPorts === undefined ? {} : { selectedPorts: outcome.selectedPorts }),
|
|
};
|
|
},
|
|
onEvent: async (taskId, event) => {
|
|
const outcome = await flow.resume(taskId, {
|
|
kind: event.kind === 'control' ? 'time' : event.kind,
|
|
ref: event.ref,
|
|
payload: event.payload,
|
|
});
|
|
return { status: outcome.status, ...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }) };
|
|
},
|
|
},
|
|
},
|
|
landing: {
|
|
/** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口(OS 7.3) */
|
|
async land(input) {
|
|
const outcome = await deps.release.land({
|
|
taskId: input.taskId,
|
|
ownerId: input.ownerId,
|
|
humanCallCap: input.humanCallCap,
|
|
selectedPorts: input.selectedPorts,
|
|
});
|
|
return {
|
|
converged: outcome.verdict.kind === 'converged',
|
|
emittedPorts: outcome.emittedPorts,
|
|
ledgerRowsMerged: outcome.ledgerRowsMerged,
|
|
// 输出口载荷 = 已落地的事实:铺平供标量取值,逐行供数组取值
|
|
facts: {
|
|
flat: {
|
|
...outcome.merged.reduce<Record<string, unknown>>(
|
|
(acc, row) => ({ ...acc, ...row.key, ...row.values }),
|
|
{},
|
|
),
|
|
/*
|
|
* **选择组发射的那一口就是这次运行的 outcome**。
|
|
* 「恰发一口,随放行一并确认」——那一口本身就是这一单的分支结论,
|
|
* 下游异或网关的判据读的就是它(如 G4:成交 / 有异议 / 拒绝)。
|
|
* 只有选择组才产生 outcome;必交付口是「都要交」,不构成选择。
|
|
*/
|
|
...(Object.values(input.selectedPorts).length > 0
|
|
? { outcome: Object.values(input.selectedPorts)[0] }
|
|
: {}),
|
|
},
|
|
rows: outcome.merged.map((r) => ({ key: r.key, values: r.values })),
|
|
},
|
|
};
|
|
},
|
|
},
|
|
memory: {
|
|
append: memoryRecorder.append as never,
|
|
seal: (taskId) => deps.memory.seal(taskId),
|
|
distill: (input) => deps.memory.distill(input),
|
|
hasHistory: async (taskId) => (await deps.memory.history(taskId)).length > 0,
|
|
},
|
|
listeners: {
|
|
hasArmedListener: async (taskId) =>
|
|
(await deps.event.listListeners(taskId)).some((l) => l.state === 'armed'),
|
|
},
|
|
persistence: {
|
|
hasDrafts: async (taskId) => (await deps.ledger.checkConflicts(taskId)) !== undefined,
|
|
hasRuntimeState: async (taskId) =>
|
|
(await flow.position(taskId)) !== undefined || (await loop.runtime(taskId)) !== undefined,
|
|
},
|
|
events: {
|
|
publishControl: async (taskId, semantic, by, reason) => {
|
|
await deps.event.publish({
|
|
type: 'control',
|
|
correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'governance', ref: by }, keys: {} },
|
|
payload: { data: { semantic, issuedBy: by, reason } },
|
|
occurredAt: clock.now().toISOString(),
|
|
source: { kind: 'governance' },
|
|
});
|
|
},
|
|
publishTerminal: async (taskId, terminal, reason, facts) => {
|
|
await deps.event.publish({
|
|
type: 'data',
|
|
correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'engine', ref: 'terminal' }, keys: { terminal } },
|
|
payload: { data: { terminal, reason } },
|
|
occurredAt: clock.now().toISOString(),
|
|
source: { kind: 'engine', detail: 'task' },
|
|
});
|
|
|
|
/*
|
|
* 任务终态到达 → 通知流程层(L7)。
|
|
*
|
|
* task 在 L6、process 在 L7,task 不得反向依赖 process —— 这一跳由 app 这层缝合。
|
|
* 「工作流状态是聚合投影」的前提是节点实例的终态**报得上去**:不报,
|
|
* 流就停在那个节点上,既不流转也不走失败走向。
|
|
* 用延后绑定是因为 process 在本函数里晚于 task 构造。
|
|
*/
|
|
const task = await taskStore.get(taskId);
|
|
const manifest = task === undefined ? undefined : await deps.registry.manifestOf(task.activityId);
|
|
const ports = manifest === undefined ? {} : outputPortsOf(manifest, facts);
|
|
await processRef.onTaskTerminal(taskId, terminal, ports);
|
|
},
|
|
},
|
|
notifier: {
|
|
notify: async (input) => {
|
|
// 改派留痕并**通知双方**
|
|
for (const to of [input.from, input.to]) {
|
|
await deps.message.notify({
|
|
staffId: to,
|
|
title: `任务改派:${input.title}`,
|
|
body: `任务 ${input.taskId} 的负责人由 ${input.from} 改为 ${input.to}(操作人 ${input.by})`,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
interaction: { setPhase: async (taskId, phase) => void (await deps.interaction.setPhase(taskId, phase)) },
|
|
clock,
|
|
ids,
|
|
});
|
|
|
|
/* ── process(L7) ─────────────────────────────────────────────── */
|
|
const process: ProcessService = createProcessService({
|
|
store: createProcessStore(db),
|
|
activities: {
|
|
async faces() {
|
|
const cards = await deps.registry.list();
|
|
const map = new Map<string, Awaited<ReturnType<ProcessService['validate']>> extends never ? never : never>();
|
|
void map;
|
|
const faces = new Map();
|
|
for (const card of cards) {
|
|
const manifest = await deps.registry.manifestOf(card.activityId);
|
|
const input = manifest.contract.input.data as { required?: string[]; properties?: Record<string, { type?: string }> };
|
|
const outputs: Record<string, Record<string, string>> = {};
|
|
for (const port of manifest.contract.output.mandatory) {
|
|
const payload = port.payload as { properties?: Record<string, { type?: string }> };
|
|
outputs[port.name] = Object.fromEntries(
|
|
Object.entries(payload.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']),
|
|
);
|
|
}
|
|
faces.set(card.activityId, {
|
|
activityId: card.activityId,
|
|
requiredInputs: input.required ?? [],
|
|
inputTypes: Object.fromEntries(
|
|
Object.entries(input.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']),
|
|
),
|
|
outputs,
|
|
});
|
|
}
|
|
return faces;
|
|
},
|
|
},
|
|
org: {
|
|
positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)),
|
|
candidates: async (position) => {
|
|
const staff = await deps.org.staffOfPosition(position);
|
|
const withLoad = await Promise.all(
|
|
staff.map(async (s) => ({
|
|
staffId: s.id,
|
|
name: s.name,
|
|
openTasks: (await task.openTasksOf(s.id)).length,
|
|
})),
|
|
);
|
|
return withLoad;
|
|
},
|
|
// 起单时按来源人取其工作组(ADR-011);没建组即 undefined,全走四策略
|
|
workGroupBinding: async (staffId) => deps.org.bindingOf(staffIdSchema.parse(staffId)),
|
|
isActive: async (staffId) => {
|
|
const staff = await deps.org.getStaff(staffIdSchema.parse(staffId)).catch(() => undefined);
|
|
return staff?.state === 'active';
|
|
},
|
|
},
|
|
tasks: {
|
|
start: async (input) =>
|
|
task.start({
|
|
activityId: input.activityId,
|
|
ownerId: input.ownerId,
|
|
title: input.title,
|
|
workflowId: input.workflowId,
|
|
nodeId: input.nodeId,
|
|
data: input.data,
|
|
...(input.message === undefined ? {} : { message: input.message }),
|
|
}),
|
|
},
|
|
approvals: {
|
|
async raise(input) {
|
|
// 审批点:卡到审批人工位 —— 与活动级的放行不同层
|
|
const issued = await deps.humanTool.issueExecution(
|
|
{ taskId: input.workflowId as never, ownerId: input.approverId, humanCallCap: 99 },
|
|
{
|
|
title: `审批:${input.subject}`,
|
|
basis: input.basis.length > 0 ? [...input.basis] : [{ k: '节点', v: input.nodeId }],
|
|
ask: '请裁决:通过 / 驳回(驳回必附意见)',
|
|
fields: [
|
|
{
|
|
key: 'verdict',
|
|
label: '裁决',
|
|
kind: 'radio',
|
|
required: true,
|
|
options: [
|
|
{ value: 'pass', label: '通过' },
|
|
{ value: 'reject', label: '驳回', negative: true },
|
|
],
|
|
},
|
|
{ key: 'comment', label: '依据 / 意见', kind: 'textarea' },
|
|
],
|
|
},
|
|
);
|
|
return { id: issued.order.id };
|
|
},
|
|
},
|
|
sfeel,
|
|
schemas: deps.schemas,
|
|
events: {
|
|
publish: async (input) => {
|
|
await deps.event.publish({
|
|
type: 'data',
|
|
correlation: {
|
|
target: { kind: 'workflow', id: input.workflowId as never },
|
|
cause: { kind: 'engine', ref: input.kind },
|
|
keys: {},
|
|
},
|
|
payload: { data: input.detail },
|
|
occurredAt: clock.now().toISOString(),
|
|
source: { kind: 'engine', detail: 'process' },
|
|
});
|
|
},
|
|
},
|
|
receipts: {
|
|
notify: async (input) => {
|
|
await deps.message.notify({
|
|
staffId: input.to,
|
|
title: `工作流回执:${input.subject}`,
|
|
body: `工作流 ${input.workflowId} 状态:${input.state}`,
|
|
});
|
|
},
|
|
},
|
|
clock,
|
|
ids,
|
|
});
|
|
|
|
/* ── trigger(L7) ────────────────────────────────────────────── */
|
|
const trigger = createTriggerService({
|
|
store: createTriggerStore(db),
|
|
process: {
|
|
start: async (input) => process.start(input),
|
|
requiredInputs: async (packageId) => {
|
|
// 与起单取版同口径:读**生效版本**的调用接口(一条流恰一个生效版本)
|
|
const all = await process.listPackages();
|
|
const pkg = all.find((x) => x.id === packageId && x.state === 'active');
|
|
const input = pkg?.callInterface.input as { required?: string[] } | undefined;
|
|
return input?.required ?? [];
|
|
},
|
|
},
|
|
sfeel,
|
|
deadLetters: {
|
|
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
|
report: async (input) => {
|
|
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
|
const { staff } = await deps.org.listStaff();
|
|
for (const person of staff) {
|
|
const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern');
|
|
if (!governs) continue;
|
|
await deps.message.notify({
|
|
staffId: person.id,
|
|
title: `触发器起单失败:${input.triggerId}`,
|
|
body: input.reason,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
ledger: {
|
|
scan: async (input) => {
|
|
const page = await deps.ledger.query({
|
|
address: { domain: input.domain, object: input.object },
|
|
where: {},
|
|
orderBy: [],
|
|
limit: 200,
|
|
offset: 0,
|
|
});
|
|
return page.rows.flatMap((row) => {
|
|
const date = row[input.dateField];
|
|
if (typeof date !== 'string') return [];
|
|
return [{ key: { id: String(row['id'] ?? '') }, date, row }];
|
|
});
|
|
},
|
|
},
|
|
clock,
|
|
ids,
|
|
});
|
|
|
|
// 延后绑定落定:此后任务终态才报得到流程层
|
|
processRef.bind(process);
|
|
|
|
return { task, process, trigger };
|
|
}
|