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/contract.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

334 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
import type { PhaseProgress } from './internal/domain/phase.ts';
export type {
CaseModel,
CaseRuntime,
CaseState,
DecisionResult,
DecisionTrace,
EnabledEntry,
EnabledSet,
Gap,
ItemRuntime,
ItemState,
LoopModel,
Navigation,
PhaseProgress,
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> }
/**
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
*
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
*/
| {
readonly kind: 'write';
readonly itemId: string;
/**
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
*
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
*/
readonly address: string;
readonly rows: ReadonlyArray<{
readonly key: Record<string, string | number>;
readonly patch: Record<string, unknown>;
}>;
}
/**
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
*
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
*
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
* 那扇门就只是个死胡同。**
*/
| {
readonly kind: 'drop';
readonly itemId: string;
/** 台账地址「域.对象」—— 与 write 同一种写法 */
readonly address: string;
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
readonly keys: ReadonlyArray<Record<string, string | number>>;
readonly reason: string;
}
/** 调人:四卡之一 —— 调人 = 立监听听人 */
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
/**
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
*
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
* 收卡的人得进待处理、办完再回来,重得不成比例。
*
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
*/
| {
readonly kind: 'ask';
readonly itemId: string;
/** 问什么 —— 一句话说清缺口 */
readonly ask: string;
/**
* 要对方填的格子。`hint` 是格子里的灰字提示;
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
*/
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly hint?: string;
readonly value?: string;
}>;
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
}
/**
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
*
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
* 那一笔该由人亲手落下,不该经你转述一遍。
*
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
*/
| {
readonly kind: 'entry';
readonly itemId: string;
readonly ask: string;
/** 落哪张表:`域.对象` */
readonly ledger: string;
/** 主键值 —— 确认时按它定位那一行 */
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
/**
* **同一次确认还要落的另几张表**(2026-08-02)。
*
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
*/
readonly also?: ReadonlyArray<{
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
readonly title: string;
readonly ledger: string;
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
}>;
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
}
/**
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
*
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
*
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
*/
| {
readonly kind: 'decide';
readonly itemId: string;
/** 问什么 —— 一句话说清这是个什么岔路 */
readonly ask: string;
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
readonly options: ReadonlyArray<{
readonly value: string;
readonly label: string;
readonly detail?: string;
readonly consequence?: string;
readonly recommended?: boolean;
}>;
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
/** 允许人给出选项之外的处置(附理由) */
readonly allowCustom?: boolean;
}
/** 立监听: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 }
/**
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
*
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
*
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
*/
| { readonly kind: 'finish'; readonly itemId: string; readonly rationale: string }
/**
* 判定成果已齐,提交放行。
*
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
*/
| {
readonly kind: 'submit';
readonly rationale: string;
readonly fields?: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly ai?: string;
readonly ledger?: string;
}>;
}
/**
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
* 开环模式(Mastra Agent 自主连环调工具)专用;单拍模式给不出这一型。
*/
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
/**
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
*
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
*
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
*/
export type Narration = {
readonly text: string;
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
readonly view?: string;
};
/**
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
*
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
*
* 执行纪律(引擎强制,不靠模型自觉):
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
*/
export const TURN_ACTION_CAP = 6;
export type AgentTurn = {
readonly actions: readonly AgentAction[];
readonly say?: Narration;
};
export type TurnOutcome = {
readonly runtime: CaseRuntime;
readonly enabled: EnabledSet;
readonly navigation: Navigation;
readonly action?: AgentAction;
readonly status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked';
readonly blockedReason?: string;
/** 终局唯放行:finished 时带裁决 —— released=true 才算走完,选口随放行确认 */
readonly released?: boolean;
readonly selectedPorts?: Record<string, string>;
};
export type LoopEngineService = {
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
/**
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
*
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
*/
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
publish(taskId: TaskId): Promise<EnabledSet>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
runTurn(taskId: TaskId): Promise<TurnOutcome>;
/** 事件驱动续跑:数据 / 时间 / 人 / 控制 */
/**
* 事件到达:**只记不跑**(登记 occur / 收验收裁决,存快照即返)。
* 从前这里自跑一整拍、deliver 随后的 tick 又跑一拍 —— 每次人卡回传
* 多耗一次不计 steps/activeMs 预算的模型决策。推进统一归 tick。
*/
onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<void>;
/** DMN 确定性求值 —— 能算的不问 LLM */
decide(taskId: TaskId, decision: string): Promise<DecisionResult>;
/** 驳回:相关计划项以新实例重新入场(迁移不是状态) */
reenter(taskId: TaskId, itemId: string): Promise<CaseRuntime>;
};