A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
@@ -269,3 +269,29 @@ describe('行数读数认得出,写错的照旧拒装', () => {
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
});
});
/**
* **工具调用读数也算绑得上**(2026-08-03)——
* 基座按留痕数「这一单成功调了几次某工具」(`tool_<工具名去符号>_calls`),
* 声明过的工具一律先铺 0。判据的两边不能都由活动自报:A2 实测约十次一次,
* 判稳那一步压根没调行情工具就把料判成「稳」转 A,自报的依据列却写得漂亮。
*/
describe('工具调用读数绑得上', () => {
const withTool = (predicate: string) =>
acceptanceLevelFindings({
contract: {
acceptanceCriteria: [{ key: 'k', label: 'l', level: 'L2', predicate }],
input: { data: { type: 'object', properties: {} } },
},
declarations: { ledger: [], tools: [{ name: 'crm.history-price-read' }] },
assets: { businessModel: { cmmnXml: '' } },
} as never);
it('声明过的工具 → `tool_..._calls` 绑得上', () => {
expect(withTool('tool_crm_history_price_read_calls > 0')).toEqual([]);
});
it('没声明的工具照旧拒装 —— 声明面就是授权面,写错一个字当场拦下', () => {
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
});
});
@@ -37,7 +37,9 @@ export type AcceptanceAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
};
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
// tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数)
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> &
Partial<Pick<ActivityManifest['declarations'], 'tools'>>;
readonly assets: {
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
};
@@ -290,6 +292,19 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
}
}
}
/*
* **工具调用读数** `tool_<工具名去符号>_calls`(2026-08-03 起)——
* 基座按留痕数「这一单成功调了几次某工具」,声明过的工具一律先铺 0。
*
* 它存在的理由与 `_rows` 一模一样:**判据的两边不能都由活动自报**。
* A2 实测约十次一次 —— 判稳那一步压根没调行情工具就把料判成「稳」转 A,
* 而自报的依据列写得漂漂亮亮,去向就此从寻源走成定价。
* 有了这个名字,「转 A 的依据来自本单亲查的行情」才是一条真机检。
*/
// 旧调用方(与老测试)只传 ledger —— 缺 tools 就当没声明工具,别掀翻整份审计
for (const tool of manifest.declarations.tools ?? []) {
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
}
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
@@ -68,8 +68,9 @@ describe('工单形态机检', () => {
it('规范的执行工单通过', () => {
expect(checkWorkOrder(execution())).toEqual([]);
});
it('执行工单 = 指导书 + 空表单,表单不得为空', () => {
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing');
it('**执行工单空表单是常态** —— 填写全在工作区的件上,零字段照发(2026-08-02 用户定)', () => {
// 从前这儿拒发空表单;通道换到件上之后,那道拒发让活动照新规矩发卡被基座连拒到熔断(实测)
expect(checkWorkOrder(execution({ fields: [] }))).toEqual([]);
});
it('radio 字段缺选项即拒', () => {
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
@@ -43,9 +43,14 @@ export function checkWorkOrder(order: WorkOrder): CardViolation[] {
function checkExecution(order: ExecutionWorkOrder): CardViolation[] {
const out: CardViolation[] = [];
if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' });
if (order.fields.length === 0) {
out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' });
}
/*
* **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去
* 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件),
* 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 +
* 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后,
* 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。
* 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。
*/
out.push(...checkFieldKeys(order.fields));
return out;
}
@@ -199,18 +199,48 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
/*
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*
* 这段话从前只对**返回值**成立:抛回任务的人事件载荷是原始 reply,
* 而放行落地读的恰是事件载荷 —— 于是「routedPort 顶上」只发生在没人看的那一份里,
* 浏览器靠自己把算出的口塞进回传才活着(chat-bubbles 那段注释),
* 换个客户端(脚本、API 调用)就死在「选择组未选出口」(A2 e2e 实测)。
* **服务端说了算的事,不靠每个客户端自觉** —— 事件与返回值现在用同一份有效口。
*/
const routed = (found.order as AcceptanceWorkOrder).routedPort;
const effectivePorts: Readonly<Record<string, string>> =
reply.mode !== 'acceptance' ? {} : routed !== undefined ? { [routed.group]: routed.name } : reply.selectedPorts;
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
/*
* **空答复要说成人话**(2026-08-02 用户实测送来的熔断单):执行卡零输入之后,
* 人「看过、确认、提交」的回传 `values` 就是空的 —— 痕上一个值都没有,
* 在模型眼里像「没答成」,于是把同一张卡再发一遍(连发五拍熔断)。
* 空不是没答:**是人照原样确认了**(件上若改过,那几笔已由保存那一刻落进草稿层)。
*/
const blank = reply.mode === 'execution' && Object.keys(values).length === 0;
await deps.memory.append({
taskId: found.taskId,
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
detail: {
by,
values,
mode: reply.mode,
...(blank ? { note: '人已办完并交回(卡上无表单,改动若有已在件上保存时落进草稿层)—— 接着办说明书里「人交回之后」的事' } : {}),
...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}),
},
});
// 回传经统一入口以人事件抛回任务
// 回传经统一入口以人事件抛回任务 —— 载荷带的是**有效口**,不是人碰巧发来的那份
await deps.events.publishHumanEvent({
taskId: found.taskId,
cardId: reply.workOrderId,
payload: { ...reply },
payload: { ...reply, ...(reply.mode === 'acceptance' ? { selectedPorts: effectivePorts } : {}) },
});
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
@@ -223,19 +253,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
/*
* 出口:**算出来的优先**。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*/
selectedPorts:
reply.mode !== 'acceptance'
? {}
: (order as AcceptanceWorkOrder).routedPort !== undefined
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
: reply.selectedPorts,
selectedPorts: effectivePorts,
};
},
@@ -165,13 +165,21 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
const body = rows
.map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one);
})
.join('');
return `${open}${body}</${tag}>`;
});
}
/**
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
* 会把第 1 页已选的顶掉。每行的 name 缀上行号,组内互斥只在本行内发生。
*/
const uniqueNames = (inner: string, rowIndex: number): string =>
inner.replace(/\bname\s*=\s*"([^"]+)"/g, (_m, n: string) => `name="${n}__r${rowIndex}"`);
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
@@ -0,0 +1,44 @@
/**
* 结构化出参的提取 —— 两次实测栽跟头,各钉一条:
*
* ① 模型一次吐两个对象(把下一拍也写了):按第一个配平的取,后面当噪声。
* ② **关思考的模型偶发多吐一个闭括号**(2026-08-02,A2 案例 0 实测):
* `…"type_tag":"A"}}]}},{"kind":…` —— 该是 `}}]},`。从前只数花括号,
* 多出的 `}` 把计数带到零,切出的半截连 `[` 都没关上,解析失败;
* 整拍六个动作全对,却退化成一张「需要你判断下一步」的兜底卡。
* 括号栈知道当下该关谁:对不上的闭括号当噪声丢(不发明内容,只丢一个多余字符)。
*/
import { describe, expect, it } from 'vitest';
import { parseJsonLoose } from '../internal/adapter/ai-sdk-provider.ts';
describe('parseJsonLoose:模型出参的宽容提取', () => {
it('干净 JSON 原样过', () => {
expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 });
});
it('```json 围栏剥掉', () => {
expect(parseJsonLoose('```json\n{"a":1}\n```')).toEqual({ a: 1 });
});
it('一次吐两个对象:取第一个配平的,后面当噪声', () => {
expect(parseJsonLoose('{"a":1}\n\n{"b":2}')).toEqual({ a: 1 });
});
it('**多吐一个闭括号:当噪声丢,整拍不作废**(实测形状)', () => {
const slip = '{"say":"归一好了","actions":[{"kind":"write","rows":[{"patch":{"type_tag":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
});
it('值里带括号的字符串不把栈带偏', () => {
expect(parseJsonLoose('{"a":"x}y]z","b":2}')).toEqual({ a: 'x}y]z', b: 2 });
});
it('转义引号照认', () => {
expect(parseJsonLoose('{"a":"he said \\"}\\" ok"}')).toEqual({ a: 'he said "}" ok' });
});
it('实在不是 JSON:undefined,不抛', () => {
expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
});
});
@@ -108,38 +108,55 @@ export function parseJsonLoose(text: string): unknown {
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
*
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 ——
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。
* 扫法是**真正的括号栈**,不是单数花括号(2026-08-02 又栽一次才改的):
* 关思考的模型偶发多吐一个闭括号(`…}}]}},{…` 该是 `…}}]},{…`),
* 只数花括号的计数被它带到零,切出的半截连 `[` 都没关上 —— 解析失败,
* 整拍(六个动作全对)退化成一张「需要你判断下一步」的兜底卡。
* 栈知道当下该关的是哪个括号:**对不上的闭括号当噪声丢掉**(自修这类手滑;
* 丢的只是一个多余字符,不发明任何内容),栈清空即取到第一个完整值,
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
*/
function firstBalanced(text: string): unknown {
const start = text.search(/[[{]/);
if (start < 0) return undefined;
const open = text[start];
const close = open === '{' ? '}' : ']';
let depth = 0;
const stack: string[] = [];
const kept: string[] = [];
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
const ch = text[i] as string;
if (inString) {
kept.push(ch);
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) {
if (ch === '"') {
inString = true;
kept.push(ch);
continue;
}
if (ch === '{' || ch === '[') {
stack.push(ch === '{' ? '}' : ']');
kept.push(ch);
continue;
}
if (ch === '}' || ch === ']') {
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢
stack.pop();
kept.push(ch);
if (stack.length === 0) {
try {
return JSON.parse(text.slice(start, i + 1));
return JSON.parse(kept.join(''));
} catch {
return undefined;
}
}
continue;
}
kept.push(ch);
}
return undefined;
}
+9 -3
View File
@@ -50,11 +50,17 @@
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦,
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
- 写完把该项收了(未 Active 的就地过一下 Active)。
- **人工激活的项:模型显式动它 = 启动它**(2026-08-02 拆墙,`readyToWork`)。
从前它要求先发显式 `start`,不发就拒;提示词与拒回都写全之后实测仍熔断
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { createTurnRunner, handsOff, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -521,12 +521,14 @@ describe('干活前先站上场', () => {
* 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed,
* 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来,
* 连拒五拍熔断交人,而那几行数据本身合法合规。
* 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动):
* 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。
*/
it('标了人工激活的项:写照落、不挂项、项不动 —— 挂项是记账,不是授权', async () => {
it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active');
expect(outcome.status).toBe('running');
});
@@ -629,11 +631,18 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
it('标了人工激活的项仍要先 start —— 那条纪律管的是触达世界与关键交付', async () => {
/*
* **人工激活的项:动它就算启动**(2026-08-02 拆墙)。
* 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西
* (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单
* 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着,
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
*/
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
});
it('编出来的项收不了', async () => {
@@ -784,7 +793,7 @@ describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区
});
it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => {
for (const kind of ['ask', 'human', 'decide', 'listen', 'submit']) {
for (const kind of ['human', 'decide', 'listen', 'submit']) {
expect(handsOff([{ kind }]), kind).toBe(true);
}
});
@@ -878,13 +887,11 @@ describe('画面不再静默丢帧', () => {
});
/**
* 补全卡 —— 对话卡族,与执行工单是两件事。
*
* SOP 上 A1-S2「按缺口清单追问补全」画的就是它。从前 Agent **发不出对话卡**:
* 行动型里只有 `human` 且只能选执行 / 验收两种工单,于是那一步只能拿执行工单顶 ——
* 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
* **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡:
* 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里;
* 别的调用方直发就如实拒并指对路(human + execution)。
*/
describe('补全卡', () => {
describe('补全卡退役:引擎不再发 elicitation 卡', () => {
const ASK = {
kind: 'ask',
itemId: 'i1',
@@ -892,18 +899,9 @@ describe('补全卡', () => {
fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }],
};
it('发出去即挂起等填 —— 调人 = 立监听听人', async () => {
it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toEqual([
{ workOrder: 'elicitation', card: { ask: '这两条缺交期,麻烦补一下', fields: ['NE555P_lead_time'] } },
]);
});
it('一个格子都没有就拒发 —— 空表单等于没问', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...ASK, fields: [] } });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
});
@@ -1225,6 +1223,30 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.issued).toHaveLength(0);
});
/*
* **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。
* 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」,
* 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着,
* 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。
*/
it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => {
const h = harness({
parsed: DONE_MODEL,
runtime: DONE_RUNTIME,
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } },
trail: [
{ kind: 'card-sent', title: '请审定这份清单' },
{ kind: 'card-reply', title: '请审定这份清单' },
] as never,
});
const outcome = await h.runner.runTurn(TASK);
// 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒
expect(h.issued).toHaveLength(0);
expect(outcome.status).not.toBe('awaiting');
// 收掉的项原样收着 —— 无事发生不该顺手把它推回场上
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => {
const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } });
const outcome = await h.runner.runTurn(TASK);
@@ -1233,3 +1255,44 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
});
/**
* **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。
*
* 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) });
it('没发过这一问 → 照发', () => {
expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send');
});
it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo');
});
it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => {
const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' });
});
it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => {
const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')];
expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo');
});
it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
});
});
@@ -101,9 +101,16 @@ function payloadOf(detail: Record<string, unknown> | undefined, limit: number =
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
*/
const chosen = detail['value'];
const note = detail['note'];
if (typeof chosen === 'string' && chosen !== '') {
const note = detail['note'];
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
} else if (typeof note === 'string' && note !== '') {
/*
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
* (2026-08-02 实测熔断单)。
*/
parts.push(note);
}
const output = detail['output'];
@@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
const x = norm(a);
const y = norm(b);
return x !== '' && x === y;
if (x === '' || y === '') return false;
if (x === y) return true;
/*
* **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张
* 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张
* 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去,
* 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。
* 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守:
* 拦错一问比多问一问更坏,真的新问题不该被吃掉。
*/
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= 6 && long.startsWith(short);
}
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
}
/**
* **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。
*
* 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后
* 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。
* 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。
*
* 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧),
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
asked: string,
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
}
/**
@@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
return actions.some((a) => HANDOFF_KINDS.has(a.kind));
}
const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']);
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
export function sameGist(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
@@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/**
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
*
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况:
* · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是
* 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。
* · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下,
* 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
* 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
* `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
* 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
* 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
*
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
* 只回一句「(不存在)」它下一拍还是照编。
@@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
);
}
if (item.manualActivation) {
throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`);
}
return transitionItem(runtime, itemId, 'Active');
}
@@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
*/
if (changed === 0) {
/*
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
*/
throw new Error(
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`,
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
);
}
await deps.memory.append({
@@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
*
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
*/
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
/*
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
*
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
*
* **同一件事不许问第二遍**(2026-08-01,实测账)。
*
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
@@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const asked = String(face['title'] ?? face['ask'] ?? '').trim();
if (asked !== '') {
const window = await deps.memory.window(taskId, STALL_WINDOW);
const sent = window.filter((t) => t.kind === 'card-sent');
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked));
if (same !== undefined) {
throw new Error(
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
'答案就在你的轨迹里(card-reply 那一条的 values)。' +
'**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' +
'如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' +
'真有别的东西要问,换一件事、换一个问法。',
);
const verdict = resendVerdict(window, asked);
if (verdict.kind !== 'send') {
const same = { title: verdict.answered };
/*
* **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测
* 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得
* 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」,
* 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。
* 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、
* finish 已收项无事发生同族:**按钮按两下不该变成墙**。
* 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实
* 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了,
* 熔断兜底要看得见它。
*/
const echoTitle = echoTitleOf(same.title);
if (verdict.kind === 'reject') {
throw new Error(
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
'答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' +
'不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' +
'真有别的东西要问,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场
return { runtime, status: 'running' };
}
}
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
@@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
case 'ask': {
/*
* 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。
*
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 ——
* 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
* `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
* (别的调用方直发)就如实拒,并指对路。
*/
if (action.fields.length === 0) {
throw new Error(
'补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问',
);
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.ask({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: action.ask,
fields: action.fields,
...(action.basis === undefined ? {} : { basis: action.basis }),
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: advanced, status: 'awaiting' };
throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
}
case 'entry': {
@@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
*/
if (action.options.length < 2) {
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡');
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单');
}
/*
* 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后
* 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。
*
* **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次,
* 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`),
* 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。
*/
const window = await deps.memory.window(taskId, STALL_WINDOW);
const verdict = resendVerdict(window, action.ask);
if (verdict.kind !== 'send') {
const echoTitle = echoTitleOf(verdict.answered);
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回
return { runtime, status: 'running' };
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.decide({
@@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/*
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
*
* 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活
* 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
* start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start:
* 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。
* start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
* 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
* 收它的只能是模型自己的这一下 finish。
*/
const { parsed: finishModel } = await loaded(taskId);
/*