/** * 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, SkillGateway, ViewGateway, 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 views: ViewGateway; readonly skills: SkillGateway; 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): Set { 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': { // 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算) const done = transitionItem(runtime, action.itemId, 'Completed'); // 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地 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: done, 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: result.ok ? done : transitionItem(runtime, action.itemId, 'Failed'), status: 'running', }; } case 'human': { /* * 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提。 * * **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡。 * 反过来写(先发卡后迁移)会让每一次被拒的回合都白发一张卡 —— 卡收不回, * 负责人的工作台上就堆起一摞谁也不知道该怎么答的「请处理」。 */ const advanced = transitionItem(runtime, action.itemId, '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: advanced, 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 { 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 activityId = (await deps.tasks.of(taskId)).activityId; const catalog = await deps.skills.index(activityId); /* * 叙事面 = 案例说明书 + 活动记忆选摘 + **技能目录**。 * 渐进披露(AM 08):常驻上下文只给「有哪些方法、何时适用」, * 正文与知识引用等 AI 说要才加载 —— 一次把所有正文塞进去, * 上下文会被方法论淹掉,真正的局面反而看不见。 */ const narrative = [ model.narrative, ...(await deps.memory.loadActivityMemory(activityId, 20)).map((m) => `[${m.layer}] ${m.statement}`), catalog.length === 0 ? '' : `【可装配的技能(目录)】\n${catalog.map((s) => `- ${s.id} ${s.label}:${s.applicability}`).join('\n')}`, ] .filter((line) => line !== '') .join('\n\n'); // 装配入信封:这一回合手上有哪些技能及其版本,记进任务记忆 —— 回放与归因依赖之 if (catalog.length > 0) { const stamp = await deps.skills.stamp(activityId, catalog.map((s) => s.id)); await deps.memory.append({ taskId, kind: 'thought', title: `装配技能:${stamp.map((s) => `${s.id}@${s.version}`).join('、')}`, detail: { skills: [...stamp] }, }); } 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'); } /* * AI 提的行动可能**不合法**(违反生命周期迁移拓扑、越权、预算触顶)。 * 引擎拒绝它是对的 —— 「语义忠实:引擎不得私改迁移拓扑」;但**不能因此死掉**: * 抛出去会让这一回合的副作用(卡已发出)留在世界上、运行时却没落笔, * 推进器于是一遍遍重来,每遍再发一张卡。 * * 正确做法:拒绝 + 留痕 + 把拒绝理由喂回下一回合的上下文。 * AI 提出非法行动是预期内的事,引擎的职责是不执行,不是崩掉。 * 反复提不出合法行动由 Harness 兜底(步预算与停滞熔断),那才是它该管的事。 */ let applied: { runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string }; try { applied = await applyAction(taskId, action, runtime); } catch (error) { const reason = error instanceof Error ? error.message : String(error); await deps.memory.append({ taskId, kind: 'result', title: `行动被拒:${reason}`, detail: { action, hint: '请在「当下可为」里另选一项,或改用调人 / 立监听' }, }); return { runtime, enabled, navigation, action, status: 'running' }; } await deps.state.save(taskId, model, parsed, applied.runtime); /* * 每回合推一次案例全景 —— 呈现是投影,失败只丢这一帧,不影响回路推进。 * 画面的素材全部来自语义引擎(阶段态 / 当下可为 / 缺口),零 LLM。 */ try { await deps.views.push({ taskId, activityId: (await deps.tasks.of(taskId)).activityId, template: 'case-overview', scope: { ...scope, 'case:stages': parsed.items .filter((i) => i.type === 'stage') .map((i) => ({ name: i.name, state: applied.runtime.items.find((r) => r.id === i.id)?.state ?? '—' })), 'case:enabled': enabled.entries.map((e) => ({ name: e.name, state: e.requiresManualStart ? '待启动' : '可进行' })), 'case:gaps': enabled.gaps.map((g) => ({ name: g, state: '未满足' })), gapCount: enabled.gaps.length, enabledCount: enabled.entries.length, activeStage: enabled.activeStages.join('、') || '—', }, }); } catch { // 呈现失败不影响回路:画面丢一帧,事实一件不少 } 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 = { ...scope }; let last = undefined as ReturnType | 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; }, }; }