M4: 范式引擎与任务运行时

skill-engine(L4):载方法不载权力(白名单外工具即拒)、结晶片段经 ToolInvoker 留痕、
  装配入信封、技能目录渐进披露

flow-engine(L5):四种步型、确定性谓词跳步、重试与声明给定的兜底、
  异步步即监听(时限必配)、写类工具登记动作意向、步序位置持久

loop-engine(L5):自研 CMMN 子集解析器(表外构件直接报错,子集封顶可机器证明)、
  两副标准生命周期与迁移拓扑(引擎不得私改)、Enabled ≠ Active、
  语义性前提标待裁量评估(引擎不代裁量)、DMN 确定性求值 + 命中行留痕、
  DRD 缺口演绎与拓扑序、双向驱动与交集导航(断点即人环)、三型行动、
  终局唯放行与 Harness

task(L6):终态五名裁定(released 唯一成功)、Harness 预算环外强制与停滞熔断
  (监听中是健康态)、控制三语义(叫停不是终态,记最重档纠正)、改派留痕通知双方、
  断点续跑三份持久状态、封卷触发蒸馏钩子

301 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:51:22 +08:00
co-authored by Claude Opus 5
parent 2850e6d4ef
commit 3c974d2860
55 changed files with 7113 additions and 1 deletions
@@ -0,0 +1,41 @@
# module: loop-engine
职责: loop 范式:CMMN 子集解析、标准生命周期推进、前提求值与 Enabled 集发布、
DMN 确定性求值与命中行留痕、双向驱动(前推 × 后拉 × 交集导航)、
Agent 回路的回合驱动(三型行动)
不负责: Agent 的推理本身(经 `AgentPort`,Mastra 在 app/adapter)、放行与落地(见 release)、
终态命名(见 task)、活动记忆的存取(经 `MemoryRecorder`)
公开出口: index.ts
- 类型:`LoopEngineService` `CaseModel` `CaseRuntime` `EnabledSet` `Navigation` `AgentAction`
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
`AutomaticOnWorldTouchingError`
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
层级: L5(编码规范 §4)
依赖: @contracts;@shared/db;skill-engine(L4);port 注入:@sfeel / ledger / toolbox /
human-tool / event / memory
被依赖: task
不变量(AM 04 / 12.2):
- **CMMN 子集封顶**:`parseCmmn` 遇到表外构件**直接报错**,不静默忽略 ——
扩构件 = 改编码规范 §4 那一行 + 建 ADR
- **语义忠实**:迁移拓扑写死在 `ITEM_TRANSITIONS` / `CASE_TRANSITIONS`,
引擎不得私改;活动与模型不得发明状态
- **Enabled ≠ Active**:引擎推进到 Enabled 为止;进入 Active 永远出自 Agent 的 manualStart 并留痕
- **引擎零 LLM**:前提求值与 DMN 都走 `@sfeel`;语义性前提标「待裁量评估」交上层,
引擎**不代裁量、不代把关**
- **能算的不问 LLM**:DMN 输入齐备即确定性求值,同输入必同结论;命中规则行必留痕
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
@@ -0,0 +1,288 @@
/** CMMN 子集解析、生命周期与 DMN —— domain 测试零 mock(AM 03 / 04) */
import { describe, expect, it } from 'vitest';
import type { DmnDecisionTable, DmnModel } from '@contracts';
import { CmmnParseError, parseCmmn } from '../internal/domain/cmmn-parse.ts';
import {
autoCompleteReady,
canTransitionItem,
IllegalTransitionError,
initialRuntime,
mayCompleteCase,
mayTerminateCase,
navigate,
occur,
publishEnabled,
reenter,
transitionItem,
} from '../internal/domain/lifecycle.ts';
import { deduceGaps, evaluateTable, evaluationOrder } from '../internal/domain/dmn.ts';
/** 渠道寻源 A3 的极简案例模型 —— 取自 MVP 示例(S1 接需核史 → S2 选渠定策 → S3 发询比价) */
const XML = `<?xml version="1.0"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing">
<caseFileModel>
<caseFileItem id="inquiry" name="询价请求"/>
<caseFileItem id="quotes" name="供应商回价单"/>
</caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
<sentry id="s_after_inquiry">
<onPart id="op1" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_semantic">
<onPart id="op2" caseFileItemRef="quotes"><standardEvent>create</standardEvent></onPart>
<ifPart id="ip1"><condition>semantic: 回价是否可信</condition></ifPart>
</sentry>
<stage id="S1" name="接需核史" autoComplete="true">
<entryCriterion id="ec1" sentryRef="s_after_inquiry"/>
<humanTask id="T1" name="人工查历史报价">
<itemControl><manualActivationRule id="m1"/></itemControl>
</humanTask>
<decisionTask id="D1" name="判定采用策略" decisionRef="strategy"/>
<planningTable id="pt1">
<humanTask id="T2" name="人工渠道资质核实"/>
</planningTable>
</stage>
<milestone id="M1" name="策略已定"/>
<timerEventListener id="TL1" name="时限预警">
<timerExpression>P4D</timerExpression>
</timerEventListener>
</casePlanModel>
</case>
</definitions>`;
describe('parseCmmn —— 子集封顶', () => {
const model = parseCmmn(XML);
it('读出案计划与自动完成', () => {
expect(model.name).toBe('渠道寻源');
expect(model.autoComplete).toBe(true);
});
it('读出任务数据(CaseFileItem)', () => {
expect(model.fileItems.map((f) => f.id)).toEqual(['inquiry', 'quotes']);
});
it('读出全部计划项与类型', () => {
expect(model.items.map((i) => `${i.id}:${i.type}`)).toEqual([
'S1:stage',
'T1:humanTask',
'D1:decisionTask',
'T2:humanTask',
'M1:milestone',
'TL1:timerEventListener',
]);
});
it('manualActivationRule 被识别 —— Enabled ≠ Active', () => {
expect(model.items.find((i) => i.id === 'T1')?.manualActivation).toBe(true);
expect(model.items.find((i) => i.id === 'D1')?.manualActivation).toBe(false);
});
it('planning table 里的项标为裁量项', () => {
expect(model.items.find((i) => i.id === 'T2')?.discretionary).toBe(true);
expect(model.items.find((i) => i.id === 'T1')?.discretionary).toBe(false);
});
it('entryCriterion 关到 sentry 的 onPart', () => {
const stage = model.items.find((i) => i.id === 'S1');
expect(stage?.entryCriteria[0]?.onParts[0]).toEqual({ kind: 'file', ref: 'inquiry', event: 'create' });
});
it('decisionRef 与 timerExpression', () => {
expect(model.items.find((i) => i.id === 'D1')?.decisionRef).toBe('strategy');
expect(model.items.find((i) => i.id === 'TL1')?.timerExpression).toBe('P4D');
});
it('表外构件直接报错 —— 不静默忽略', () => {
const bad = XML.replace('<milestone id="M1" name="策略已定"/>', '<eventListener id="X"/>');
expect(() => parseCmmn(bad)).toThrow(CmmnParseError);
});
it('标签不匹配报错', () => {
expect(() => parseCmmn('<definitions><case id="a"></definitions>')).toThrow(CmmnParseError);
});
});
describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () => {
it('计划项八态的合法迁移', () => {
expect(canTransitionItem('Available', 'Enabled')).toBe(true);
expect(canTransitionItem('Enabled', 'Active')).toBe(true);
expect(canTransitionItem('Active', 'Completed')).toBe(true);
expect(canTransitionItem('Enabled', 'Completed')).toBe(false);
expect(canTransitionItem('Completed', 'Active')).toBe(false);
});
it('非法迁移抛错', () => {
const model = parseCmmn(XML);
const runtime = initialRuntime(model);
expect(() => transitionItem(runtime, 'T1', 'Completed')).toThrow(IllegalTransitionError);
});
it('里程碑只有一条主迁移 Available →(occur)→ Completed', () => {
const model = parseCmmn(XML);
const next = occur(initialRuntime(model), 'M1');
expect(next.items.find((i) => i.id === 'M1')?.state).toBe('Completed');
expect(next.milestones).toEqual(['M1']);
});
it('驳回是迁移不是状态:新实例重新入场', () => {
const model = parseCmmn(XML);
let runtime = transitionItem(initialRuntime(model), 'T1', 'Enabled');
runtime = transitionItem(runtime, 'T1', 'Active');
const next = reenter(runtime, 'T1');
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
});
});
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
it('Completed 只出自放行', () => {
expect(mayCompleteCase('release')).toBe(true);
expect(mayCompleteCase('harness')).toBe(false);
expect(mayCompleteCase('owner')).toBe(false);
});
it('Terminated 只出自 Harness / 负责人 / 治理台', () => {
expect(mayTerminateCase('harness')).toBe(true);
expect(mayTerminateCase('owner')).toBe(true);
expect(mayTerminateCase('governance')).toBe(true);
expect(mayTerminateCase('release')).toBe(false);
});
});
describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
const model = parseCmmn(XML);
it('前提未满足时不发布', () => {
const enabled = publishEnabled(model, initialRuntime(model), {}, new Set(), () => true);
expect(enabled.entries.map((e) => e.id)).not.toContain('S1');
});
it('onPart 件到 + 无 ifPart → Enabled', () => {
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.map((e) => e.id)).toContain('S1');
});
it('父阶段未激活的子项不入场', () => {
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.map((e) => e.id)).not.toContain('T1');
});
it('manualActivation 的项标出「须 Agent 启动」', () => {
let runtime = initialRuntime(model);
runtime = transitionItem(runtime, 'S1', 'Enabled');
runtime = transitionItem(runtime, 'S1', 'Active');
const enabled = publishEnabled(model, runtime, { inquiry: {} }, new Set(['inquiry']), () => true);
expect(enabled.entries.find((e) => e.id === 'T1')?.requiresManualStart).toBe(true);
});
});
describe('navigate —— 导航即交集,断点即人环', () => {
it('有交集即择优推进', () => {
const nav = navigate(
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
new Set(['a']),
);
expect(nav.kind).toBe('proceed');
});
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
});
it('无缺口即收敛', () => {
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
});
});
describe('autoCompleteReady —— 齐了即收', () => {
it('必做项未完不收', () => {
const model = parseCmmn(XML);
expect(autoCompleteReady(model, initialRuntime(model))).toBe(false);
});
});
/* ── DMN:三分类规则 P-03 ─────────────────────────────────────────── */
const p03: DmnDecisionTable = {
id: 'p03',
label: '三分类规则 P-03',
hitPolicy: 'FIRST',
inputs: [
{ name: 'days', expression: 'historyDays' },
{ name: 'stable', expression: 'marketStable' },
],
outputs: [{ name: 'type' }, { name: 'play' }],
rules: [
{ id: 'r1', when: ['<= 90', 'true'], then: ['A', '直接快答'] },
{ id: 'r2', when: ['-', '-'], then: ['B', '重估后报'] },
],
};
const evalExpr = (expr: string, scope: Readonly<Record<string, unknown>>): unknown => scope[expr];
const evalTest = (cell: string, value: unknown): boolean => {
const trimmed = cell.trim();
if (trimmed === '-' || trimmed === '') return true;
if (trimmed.startsWith('<=')) return Number(value) <= Number(trimmed.slice(2));
if (trimmed === 'true') return value === true;
return String(value) === trimmed.replace(/^"|"$/g, '');
};
describe('evaluateTable —— 能算的不问 LLM', () => {
it('命中首行,留痕带命中规则行', () => {
const result = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
expect(result.kind).toBe('decided');
if (result.kind === 'decided') {
expect(result.trace.hitRules).toEqual(['r1']);
expect(result.trace.outputs).toEqual({ type: 'A', play: '直接快答' });
expect(result.trace.inputs).toEqual({ days: 30, stable: true });
}
});
it('同输入必同结论 —— 零方差', () => {
const a = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
const b = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
expect(a).toEqual(b);
});
it('落到兜底行', () => {
const result = evaluateTable(p03, { historyDays: 200, marketStable: false }, evalExpr, evalTest);
if (result.kind === 'decided') expect(result.trace.outputs['type']).toBe('B');
});
it('输入不齐即 incomplete —— DRD 指出缺什么', () => {
const result = evaluateTable(p03, { historyDays: 30 }, evalExpr, evalTest);
expect(result).toEqual({ kind: 'incomplete', missing: ['stable'] });
});
});
describe('DRD —— 缺口演绎与拓扑序', () => {
const model: DmnModel = {
drd: [
{ decision: 'price', requires: ['margin'], inputs: ['basePrice'] },
{ decision: 'margin', requires: [], inputs: ['customerGrade', 'materialType'] },
],
tables: [],
};
it('反向遍历给出缺口', () => {
expect(deduceGaps(model, 'price', new Set(['basePrice']))).toEqual([
{ kind: 'decision', ref: 'margin' },
{ kind: 'input', ref: 'customerGrade' },
{ kind: 'input', ref: 'materialType' },
]);
});
it('已知项不再算缺口', () => {
expect(deduceGaps(model, 'price', new Set(['basePrice', 'margin']))).toEqual([]);
});
it('拓扑序:先算上游决策', () => {
expect(evaluationOrder(model, 'price')).toEqual(['margin', 'price']);
});
it('成环即报错', () => {
const cyclic: DmnModel = { drd: [{ decision: 'a', requires: ['b'], inputs: [] }, { decision: 'b', requires: ['a'], inputs: [] }], tables: [] };
expect(() => evaluationOrder(cyclic, 'a')).toThrow(/成环/);
});
});
@@ -0,0 +1,98 @@
/**
* module: loop-engine —— 对外类型(编码规范 §10.1;语义出处 AM 03 / 04)。
*
* loop 的运行 = **CMMN 语义引擎(下层,零 LLM)× Agent 回路(上层,临场裁量)**。
* 两层的耦合点是任务数据(驱动耦合)与任务记忆(记录同轴)。
*/
import type { LoopModel, TaskId } from '@contracts';
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
import type {
CaseRuntime,
CaseState,
EnabledEntry,
EnabledSet,
ItemRuntime,
ItemState,
Navigation,
TerminalAuthority,
} from './internal/domain/lifecycle.ts';
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
export type {
CaseModel,
CaseRuntime,
CaseState,
DecisionResult,
DecisionTrace,
EnabledEntry,
EnabledSet,
Gap,
ItemRuntime,
ItemState,
LoopModel,
Navigation,
PlanItem,
PlanItemType,
TerminalAuthority,
};
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
export class ForbiddenTerminalError extends Error {
constructor(readonly by: TerminalAuthority) {
super(`终局迁移不得由 ${by} 产生:Completed 必经放行,Terminated 只出自 Harness 与负责人(AM 4.1)`);
this.name = 'ForbiddenTerminalError';
}
}
export class AutomaticOnWorldTouchingError extends Error {
constructor(readonly itemId: string) {
super(`计划项 ${itemId} 触达世界却未声明 manualActivationRule —— Enabled ≠ Active(AM 12.2)`);
this.name = 'AutomaticOnWorldTouchingError';
}
}
/** Agent 的三型行动 —— 每一步行动属于且仅属于其一(AM 4.2) */
export type AgentAction =
/** 调工具:系统调用与 LLM 工具 */
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
/** 调人:四卡之一 —— 调人 = 立监听听人 */
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
/** 启动一个 Enabled 项(manualStart,留痕) */
| { readonly kind: 'start'; readonly itemId: string }
/** 规划裁量项入场 */
| { readonly kind: 'plan'; readonly itemId: string }
/** 弃用某项(此单不走这条) */
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
/** 评估语义性前提(评估留痕,可回放可归因) */
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
/** 判定成果已齐,提交放行 */
| { readonly kind: 'submit'; readonly rationale: string };
export type TurnOutcome = {
readonly runtime: CaseRuntime;
readonly enabled: EnabledSet;
readonly navigation: Navigation;
readonly action?: AgentAction;
readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked';
readonly blockedReason?: string;
};
export type LoopEngineService = {
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
publish(taskId: TaskId): Promise<EnabledSet>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
runTurn(taskId: TaskId): Promise<TurnOutcome>;
/** 事件驱动续跑:数据 / 时间 / 人 / 控制 */
onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TurnOutcome>;
/** DMN 确定性求值 —— 能算的不问 LLM */
decide(taskId: TaskId, decision: string): Promise<DecisionResult>;
/** 驳回:相关计划项以新实例重新入场(迁移不是状态) */
reenter(taskId: TaskId, itemId: string): Promise<CaseRuntime>;
};
+68
View File
@@ -0,0 +1,68 @@
/**
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
AgentAction,
CaseModel,
CaseRuntime,
CaseState,
DecisionResult,
DecisionTrace,
EnabledEntry,
EnabledSet,
Gap,
ItemRuntime,
ItemState,
LoopEngineService,
LoopModel,
Navigation,
PlanItem,
PlanItemType,
TerminalAuthority,
TurnOutcome,
} from './contract.ts';
export {
AutomaticOnWorldTouchingError,
CmmnParseError,
ForbiddenTerminalError,
IllegalTransitionError,
} from './contract.ts';
export type {
AgentPort,
CardGateway,
Clock,
ListenerRegistrar,
LoopStateStore,
MemoryRecorder,
Sfeel,
TaskContext,
TaskDataGateway,
ToolGateway,
} from './port.ts';
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
export {
assertItemTransition,
autoCompleteReady,
canTransitionCase,
canTransitionItem,
CASE_STATES,
evaluateCriterion,
initialRuntime,
ITEM_STATES,
mayCompleteCase,
mayTerminateCase,
navigate,
occur,
publishEnabled,
reenter,
transitionItem,
} from './internal/domain/lifecycle.ts';
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
@@ -0,0 +1,15 @@
/**
* loop-engine 的 sys schema 表(编码规范 §7 / ADR-002)。
* 运行时实例状态:loop 的**模型定位与就绪集** —— 断点续跑的三份持久状态之一(OS 09)。
*/
import { jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const loopStateTable = sys.table('loop_state', {
taskId: text('task_id').primaryKey(),
model: jsonb('model').notNull(),
/** 解析后的 CMMN 子集结构 —— 免去每回合重解析 */
parsed: jsonb('parsed').notNull(),
runtime: jsonb('runtime').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,36 @@
/**
* loop-engine 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq } from 'drizzle-orm';
import type { LoopModel, TaskId } from '@contracts';
import { loopModelSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { LoopStateStore } from '../../port.ts';
import type { CaseModel } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts';
import { loopStateTable } from './loop-state-schema.ts';
export function createLoopStateStore(db: Db): LoopStateStore {
return {
async save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime) {
const values = { taskId, model, parsed, runtime, updatedAt: new Date() };
await db
.insert(loopStateTable)
.values(values)
.onConflictDoUpdate({
target: loopStateTable.taskId,
set: { model, parsed, runtime, updatedAt: values.updatedAt },
});
},
async load(taskId: TaskId) {
const rows = await db.select().from(loopStateTable).where(eq(loopStateTable.taskId, taskId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return {
model: loopModelSchema.parse(row.model),
parsed: row.parsed as CaseModel,
runtime: row.runtime as CaseRuntime,
};
},
};
}
@@ -0,0 +1,348 @@
/**
* CMMN 子集解析 —— 纯函数,零 IO、零依赖(编码规范 §5.3 / §4)。
*
* **CMMN 子集封顶**:构件与生命周期以 AM 03 构件表 + AM 4.1 两副状态表为全集,
* 不实现 CMMN 1.1 其余构件 —— 扩构件 = 改 §4 那一行 + ADR。
*
* 子集构件:CasePlanModel / Stage / HumanTask / DecisionTask / ProcessTask / CaseTask /
* Milestone / TimerEventListener / UserEventListener / PlanningTable + DiscretionaryItem /
* EntryCriterion + ExitCriterion(onPart + ifPart)/ repetitionRule / manualActivationRule / autoComplete。
*
* 自研极小 XML 读取器:只认标签、属性与嵌套 —— 不支持实体扩展、CDATA 之外的特性,
* 遇到不认识的构件**直接报错**,不静默忽略(子集封顶要能被机器证明)。
*/
export class CmmnParseError extends Error {
constructor(message: string) {
super(`CMMN 解析失败:${message}`);
this.name = 'CmmnParseError';
}
}
/* ── 极小 XML 读取器 ──────────────────────────────────────────────── */
export type XmlNode = {
readonly tag: string;
readonly attrs: Readonly<Record<string, string>>;
readonly children: readonly XmlNode[];
readonly text: string;
};
const TAG = /<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g;
const ATTR = /([\w.:-]+)\s*=\s*"([^"]*)"/g;
export function parseXml(source: string): XmlNode {
const cleaned = source.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '');
const stack: Array<{ tag: string; attrs: Record<string, string>; children: XmlNode[]; text: string }> = [];
let root: XmlNode | undefined;
let lastIndex = 0;
TAG.lastIndex = 0;
for (let m = TAG.exec(cleaned); m !== null; m = TAG.exec(cleaned)) {
const [full, closing, rawTag, rawAttrs, selfClosing] = m;
const between = cleaned.slice(lastIndex, m.index).trim();
lastIndex = m.index + full.length;
const top = stack.at(-1);
if (between !== '' && top !== undefined) top.text += unescapeXml(between);
const tag = stripNamespace(rawTag!);
if (closing === '/') {
const finished = stack.pop();
if (finished === undefined) throw new CmmnParseError(`多余的结束标签 </${tag}>`);
if (finished.tag !== tag) throw new CmmnParseError(`标签不匹配:<${finished.tag}> 与 </${tag}>`);
const node: XmlNode = { ...finished, children: finished.children };
const parent = stack.at(-1);
if (parent === undefined) root = node;
else parent.children.push(node);
continue;
}
const attrs: Record<string, string> = {};
ATTR.lastIndex = 0;
for (let a = ATTR.exec(rawAttrs ?? ''); a !== null; a = ATTR.exec(rawAttrs ?? '')) {
attrs[stripNamespace(a[1]!)] = unescapeXml(a[2]!);
}
if (selfClosing === '/') {
const node: XmlNode = { tag, attrs, children: [], text: '' };
const parent = stack.at(-1);
if (parent === undefined) root = node;
else parent.children.push(node);
continue;
}
stack.push({ tag, attrs, children: [], text: '' });
}
if (stack.length > 0) throw new CmmnParseError(`标签未闭合:<${stack.at(-1)!.tag}>`);
if (root === undefined) throw new CmmnParseError('没有根元素');
return root;
}
function stripNamespace(name: string): string {
const idx = name.indexOf(':');
return idx < 0 ? name : name.slice(idx + 1);
}
function unescapeXml(text: string): string {
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
/* ── 子集构件模型 ───────────────────────────────────────────────────── */
/** 计划项类型 —— 子集全集(AM 03 构件表) */
export const PLAN_ITEM_TYPES = [
'humanTask',
'decisionTask',
'processTask',
'caseTask',
'stage',
'milestone',
'timerEventListener',
'userEventListener',
] as const;
export type PlanItemType = (typeof PLAN_ITEM_TYPES)[number];
/** 前提:onPart(听什么)+ ifPart(内容条件,S-FEEL) */
export type Criterion = {
readonly id: string;
/** 监听哪个任务数据项的写入/变更,或哪个计划项的迁移 */
readonly onParts: ReadonlyArray<{ kind: 'file' | 'plan-item'; ref: string; event: string }>;
/** 内容条件 —— 不可机算的标记为语义性前提,交 Agent 评估 */
readonly ifPart?: string;
/** 语义性前提:引擎标「待裁量评估」,不自行裁决 */
readonly semantic: boolean;
};
export type PlanItem = {
readonly id: string;
readonly name: string;
readonly type: PlanItemType;
/** 所属阶段;顶层为 undefined */
readonly parent?: string;
/** 触达世界与关键交付者**必须** manualActivationRule = true(AM 4.1 原理一) */
readonly manualActivation: boolean;
readonly repetition: boolean;
/** 裁量项:从 planning table 由 Agent 临场规划入场 */
readonly discretionary: boolean;
readonly entryCriteria: readonly Criterion[];
readonly exitCriteria: readonly Criterion[];
/** DecisionTask 引用的决策 */
readonly decisionRef?: string;
/** TimerEventListener 的时限 */
readonly timerExpression?: string;
/** 阶段的「齐了即收」 */
readonly autoComplete: boolean;
/** 调人时投递到哪个工位 */
readonly roleRef?: string;
};
export type CaseModel = {
readonly id: string;
readonly name: string;
readonly autoComplete: boolean;
readonly items: readonly PlanItem[];
/** CaseFileItem:任务数据 —— 事的数据中心 */
readonly fileItems: ReadonlyArray<{ id: string; name: string }>;
};
const ITEM_TAGS: Readonly<Record<string, PlanItemType>> = {
humanTask: 'humanTask',
decisionTask: 'decisionTask',
processTask: 'processTask',
caseTask: 'caseTask',
stage: 'stage',
milestone: 'milestone',
timerEventListener: 'timerEventListener',
userEventListener: 'userEventListener',
};
/** 结构性标签:允许出现但本身不是计划项 */
const STRUCTURAL = new Set([
'definitions',
'case',
'casePlanModel',
'caseFileModel',
'caseFileItem',
'planItem',
'planningTable',
'discretionaryItem',
'entryCriterion',
'exitCriterion',
'sentry',
'onPart',
'ifPart',
'condition',
'standardEvent',
'itemControl',
'repetitionRule',
'manualActivationRule',
'requiredRule',
'timerExpression',
'caseRoles',
'role',
'extensionElements',
'documentation',
'CMMNDI',
'CMMNDiagram',
'CMMNShape',
'CMMNEdge',
'Bounds',
'waypoint',
'CMMNLabel',
]);
export function parseCmmn(xml: string): CaseModel {
const root = parseXml(xml);
const plan = findFirst(root, 'casePlanModel');
if (plan === undefined) throw new CmmnParseError('缺 casePlanModel');
assertSubset(plan);
const sentries = new Map<string, XmlNode>();
collect(plan, 'sentry', sentries);
const items: PlanItem[] = [];
walkItems(plan, undefined, sentries, items, false);
const fileModel = findFirst(root, 'caseFileModel');
const fileItems =
fileModel === undefined
? []
: fileModel.children
.filter((c) => c.tag === 'caseFileItem')
.map((c) => ({ id: c.attrs['id'] ?? '', name: c.attrs['name'] ?? c.attrs['id'] ?? '' }));
return {
id: plan.attrs['id'] ?? 'case',
name: plan.attrs['name'] ?? '案例',
autoComplete: plan.attrs['autoComplete'] === 'true',
items,
fileItems,
};
}
/** 子集封顶的机器证明:出现表外构件即报错,不静默忽略 */
function assertSubset(node: XmlNode): void {
for (const child of node.children) {
if (ITEM_TAGS[child.tag] === undefined && !STRUCTURAL.has(child.tag)) {
throw new CmmnParseError(`构件 <${child.tag}> 不在 CMMN 子集内(子集封顶,扩构件须改 §4 并建 ADR)`);
}
assertSubset(child);
}
}
function walkItems(
node: XmlNode,
parent: string | undefined,
sentries: Map<string, XmlNode>,
out: PlanItem[],
discretionary: boolean,
): void {
for (const child of node.children) {
const type = ITEM_TAGS[child.tag];
if (type !== undefined) {
const id = child.attrs['id'] ?? '';
const control = findFirst(child, 'itemControl');
const item: PlanItem = {
id,
name: child.attrs['name'] ?? id,
type,
...(parent === undefined ? {} : { parent }),
manualActivation: hasRule(control, 'manualActivationRule') || child.attrs['manualActivation'] === 'true',
repetition: hasRule(control, 'repetitionRule') || child.attrs['repetition'] === 'true',
discretionary,
entryCriteria: criteriaOf(child, 'entryCriterion', sentries),
exitCriteria: criteriaOf(child, 'exitCriterion', sentries),
...(child.attrs['decisionRef'] === undefined ? {} : { decisionRef: child.attrs['decisionRef'] }),
...timerOf(child),
autoComplete: child.attrs['autoComplete'] === 'true',
...(child.attrs['roleRef'] === undefined ? {} : { roleRef: child.attrs['roleRef'] }),
};
out.push(item);
// 阶段展开即 create 子项;planningTable 由下面的分支统一按裁量项处理,此处不重复收集
if (type === 'stage') walkItems(child, id, sentries, out, false);
continue;
}
if (child.tag === 'planningTable') {
walkItems(child, parent, sentries, out, true);
continue;
}
if (child.tag === 'discretionaryItem') {
walkItems({ ...child, children: child.children }, parent, sentries, out, true);
continue;
}
if (STRUCTURAL.has(child.tag)) walkItems(child, parent, sentries, out, discretionary);
}
}
function criteriaOf(node: XmlNode, tag: 'entryCriterion' | 'exitCriterion', sentries: Map<string, XmlNode>): Criterion[] {
return node.children
.filter((c) => c.tag === tag)
.map((c) => {
const sentryRef = c.attrs['sentryRef'];
const sentry = sentryRef === undefined ? undefined : sentries.get(sentryRef);
const onParts = (sentry?.children ?? [])
.filter((s) => s.tag === 'onPart')
.map((s) => ({
kind: (s.attrs['caseFileItemRef'] !== undefined ? 'file' : 'plan-item') as 'file' | 'plan-item',
ref: s.attrs['caseFileItemRef'] ?? s.attrs['sourceRef'] ?? '',
event: textOf(s, 'standardEvent') ?? s.attrs['standardEvent'] ?? 'create',
}));
const ifPartNode = sentry === undefined ? undefined : findFirst(sentry, 'ifPart');
const condition = ifPartNode === undefined ? undefined : textOf(ifPartNode, 'condition') ?? ifPartNode.text.trim();
const semantic = condition !== undefined && condition.startsWith('semantic:');
return {
id: c.attrs['id'] ?? sentryRef ?? '',
onParts,
...(condition === undefined || condition === ''
? {}
: { ifPart: semantic ? condition.slice('semantic:'.length).trim() : condition }),
semantic,
} satisfies Criterion;
});
}
function timerOf(node: XmlNode): { timerExpression?: string } {
const value = textOf(node, 'timerExpression');
return value === undefined ? {} : { timerExpression: value };
}
function hasRule(control: XmlNode | undefined, rule: string): boolean {
if (control === undefined) return false;
const node = findFirst(control, rule);
if (node === undefined) return false;
const condition = textOf(node, 'condition');
return condition === undefined || condition.trim() === '' || condition.trim() === 'true';
}
function findFirst(node: XmlNode, tag: string): XmlNode | undefined {
if (node.tag === tag) return node;
for (const child of node.children) {
const found = findFirst(child, tag);
if (found !== undefined) return found;
}
return undefined;
}
function collect(node: XmlNode, tag: string, out: Map<string, XmlNode>): void {
if (node.tag === tag) {
const id = node.attrs['id'];
if (id !== undefined) out.set(id, node);
}
for (const child of node.children) collect(child, tag, out);
}
function textOf(node: XmlNode, tag: string): string | undefined {
const found = node.children.find((c) => c.tag === tag);
if (found === undefined) return undefined;
const text = found.text.trim();
return text === '' ? undefined : text;
}
@@ -0,0 +1,138 @@
/**
* DMN 求值 —— 纯逻辑,零 IO(编码规范 §5.3 / §4:DMN 落点在本模块 domain,
* 单元格谓词复用 `@sfeel`;语义出处 AM 3.3)。
*
* 「DMN 承担一件事:把装得下的判断**从 LLM 手里拿走**。
* 能算的不问 LLM(必须)—— 输入齐备的决策表由语义引擎确定性求值,同输入必同结论,零方差零 token;
* DRD 即缺口演绎(应)—— 输入不齐时,DRD 指出缺什么;
* 决策留痕(必须)—— 输入快照 + 决策表版本 + **命中规则行**;
* 结论不可静默覆写(必须)。」
*/
import type { DmnDecisionTable, DmnModel } from '@contracts';
export type UnaryTestEvaluator = (cell: string, value: unknown, scope: Readonly<Record<string, unknown>>) => boolean;
export type ExpressionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => unknown;
/** 决策留痕:输入快照 + 决策表版本 + 命中规则行 —— 决策可回放、可归因 */
export type DecisionTrace = {
readonly decision: string;
readonly tableId: string;
readonly inputs: Readonly<Record<string, unknown>>;
readonly hitRules: readonly string[];
readonly outputs: Readonly<Record<string, unknown>>;
};
export type DecisionResult =
| { readonly kind: 'decided'; readonly trace: DecisionTrace }
/** 输入不齐 —— DRD 指出缺什么(缺口演绎的后拉遍历) */
| { readonly kind: 'incomplete'; readonly missing: readonly string[] }
/** 无规则命中 */
| { readonly kind: 'no-match'; readonly inputs: Readonly<Record<string, unknown>> };
/** 求值一张决策表 */
export function evaluateTable(
table: DmnDecisionTable,
scope: Readonly<Record<string, unknown>>,
evaluateExpression: ExpressionEvaluator,
evaluateUnaryTest: UnaryTestEvaluator,
): DecisionResult {
const inputs: Record<string, unknown> = {};
const missing: string[] = [];
for (const input of table.inputs) {
const value = evaluateExpression(input.expression, scope);
if (value === undefined) missing.push(input.name);
inputs[input.name] = value;
}
if (missing.length > 0) return { kind: 'incomplete', missing };
const hitRules: string[] = [];
const outputs: Record<string, unknown> = {};
for (const rule of table.rules) {
const matches = table.inputs.every((input, index) => {
const cell = rule.when[index] ?? '-';
return evaluateUnaryTest(cell, inputs[input.name], scope);
});
if (!matches) continue;
hitRules.push(rule.id);
for (const [index, output] of table.outputs.entries()) {
const value = rule.then[index];
if (table.hitPolicy === 'COLLECT') {
const bucket = (outputs[output.name] as unknown[] | undefined) ?? [];
bucket.push(value);
outputs[output.name] = bucket;
} else {
// UNIQUE / FIRST / PRIORITY:先命中者定结论
if (!(output.name in outputs)) outputs[output.name] = value;
}
}
if (table.hitPolicy === 'FIRST' || table.hitPolicy === 'UNIQUE') break;
}
if (hitRules.length === 0) return { kind: 'no-match', inputs };
return { kind: 'decided', trace: { decision: table.id, tableId: table.id, inputs, hitRules, outputs } };
}
/* ── DRD:缺口演绎 ─────────────────────────────────────────────────── */
export type Gap =
/** 可算:先算上游决策 */
| { readonly kind: 'decision'; readonly ref: string }
/** 可查 / 只有人知道 / 在世界侧 —— 由 Agent 按断点判定去处 */
| { readonly kind: 'input'; readonly ref: string };
/**
* 沿 DRD 反向遍历:目标决策缺什么 → 缺口由什么推出。
* 「主要就是走 DRD」—— 双向驱动的后拉遍历。
*/
export function deduceGaps(model: DmnModel, decision: string, known: ReadonlySet<string>): Gap[] {
const node = model.drd.find((d) => d.decision === decision);
if (node === undefined) return [];
const gaps: Gap[] = [];
for (const required of node.requires) {
if (known.has(required)) continue;
gaps.push({ kind: 'decision', ref: required });
gaps.push(...deduceGaps(model, required, known));
}
for (const input of node.inputs) {
if (!known.has(input)) gaps.push({ kind: 'input', ref: input });
}
// 去重,保持首次出现次序
const seen = new Set<string>();
return gaps.filter((g) => {
const key = `${g.kind}:${g.ref}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** 拓扑序:先算上游决策,再算目标 */
export function evaluationOrder(model: DmnModel, target: string): string[] {
const order: string[] = [];
const visiting = new Set<string>();
const visit = (decision: string): void => {
if (order.includes(decision)) return;
if (visiting.has(decision)) throw new Error(`DRD 成环:${decision}`);
visiting.add(decision);
const node = model.drd.find((d) => d.decision === decision);
for (const required of node?.requires ?? []) visit(required);
visiting.delete(decision);
order.push(decision);
};
visit(target);
return order;
}
/**
* 结论不可静默覆写(必须):
* DMN 结论对 Agent 是**约束性输入** —— 认为不合理时只有两条路:
* 超出授权走「人 · 决策」,规则本身有病提迭代提案。
*/
export function isBindingConclusion(result: DecisionResult): result is Extract<DecisionResult, { kind: 'decided' }> {
return result.kind === 'decided';
}
@@ -0,0 +1,281 @@
/**
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
*
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
*/
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
/** 案计划(整案)五态 */
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
export type CaseState = (typeof CASE_STATES)[number];
/** 计划项八态 */
export const ITEM_STATES = [
'Available',
'Enabled',
'Disabled',
'Active',
'Suspended',
'Failed',
'Completed',
'Terminated',
] as const;
export type ItemState = (typeof ITEM_STATES)[number];
/** 迁移拓扑 —— 引擎不得私改 */
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
Disabled: ['Enabled', 'Terminated'],
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
Suspended: ['Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
export class IllegalTransitionError extends Error {
constructor(
readonly from: string,
readonly to: string,
) {
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
this.name = 'IllegalTransitionError';
}
}
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
return ITEM_TRANSITIONS[from].includes(to);
}
export function assertItemTransition(from: ItemState, to: ItemState): void {
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
}
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
return CASE_TRANSITIONS[from].includes(to);
}
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
export type ItemRuntime = {
readonly id: string;
readonly state: ItemState;
/** repetitionRule 的实例序号 */
readonly instance: number;
/** 语义性前提待裁量评估的标记 */
readonly awaitingJudgement?: boolean;
};
export type CaseRuntime = {
readonly state: CaseState;
readonly items: readonly ItemRuntime[];
/** 已达成的里程碑 */
readonly milestones: readonly string[];
};
export function initialRuntime(model: CaseModel): CaseRuntime {
return {
state: 'Active',
items: model.items.map((item) => ({
id: item.id,
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
state: item.discretionary ? 'Available' : 'Available',
instance: 1,
})),
milestones: [],
};
}
/* ── 前提求值 ───────────────────────────────────────────────────────── */
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
export type CriterionVerdict =
| { readonly kind: 'satisfied' }
| { readonly kind: 'unsatisfied' }
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
| { readonly kind: 'needs-judgement'; readonly statement: string };
export function evaluateCriterion(
criterion: Criterion,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): CriterionVerdict {
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
if (criterion.onParts.length > 0) {
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
if (!allArrived) return { kind: 'unsatisfied' };
}
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
}
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
export type EnabledEntry = {
readonly id: string;
readonly name: string;
readonly type: PlanItem['type'];
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
readonly requiresManualStart: boolean;
readonly discretionary: boolean;
};
export type Redlight = { readonly criterion: string; readonly statement: string };
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
readonly gaps: readonly string[];
/** 阶段位置:Active 的阶段 */
readonly activeStages: readonly string[];
};
/**
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
*/
export function publishEnabled(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
unmetCriteria: readonly string[] = [],
): EnabledSet {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const entries: EnabledEntry[] = [];
const judgements: Redlight[] = [];
for (const item of model.items) {
const state = byId.get(item.id)?.state ?? 'Available';
if (state !== 'Available' && state !== 'Enabled') continue;
// 父阶段未激活的项不入场
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
let satisfied = true;
for (const criterion of item.entryCriteria) {
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
if (verdict.kind === 'needs-judgement') {
judgements.push({ criterion: criterion.id, statement: verdict.statement });
satisfied = false;
break;
}
if (verdict.kind === 'unsatisfied') {
satisfied = false;
break;
}
}
if (!satisfied) continue;
entries.push({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
});
}
return {
entries,
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
.map((i) => i.id),
};
}
/**
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
* **交集为空即断点** —— 调人或立监听的判定时机。」
*/
export type Navigation =
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
| { readonly kind: 'converged' };
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
return { kind: 'proceed', candidates: intersection };
}
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
});
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
return allDone && noneRunning;
}
/**
* 终局唯放行与 Harness(必须):
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
* **AI 不得产生终局迁移**。
*/
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
export function mayCompleteCase(by: TerminalAuthority): boolean {
return by === 'release';
}
export function mayTerminateCase(by: TerminalAuthority): boolean {
return by === 'harness' || by === 'owner' || by === 'governance';
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
};
}
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
const current = runtime.items.find((i) => i.id === itemId);
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
assertItemTransition(current.state, to);
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
}
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) =>
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
),
};
}
@@ -0,0 +1,322 @@
/**
* loop 回合驱动 —— 语义引擎 × Agent 回路(编码规范 §5.3 / §6.3;语义出处 AM 04)。
*
* 定位 → 择向 → 决断 → 行动 → 写数据 → 回到定位。
* **回合由 usecase 驱动,不放开自由 maxSteps;预算在环外由 Harness 强制。**
*/
import type { LoopModel, TaskId } from '@contracts';
import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract.ts';
import { ForbiddenTerminalError } from '../../contract.ts';
import type {
AgentPort,
CardGateway,
Clock,
ListenerRegistrar,
LoopStateStore,
MemoryRecorder,
Sfeel,
TaskContext,
TaskDataGateway,
ToolGateway,
} from '../../port.ts';
import { parseCmmn } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts';
import {
autoCompleteReady,
initialRuntime,
navigate,
occur,
publishEnabled,
reenter as reenterItem,
transitionItem,
} from '../domain/lifecycle.ts';
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
export type TurnRunnerDeps = {
readonly state: LoopStateStore;
readonly sfeel: Sfeel;
readonly data: TaskDataGateway;
readonly agent: AgentPort;
readonly tools: ToolGateway;
readonly cards: CardGateway;
readonly listeners: ListenerRegistrar;
readonly memory: MemoryRecorder;
readonly tasks: TaskContext;
readonly clock: Clock;
};
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
async function loaded(taskId: TaskId) {
const found = await deps.state.load(taskId);
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
return found;
}
/** 变化面:任务视图里有值的键 —— onPart 的「件到」判据 */
function changedRefs(scope: Record<string, unknown>): Set<string> {
return new Set(Object.keys(scope).filter((k) => scope[k] !== undefined && scope[k] !== null));
}
async function publishFor(taskId: TaskId) {
const { model, parsed, runtime } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const gaps = parsed.items
.filter((i) => !i.discretionary && runtime.items.find((r) => r.id === i.id)?.state !== 'Completed')
.map((i) => i.name);
return {
model,
parsed,
runtime,
scope,
enabled: publishEnabled(parsed, runtime, scope, changedRefs(scope), deps.sfeel.condition, gaps),
};
}
async function applyAction(taskId: TaskId, action: AgentAction, runtime: CaseRuntime): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string }> {
const context = await deps.tasks.of(taskId);
switch (action.kind) {
case 'start': {
// manualStart —— 进入 Active 永远出自 Agent 的裁量启动,留痕
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `manualStart:${action.itemId}`,
detail: { by: 'agent' },
});
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'running' };
}
case 'plan': {
await deps.memory.append({ taskId, kind: 'engine-transition', title: `规划裁量项入场:${action.itemId}` });
return { runtime: transitionItem(runtime, action.itemId, 'Enabled'), status: 'running' };
}
case 'disable': {
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `弃用计划项:${action.itemId}`,
detail: { reason: action.reason },
});
return { runtime: transitionItem(runtime, action.itemId, 'Disabled'), status: 'running' };
}
case 'judge': {
// 语义性前提的裁量评估 —— 评估留痕,可回放可归因
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `语义性前提评估:${action.criterion} → ${action.satisfied ? '满足' : '未满足'}`,
detail: { rationale: action.rationale },
});
return { runtime, status: 'running' };
}
case 'tool': {
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
if (await deps.tools.isWriteEffect(action.tool)) {
await deps.tools.registerIntent({
taskId,
tool: action.tool,
input: action.input,
label: `${action.itemId}:${action.tool}`,
});
return { runtime: transitionItem(runtime, action.itemId, 'Completed'), status: 'running' };
}
const result = await deps.tools.invoke({
tool: action.tool,
input: action.input,
activityId: context.activityId,
taskId,
});
await deps.memory.append({
taskId,
kind: 'result',
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
detail: { output: result.output, error: result.error?.message },
});
return {
runtime: transitionItem(runtime, action.itemId, result.ok ? 'Completed' : 'Failed'),
status: 'running',
};
}
case 'human': {
// 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: action.workOrder,
card: action.card,
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'awaiting' };
}
case 'listen': {
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
await deps.listeners.register({
taskId,
eventType: action.eventType,
keyFilter: action.keyFilter,
deadline: action.deadline,
});
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
detail: action.keyFilter,
});
return { runtime, status: 'awaiting' };
}
case 'submit': {
await deps.memory.append({
taskId,
kind: 'thought',
title: '判定成果已齐,提交放行',
detail: { rationale: action.rationale },
});
// 终局唯放行:引擎不把案计划置为 Completed —— 那是 release 的事
return { runtime, status: 'submitted' };
}
}
}
return {
async load(taskId, model: LoopModel) {
const parsed = parseCmmn(model.cmmnXml);
const runtime = initialRuntime(parsed);
await deps.state.save(taskId, model, parsed, runtime);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `装载案例模型:${parsed.name}(${parsed.items.length} 个计划项)`,
});
return runtime;
},
async runtime(taskId) {
return (await deps.state.load(taskId))?.runtime;
},
async publish(taskId) {
return (await publishFor(taskId)).enabled;
},
/** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
async runTurn(taskId): Promise<TurnOutcome> {
const { model, parsed, runtime, scope, enabled } = await publishFor(taskId);
// 齐了即收:必做项完成且无进行中项 → 提交放行(仍必经放行卡)
if (autoCompleteReady(parsed, runtime)) {
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'submitted' };
}
// 导航即交集:当下可为 ∩ 目标所需
const navigation = navigate(enabled, new Set(enabled.entries.map((e) => e.id)));
if (navigation.kind === 'breakpoint') {
// 断点即人环 —— 由 Agent 定夺调人还是立监听,判定留痕
await deps.memory.append({
taskId,
kind: 'thought',
title: '依赖断点:当下可为 ∩ 目标所需 = 空',
detail: { gaps: navigation.gaps },
});
}
const narrative = [
model.narrative,
...(await deps.memory.loadActivityMemory((await deps.tasks.of(taskId)).activityId, 20)).map(
(m) => `[${m.layer}] ${m.statement}`,
),
].join('\n\n');
const action = await deps.agent.decide({ taskId, narrative, enabled, scope });
// AI 不得产生终局迁移
if (action.kind === 'start') {
const item = parsed.items.find((i) => i.id === action.itemId);
if (item === undefined) throw new ForbiddenTerminalError('owner');
}
const applied = await applyAction(taskId, action, runtime);
await deps.state.save(taskId, model, parsed, applied.runtime);
return {
runtime: applied.runtime,
enabled,
navigation,
action,
status: applied.status,
...(applied.reason === undefined ? {} : { blockedReason: applied.reason }),
};
},
/** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */
async onEvent(taskId, event) {
const { model, parsed, runtime } = await loaded(taskId);
await deps.memory.append({
taskId,
kind: 'observation',
title: `事件到达(${event.kind}):${event.ref}`,
detail: event.payload,
});
let next = runtime;
// 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed
const listener = parsed.items.find((i) => i.id === event.ref && i.type.endsWith('EventListener'));
if (listener !== undefined) next = occur(runtime, listener.id);
await deps.state.save(taskId, model, parsed, next);
// 前提前推:事件写数据 → 任务视图变化 → 前提求值 → 新项 Enabled
return this.runTurn(taskId);
},
/** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */
async decide(taskId, decision) {
const { model } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const order = evaluationOrder(model.dmn, decision);
const enriched: Record<string, unknown> = { ...scope };
let last = undefined as ReturnType<typeof evaluateTable> | undefined;
for (const step of order) {
const table = model.dmn.tables.find((t) => t.id === step);
if (table === undefined) continue;
const result = evaluateTable(table, enriched, deps.sfeel.expression, deps.sfeel.unaryTest);
last = result;
if (result.kind !== 'decided') break;
Object.assign(enriched, result.trace.outputs);
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `DMN 求值:${table.label} → 命中 ${result.trace.hitRules.join('、')}`,
detail: { inputs: result.trace.inputs, outputs: result.trace.outputs, table: table.id },
});
}
return last ?? { kind: 'no-match', inputs: {} };
},
/** 驳回:相关计划项以新实例重新入场(repetitionRule),记为纠正信号 */
async reenter(taskId, itemId) {
const { model, parsed, runtime } = await loaded(taskId);
const next = reenterItem(runtime, itemId);
await deps.state.save(taskId, model, parsed, next);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `驳回重入:${itemId}(新实例)`,
});
return next;
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/loop-engine",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+93
View File
@@ -0,0 +1,93 @@
/**
* module: loop-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { LoopModel, StaffId, TaskId } from '@contracts';
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
import type { AgentAction } from './contract.ts';
export type Clock = { now(): Date };
/** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */
export type LoopStateStore = {
save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise<void>;
load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime } | undefined>;
};
/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */
export type Sfeel = {
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
unaryTest(cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean;
};
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
export type TaskDataGateway = {
view(taskId: TaskId): Promise<Record<string, unknown>>;
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<void>;
};
/**
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
* **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。**
*/
export type AgentPort = {
decide(input: {
taskId: TaskId;
/** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
}): Promise<AgentAction>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
export type ToolGateway = {
invoke(input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId }): Promise<{
ok: boolean;
output?: Record<string, unknown>;
error?: { message: string };
}>;
isWriteEffect(tool: string): Promise<boolean>;
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
};
export type CardGateway = {
issue(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
card: Record<string, unknown>;
}): Promise<{ id: string }>;
};
export type ListenerRegistrar = {
register(input: {
taskId: TaskId;
eventType: 'data' | 'time' | 'human';
keyFilter: Record<string, string>;
deadline: string;
}): Promise<{ id: string }>;
};
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
};
export type TaskContext = {
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
};