Archived
脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 —— 脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试 「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0 (第五例停在大模型账户余额不足,不是代码问题)。 平台侧(六条): - L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。 契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算), `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。 - M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**; 现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。 - N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** —— 下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把 「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。 - O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。 - P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。 - Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有 = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。 活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、 ⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账; S0_bind 写明发录入卡那一拍别抢写需求行。 e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。 门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/* (工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
|
||||
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
|
||||
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
|
||||
*/
|
||||
/**
|
||||
* **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
|
||||
*
|
||||
* 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
|
||||
* 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
|
||||
* `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
|
||||
* 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
|
||||
* finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
|
||||
* **指向两扇打不开的门,比不指还糟。**
|
||||
*
|
||||
* 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
|
||||
* 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
|
||||
*/
|
||||
export function unmetWhy(doneWhen: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
|
||||
const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
|
||||
if (idle.length === 0) return head;
|
||||
return (
|
||||
`${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
|
||||
'说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
|
||||
'别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
|
||||
);
|
||||
}
|
||||
|
||||
export function sameQuestion(a: string, b: string): boolean {
|
||||
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
||||
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
|
||||
return short.length >= 6 && long.startsWith(short);
|
||||
}
|
||||
|
||||
/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
|
||||
export function earlyFinishTitleOf(itemId: string): string {
|
||||
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
|
||||
}
|
||||
|
||||
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
||||
export function echoTitleOf(answered: string): string {
|
||||
return `这张卡人已答过,没有再发(「${answered}」)`;
|
||||
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
|
||||
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
||||
*/
|
||||
export function resendVerdict(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
asked: string,
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: 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 replies = window.filter((t) => t.kind === 'card-reply');
|
||||
const replied = new Set(replies.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 };
|
||||
/*
|
||||
* **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
|
||||
* 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
|
||||
* 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
|
||||
* 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
|
||||
* 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
|
||||
*/
|
||||
const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
|
||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
|
||||
*
|
||||
* 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
|
||||
* 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
|
||||
* 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
|
||||
* 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
|
||||
*
|
||||
* 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
|
||||
* **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
|
||||
*
|
||||
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
||||
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
||||
*/
|
||||
export function answeredCardFact(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
): string {
|
||||
let at = -1;
|
||||
for (let i = window.length - 1; i >= 0; i -= 1) {
|
||||
if (window[i]?.kind === 'card-reply') {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const reply = at < 0 ? undefined : window[at];
|
||||
if (reply === undefined) return '';
|
||||
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
|
||||
if (newer) return '';
|
||||
const value = reply.detail?.['value'];
|
||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
||||
return chose === undefined
|
||||
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
||||
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
||||
*/
|
||||
doneWhenOf?: (itemId: string) => boolean | undefined,
|
||||
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
|
||||
/**
|
||||
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
||||
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
||||
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
|
||||
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
(verdict.chose === undefined
|
||||
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
|
||||
: `**他选的是「${verdict.chose}」** —— `) +
|
||||
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
|
||||
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
|
||||
note:
|
||||
verdict.chose === undefined
|
||||
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
|
||||
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回
|
||||
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
||||
*/
|
||||
const doneWhenNow = doneWhenOf?.(action.itemId);
|
||||
if (doneWhenNow === false) {
|
||||
throw new Error(
|
||||
if (doneWhenNow?.ok === false) {
|
||||
const say =
|
||||
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
||||
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
|
||||
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
|
||||
'基座会自己收,不必你再 finish。',
|
||||
);
|
||||
'不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
|
||||
doneWhenNow.why;
|
||||
/*
|
||||
* **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
|
||||
* 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
|
||||
* 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
|
||||
* 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
|
||||
* 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
|
||||
* 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
|
||||
* 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
|
||||
*/
|
||||
const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
|
||||
(t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
|
||||
).length;
|
||||
if (seen === 0) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: earlyFinishTitleOf(action.itemId),
|
||||
detail: { internal: true, note: say },
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
throw new Error(say);
|
||||
}
|
||||
const ready = readyToWork(finishModel, runtime, action.itemId);
|
||||
await deps.memory.append({
|
||||
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
: [
|
||||
`【当前这一步 · ${current.item}】${current.intent}`,
|
||||
currentTouch,
|
||||
answeredCardFact(trail),
|
||||
current.guide,
|
||||
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
||||
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
||||
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const step = stepOf(itemId);
|
||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
||||
try {
|
||||
return deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
|
||||
} catch {
|
||||
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user