人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 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
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
+60 -2
View File
@@ -11,11 +11,11 @@
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
`AutomaticOnWorldTouchingError`
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
@@ -36,6 +36,64 @@
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 `TURN_ACTION_CAP` = 6):
拍是按模型调用计费的 —— 一拍只许一个行动时,每个 write / finish / start 都单烧一遍
完整上下文,实测 A1 六步的活恰好 21 拍(其中 19 拍是该约束下的最短路径)。
收成一串后同一单 8 拍收口、token 减半。执行纪律引擎强制:按序执行,谁被拒停在谁那儿
(**前缀已生效不回滚**,理由喂回下一拍);`tool` 之后截断(出参没看见,后面的行动是
闭着眼计划的);挂起型(ask/human/listen/submit)落地即收拍;超封顶丢弃并留痕。
- **写台账草稿是过程写的唯一形态**(2026-07-30 新增 `write` 行动,铁律②)。
从前这条路**根本不存在**:`TaskDataGateway.draftWrite` 有端口无调用方,
Agent 干出来的结论一处也放不下 —— 后果不是少个功能,而是必交付物恒零行、
验收判据求值取不到值、出口载荷推不出字段:跑得再热闹,放行那刻什么也交不出。
- 一拍可写**多行**:一份 BOM 一次写完,不一行一拍各烧一次模型;
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
又常驻「当下可为」与「目标缺口」,让 Agent 每拍都看见自己按不动的东西。
现在引擎在发布前自己收(达成留痕);**无前提的里程碑不自达成** —— 那是建模漏了前提。
- **事件监听器不是交付物,是等待岗**(2026-07-30):不进 `gaps`,也不算 autoComplete 的必做项
(从前只排除 `timerEventListener`,一个 `userEventListener` 就让案子永远判不齐)。
- **一拍 = 一个行动 + 一句交代**(2026-07-30 `AgentTurn`)。Agent 从前**不会说话** ——
会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。
实测:A1 跑十几拍、写七行台账、调两次工具,负责人的对话区从头到尾空白,
他只看得见「多了一个任务」。「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
- 交代落 `narration`(它进对话区),与 `thought`(给自己留的痕)分开;
- **不是一型行动,是每一拍的附带**:先做成 `kind:'say'` 试过 —— 它占一整拍又不推进局面,
预算下的 Agent 理所当然跳过它,对话区照旧空白。让说话与干活抢拍,
等于把「必须说」设计成了「有空才说」;
- **先交代再动手**:人看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果;
- `say.view` 可顺手换局面模版 —— 局面变了,人既要听到为什么,也要看到新画面。
- **卡死了交回给人**(2026-07-30 `stalledTurns` + `STALL_LIMIT=5`)。预算会烧完,
但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 —— 那不是在办,是卡死了。
连续 5 拍行动被拒就不再问模型(再问也是同一个答案),发执行卡把**拒绝理由原话**摆给人:
是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同,人得看得见证据。
- **补全卡是对话卡,不是执行工单**(`ask` 行动 / `CardGateway.ask`)。
「按缺口清单追问补全」要的是:缺口清单摆在对话区、带一张表单,人就地填齐。
从前 Agent **发不出对话卡**(只能选执行 / 验收两种工单),SOP 上画着补全卡的那一步
只能拿执行工单顶 —— 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、
办完再回来,重得不成比例。一个格子都没有即拒发(空表单等于没问)。
- **决策卡也是对话卡**(2026-07-31 `decide` 行动 / `CardGateway.decide`)。
与补全卡的分工:补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。
从前决策卡**只有 UI 没有 Agent 侧** —— 卡面在对话区画得出来,行动型里却发不出它,
于是所有岔路口都被写成补全卡:人拿到一张要自己敲字的表单,而不是两个可点的选项。
少于两个选项即拒发(一个选项的「选择题」是通知,不是决策)。
- **视图推送回报推没推上**(`ViewGateway.push` → `{pushed, templates}`)。
从前引擎每拍推一个**硬编码**的 `case-overview`,而活动声明的模版未必叫这个名字
(A1 的四张一个都不叫)—— 模版查不到就静默 return,**每一帧都被悄悄丢掉**,
工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」看起来像「没产出」。
现在:要的那张没有就退到活动声明的第一张,换不上则如实记痕。
- **人答了什么要看得见**:`MemoryRecorder.window` 带回 `detail`,回传值(`detail.values`)
随轨迹进叙事面。不给的话「执行工单回传」这一行读起来像事情办完了,而人填了哪几个值
Agent 一个字看不见 —— 既写不进台账,也只能再问一遍。
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
@@ -180,19 +180,38 @@ describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
describe('navigate —— 导航即交集,断点即人环', () => {
it('有交集即择优推进', () => {
const nav = navigate(
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺报价'], activeStages: [] },
new Set(['a']),
);
expect(nav.kind).toBe('proceed');
});
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
it('交集为空**且手上没开着的活**才是断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], inProgress: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
});
/*
* 断点的定义是「无路可走」。手上还有开着的活就不算 —— 从前只看 Enabled 集,
* 局面推进开以后手上全是 Active,每拍都判成断点,而断点的处置是「调人或立监听」:
* 环于是把没干完的活丢下,去等一个不会来的事件。
*/
it('Enabled 为空但有进行中的项 → 接着干,不是断点', () => {
const nav = navigate(
{
entries: [],
inProgress: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }],
judgements: [],
gaps: ['缺分类表'],
activeStages: [],
},
new Set(['nothing']),
);
expect(nav).toEqual({ kind: 'proceed', candidates: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }] });
});
it('无缺口即收敛', () => {
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
expect(navigate({ entries: [], inProgress: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
});
});
@@ -0,0 +1,176 @@
/**
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
*
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
*/
import { describe, expect, it } from 'vitest';
import { readingsOf } from '../internal/domain/lifecycle.ts';
describe('收步判据的读数', () => {
it('把判据读的那几个值摆出来', () => {
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
missing_key_total: 1,
missing_minor_total: 0,
});
expect(out).toContain('missing_key_total=1');
expect(out).toContain('missing_minor_total=0');
});
it('只摆判据读的,不摆整个任务视图', () => {
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
expect(out).toContain('line_total=3');
expect(out).not.toContain('subject');
expect(out).not.toContain('pn');
});
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
});
it('保留字不当台账名去查', () => {
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
expect(out).toContain('need_sourcing=false');
expect(out).not.toContain('true=');
expect(out).not.toContain('and=');
});
it('字符串字面量里的词不当台账名', () => {
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
});
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
expect(readingsOf('1 = 1', {})).toBe('');
});
});
/**
* 判据名归一要削掉**引擎自己贴的等级标记**。
*
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
*/
import { criterionKey } from '../internal/domain/lifecycle.ts';
describe('判据名归一', () => {
it('模型抄进等级标记也要认得出是同一条', () => {
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
);
});
it('五个等级都削', () => {
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
}
});
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
});
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
});
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
});
});
/**
* 同一句交代不进两遍对话区。
*
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
* 人在对话区看到同一个问题被问三遍,像卡带。
*
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
*/
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
const now = say.trim().replace(/\s+/g, '');
const last = [...trail].reverse().find((t) => t.kind === 'narration');
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
}
describe('对话区不结巴', () => {
const n = (title: string) => ({ kind: 'narration', title });
it('跟上一句一字不差 —— 压下', () => {
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
});
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
});
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
});
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
});
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
});
it('一条叙事都还没有 —— 放行', () => {
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
});
});
/**
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事。
*/
describe('行数判据拒回:连主键名单一起摆', () => {
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
const out = readingsOf('quote_intake_record_rows = line_total', {
quote_intake_record_rows: 10,
line_total: 9,
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
});
expect(out).toContain('quote_intake_record_rows=10');
expect(out).toContain('line_total=9');
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
});
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
quote_intake_type_table_rows: 2,
quote_intake_record_rows: 3,
quote_intake_type_table_keys: 'A、B',
quote_intake_record_keys: 'A、B、C',
});
expect(out).toContain('是这几行:A、B;');
expect(out).toContain('是这几行:A、B、C');
});
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
quote_intake_alt_request_rows: 0,
type_c_total: 0,
});
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
});
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
'(现在:missing_key_total=1)',
);
});
});
@@ -0,0 +1,67 @@
/**
* 阶段进度 —— 进度条的刻度。判据是「这一阶段的计划项**全收完**才算过」。
*/
import { describe, expect, it } from 'vitest';
import type { CasePhase } from '@contracts';
import { CLOSING_PHASE, phaseProgress } from '../internal/domain/phase.ts';
import type { ItemRuntime, ItemState } from '../internal/domain/lifecycle.ts';
/** A1 的三段:定位客户 / 归一受理 / 判定去向;放行那一格由进度条自己加 */
const PHASES: CasePhase[] = [
{ name: '定位客户', items: ['S1_customer'] },
{ name: '归一受理', items: ['S2_normalize'] },
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
];
const items = (states: Record<string, ItemState>): ItemRuntime[] =>
Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
const ALL = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route', 'T_release'];
const at = (done: readonly string[]): ItemRuntime[] =>
items(Object.fromEntries(ALL.map((id) => [id, done.includes(id) ? 'Completed' : 'Available'])));
describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
it('一格都没收:停在第一阶段', () => {
expect(phaseProgress(PHASES, at([]), false)).toEqual({ index: 1, total: 4, name: '定位客户' });
});
it('逐段推进:收完一段才进下一段', () => {
expect(phaseProgress(PHASES, at(['S1_customer']), false)).toMatchObject({ index: 2, name: '归一受理' });
expect(phaseProgress(PHASES, at(['S1_customer', 'S2_normalize']), false)).toMatchObject({
index: 3,
name: '判定去向',
});
});
it('一段里的项没收齐就不算过 —— 三项收两项仍停在这一段', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify'];
expect(phaseProgress(PHASES, at(done), false)).toMatchObject({ index: 3, name: '判定去向' });
});
it('三段都过、还没收口:进最后一格(等放行),但不是终态', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route'];
expect(phaseProgress(PHASES, at(done), false)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('终态直接给最后一格 —— 不去数计划项', () => {
// 放行完成那一瞬 T_release 还没收:去数项的话会显示「还差一格」,而单已经收口了
expect(phaseProgress(PHASES, at([]), true)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('被跳过的裁量项(Terminated)算过 —— 它不会再收,等它等到天亮', () => {
const mixed = items({
S1_customer: 'Completed',
S2_normalize: 'Completed',
S4_history: 'Completed',
S5_classify: 'Terminated',
S6_route: 'Completed',
T_release: 'Available',
});
expect(phaseProgress(PHASES, mixed, false)).toMatchObject({ index: 4, name: CLOSING_PHASE });
});
it('没声明阶段就不给刻度 —— 界面照此不画进度条,不编一个出来', () => {
expect(phaseProgress(undefined, at([]), false)).toBeUndefined();
expect(phaseProgress([], at([]), false)).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
+223 -2
View File
@@ -17,6 +17,7 @@ import type {
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,
@@ -31,6 +32,7 @@ export type {
ItemState,
LoopModel,
Navigation,
PhaseProgress,
PlanItem,
PlanItemType,
TerminalAuthority,
@@ -57,8 +59,154 @@ export class AutomaticOnWorldTouchingError extends Error {
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,留痕) */
@@ -69,8 +217,35 @@ export type AgentAction =
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
/** 评估语义性前提(评估留痕,可回放可归因) */
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
/** 判定成果已齐,提交放行 */
| { readonly kind: 'submit'; 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 —— 不能再发一张,否则负责人工位上堆两张同样的卡。
@@ -78,6 +253,44 @@ export type AgentAction =
*/
| { 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;
@@ -94,6 +307,14 @@ 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>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
@@ -3,6 +3,8 @@
*/
export type {
AgentAction,
AgentTurn,
Narration,
CaseModel,
CaseRuntime,
CaseState,
@@ -26,6 +28,7 @@ export {
CmmnParseError,
ForbiddenTerminalError,
IllegalTransitionError,
TURN_ACTION_CAP,
} from './contract.ts';
export type {
@@ -47,9 +50,12 @@ export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
export {
assertItemTransition,
achieveMilestones,
autoCompleteReady,
canTransitionCase,
canTransitionItem,
criterionKey,
readingsOf,
CASE_STATES,
evaluateCriterion,
initialRuntime,
@@ -139,6 +139,17 @@ export type Redlight = { readonly criterion: string; readonly statement: string
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/**
* **进行中的项** —— 已开工、还没交出成果的那些。
*
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
*
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
*/
readonly inProgress: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
@@ -196,6 +207,16 @@ export function publishEnabled(
return {
entries,
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
inProgress: model.items
.filter((item) => byId.get(item.id)?.state === 'Active')
.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
})),
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
/*
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
*/
if (intersection.length === 0) {
return enabled.inProgress.length > 0
? { kind: 'proceed', candidates: enabled.inProgress }
: { kind: 'breakpoint', gaps: enabled.gaps };
}
return { kind: 'proceed', candidates: intersection };
}
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
/*
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
*/
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
/**
* 前提达成即达成的里程碑 —— **不需要谁去按**。
*
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
* 让 Agent 每拍都看到一堆自己按不动的东西。
*
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
*/
export function achieveMilestones(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): { runtime: CaseRuntime; achieved: string[] } {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
let next = runtime;
const achieved: string[] = [];
for (const item of model.items) {
if (item.type !== 'milestone') continue;
if (byId.get(item.id)?.state !== 'Available') continue;
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
if (item.entryCriteria.length === 0) continue;
const met = item.entryCriteria.every(
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
);
if (!met) continue;
next = occur(next, item.id);
achieved.push(item.id);
}
return { runtime: next, achieved };
}
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
),
};
}
/**
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
*
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
*
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
*/
export function criterionKey(raw: string): string {
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
/*
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
*
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
*
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
*/
return tight.replace(/\(l[1-5]\)$/, '');
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
*
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
*/
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
const names = new Set<string>();
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
}
if (names.size === 0) return '';
const readings = [...names].map((name) => {
const value = scope[name];
if (value === undefined || value === null) return `${name}=(没写过)`;
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
});
/*
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
*
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
*/
const rowKeys = [...names]
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
.map((n) => {
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
if (typeof listed !== 'string' || listed === '') return '';
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
})
.filter((s) => s !== '');
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
}
@@ -0,0 +1,50 @@
/**
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
*
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
* 两层分开,是因为它们回答的是两个问题:
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
*/
import type { CasePhase } from '@contracts';
import type { ItemRuntime } from './lifecycle.ts';
export type PhaseProgress = {
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
readonly index: number;
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
readonly total: number;
/** 这一格叫什么 */
readonly name: string;
};
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
export const CLOSING_PHASE = '放行收口';
/**
* 算这一单在第几阶段。
*
* 判据是「这一阶段的计划项**全部收完**」——
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
*
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
*/
export function phaseProgress(
phases: readonly CasePhase[] | undefined,
items: readonly ItemRuntime[],
terminal: boolean,
): PhaseProgress | undefined {
if (phases === undefined || phases.length === 0) return undefined;
const total = phases.length + 1;
if (terminal) return { index: total, total, name: CLOSING_PHASE };
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
for (const [at, phase] of phases.entries()) {
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
}
return { index: total, total, name: CLOSING_PHASE };
}
File diff suppressed because it is too large Load Diff
+150 -7
View File
@@ -4,7 +4,7 @@
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 { AgentAction } from './contract.ts';
import type { AgentTurn } from './contract.ts';
export type Clock = { now(): Date };
@@ -32,13 +32,47 @@ export type Sfeel = {
/** 任务数据 —— 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<void>;
}): Promise<{ changed: boolean }>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路。
*
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
draftDrop(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
}): Promise<{ dropped: boolean }>;
};
/**
@@ -52,7 +86,23 @@ export type AgentPort = {
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
}): Promise<AgentAction>;
/**
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`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>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
@@ -61,9 +111,21 @@ export type ToolGateway = {
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' }>
>;
};
/**
@@ -77,6 +139,24 @@ export type PendingAcceptance = {
};
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;
@@ -84,6 +164,42 @@ export type CardGateway = {
workOrder: 'execution' | 'acceptance';
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;
}): Promise<{ id: string }>;
};
export type ListenerRegistrar = {
@@ -102,12 +218,18 @@ export type ListenerRegistrar = {
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
*/
export type ViewGateway = {
/**
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
*/
push(input: {
taskId: TaskId;
activityId: string;
template: string;
scope: Record<string, unknown>;
}): Promise<void>;
}): Promise<{ pushed: boolean; templates: string[] }>;
};
/**
@@ -124,16 +246,37 @@ export type SkillGateway = {
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
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 }>>;
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
window(taskId: TaskId): Promise<Array<{ kind: string; title: 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>>;
};