Archived
E2E 跑 loop 时 A3 一口气发了八张一模一样的「请处理」卡。逐层查下去,四处: ① **并发推进的真根因**:`pg_try_advisory_lock` 是**会话级**的,而我们经连接池取连接 —— 加锁与解锁多半落在不同连接上,解锁解不掉自己那把;同一连接被复用时又因可重入而直接放行。 两头都不对,于是同一任务被并发推进(现象:同一步跑两遍、同一张卡发两张、 同一行草稿写出两条互相打架的版本)。前一轮「共用同一把锁」的修法方向对、器械错。 改用**进程内按目标互斥**(MVP 单进程,确定且够用),推进器与派发器共用一把; 多进程部署时再叠事务级 `pg_try_advisory_xact_lock`(随事务释放,不依赖连接归属)。 实测:同一单从 8 张卡降到 1 张,2 回合后落到等待。 ② **非法行动会把回合打死**:AI 提了不合法的生命周期迁移(Available → Disabled), `applyAction` 抛出、运行时没落笔、推进器一遍遍重来。引擎拒绝它是对的 ——「语义忠实:引擎不得私改迁移拓扑」——但不该因此崩掉。 改为:拒绝 + 留痕 + 把理由喂回下一回合的上下文。AI 提出非法行动是预期内的事。 ③ **副作用抢在校验之前**:卡在迁移合法性判定**之前**就发出去了,于是每个被拒的回合 都白发一张卡 —— 卡收不回,负责人工作台上堆起一摞没人知道怎么答的「请处理」。 human / tool 两个分支改为**先验迁移再动手**。 ④ **裸调用**:模型只回一个「请处理」。按「调用必附上下文,裸调用不得发出」(AM 06) 把卡面结构写进出参 schema(title / ask / basis / fields 必填),提示里讲明要求, 归一时用当下局面兜底;卡片装配也修了 —— 结构化 basis 优先,嵌套对象转 JSON, 不再出现一行 `[object Object]`。 实测卡面:「请确认 NE555P 历史报价并判定采用策略」+ 三个回填字段。 gates: tsc / eslint / depcruise(336 模块)/ api-report / 429 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
/**
|
|
* 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<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': {
|
|
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
|
|
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<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 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<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;
|
|
},
|
|
};
|
|
}
|