Archived
手改是**兜 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
334 lines
16 KiB
TypeScript
334 lines
16 KiB
TypeScript
/**
|
||
* 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>;
|
||
};
|