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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user