Archived
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:
co-authored by
Claude Opus 5
parent
2850e6d4ef
commit
3c974d2860
@@ -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>;
|
||||
};
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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" }
|
||||
}
|
||||
@@ -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 }>;
|
||||
};
|
||||
Reference in New Issue
Block a user