This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/port.ts
T
xcsandClaude Fable 5 7b85a79323 harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:16 +08:00

317 lines
14 KiB
TypeScript

/**
* 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 { AgentTurn } from './contract.ts';
export type Clock = { now(): Date };
/** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */
export type LoopStateStore = {
save(
taskId: TaskId,
model: LoopModel,
parsed: CaseModel,
runtime: CaseRuntime,
pending?: PendingAcceptance | null,
): Promise<void>;
load(
taskId: TaskId,
): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance } | 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>>;
/**
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
*
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
*/
writable(taskId: TaskId): Promise<
Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{ name: string; label: string; type?: string }>;
}>
>;
/**
* 写草稿,并回报**这一笔到底有没有改变什么**。
*
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
*/
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<{ changed: boolean }>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路。
*
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
draftDrop(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
}): Promise<{ dropped: boolean }>;
};
/**
* 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>;
/**
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
*
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
*/
tools: Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
writable: Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{ name: string; label: string; type?: string }>;
}>;
}): Promise<AgentTurn>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
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 };
/**
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
*
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
*/
awaitingHuman?: { readonly workOrderId: string };
}>;
isWriteEffect(tool: string): Promise<boolean>;
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
busOf(activityId: string): Promise<
Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>
>;
};
/**
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
* 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
*/
export type PendingAcceptance = {
readonly acceptanceCardId: string;
readonly verdict?: 'pass' | 'reject';
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
readonly comment?: string;
readonly selectedPorts?: Record<string, string>;
};
export type CardGateway = {
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
entry(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
also?: ReadonlyArray<{
title: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}): Promise<{ id: string }>;
issue(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
itemId?: string | undefined;
card: Record<string, unknown>;
}): Promise<{ id: string }>;
/**
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
*
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
*/
ask(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
fields: ReadonlyArray<{ key: string; label: string; hint?: string }>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}): Promise<{ id: string }>;
/**
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
*
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
*/
decide(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
options: ReadonlyArray<{
value: string;
label: string;
detail?: string;
consequence?: string;
recommended?: boolean;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
allowCustom?: boolean;
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
itemId?: string | undefined;
/**
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
*/
variant?: 'review' | undefined;
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
checklist?: ReadonlyArray<string> | undefined;
}): Promise<{ id: string }>;
/**
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
*/
unanswered(taskId: TaskId): Promise<ReadonlyArray<{ id: string; title: string }>>;
};
export type ListenerRegistrar = {
register(input: {
taskId: TaskId;
eventType: 'data' | 'time' | 'human';
keyFilter: Record<string, string>;
deadline: string;
}): Promise<{ id: string }>;
/**
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
*
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
*/
declared(taskId: TaskId): Promise<ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>>;
};
/**
* 视图流控 —— app 注入 `interaction`(L3)。
* 「loop:AI 按定位与局面临场组织视图流」(AM 07)。MVP 每回合推一次**案例全景**:
* 阶段与计划项的生命周期态、当下可为、目标缺口 —— 这些是语义引擎算出来的确定事实,
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
*/
export type ViewGateway = {
/**
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
*/
push(input: {
taskId: TaskId;
activityId: string;
template: string;
scope: Record<string, unknown>;
}): Promise<{ pushed: boolean; templates: string[] }>;
};
/**
* 技能 —— app 注入 `skill-engine`(L4)。
* 「渐进披露:常驻上下文**仅有技能目录**,按事加载正文、按需下钻引用」(AM 08)。
*/
export type SkillGateway = {
/** 目录:id + 何时适用 —— 只给这些,正文等 AI 说要才加载 */
index(activityId: string): Promise<ReadonlyArray<{ id: string; label: string; applicability: string }>>;
/** 装配入信封:这一回合装配了哪些技能及其版本,记入任务记忆 */
stamp(activityId: string, skillIds: readonly string[]): Promise<ReadonlyArray<{ id: string; version: string }>>;
};
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
/**
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
*
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
*/
window(
taskId: TaskId,
size?: number,
): Promise<Array<{ kind: string; title: string; detail?: Record<string, unknown> | undefined }>>;
};
export type TaskContext = {
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
/**
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
*
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
*/
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
};