Archived
**I6 · 配方只认 `liveStep`**:`current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`), 它不过滤 `settledIds`。当初这只影响提示词内容;配方接进来之后它驱动**副作用** —— 再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。兜底继续只服务提示词。 同处 `settledIds` 改取 sweep 之后的 `stateRuntime`(`liveStepOf` 吃的是 sweep 之后的 `stateEnabled`,两边得同一份局面)。 **注**:这一形状(既已收掉又在场)我没能在当前 lifecycle 里构造出可达来路 —— `publishEnabled` 把 Completed/Terminated 挡在 entries 与 inProgress 之外, `reenter` 又是就地改状态、不留旧实例。用例用的是 `live-step.ts` 注释描述的那种局面 (同 id 两份实例),守的是不变式;详见最终报告。 **F1 · `afterSave` 只在有未答的卡时才 `wake`**:`wake` 对 awaiting-event / awaiting-human 一律拨成 running,推进器随即推一拍并**记一步预算**。而现有九个活动包一个 auto 都没有: 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human (等人裁决)时被叫醒,那一拍结束还会把它写成 awaiting-event —— 从「等人办事」降级成 「等外部回音」,而等的明明还是人。**这是界面撒谎。**「保存即铺格」的场景恰恰都有卡在场。 用既有的 `humanTool.pendingCards`,不新造。`wake` 失败不再 `.catch(() => false)` 静默 —— 它是「保存即铺格」的唯一触发器,吞掉等于人按了保存、格子没出现、系统一个字不说。 **F3 · `prune` 不在人填的时候撤行**:人在件上「加一行」加出来的行,配方的数据源里当然没有 —— 编辑中途把它悄悄撤掉是最不能接受的一种静默数据丢失。有卡在等人(quiet)那几拍只铺不撤; 同一张表既被 prune 又列在该步 `humanWrites` 里,装包报 warn;规范 §4.6 纪律补两条 (prune 的表不该是人能加行的表 · card 之后那几笔要等人交回之后那一拍才跑)。 I6 2 测(做过变异验证)· F1 5 测 · F3 2+1 测。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2682 lines
148 KiB
TypeScript
2682 lines
148 KiB
TypeScript
/**
|
|
* loop 回合驱动 —— 语义引擎 × Agent 回路(编码规范 §5.3 / §6.3;语义出处 AM 04)。
|
|
*
|
|
* 定位 → 择向 → 决断 → 行动 → 写数据 → 回到定位。
|
|
* **回合由 usecase 驱动,不放开自由 maxSteps;预算在环外由 Harness 强制。**
|
|
*/
|
|
import type { LoopModel, PlanStep, TaskId } from '@contracts';
|
|
import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract.ts';
|
|
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
|
import type {
|
|
AgentPort,
|
|
AnalyzeGateway,
|
|
CardGateway,
|
|
Clock,
|
|
ListenerRegistrar,
|
|
LoopStateStore,
|
|
MemoryRecorder,
|
|
SkillGateway,
|
|
ViewGateway,
|
|
Sfeel,
|
|
TaskContext,
|
|
TaskDataGateway,
|
|
ToolGateway,
|
|
} from '../../port.ts';
|
|
import { parseCmmn } from '../domain/cmmn-parse.ts';
|
|
import type { CaseModel } from '../domain/cmmn-parse.ts';
|
|
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
|
import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts';
|
|
import {
|
|
achieveMilestones,
|
|
autoCompleteReady,
|
|
criterionKey,
|
|
readingsOf,
|
|
initialRuntime,
|
|
markWorldTouched,
|
|
navigate,
|
|
occur,
|
|
publishEnabled,
|
|
reenter as reenterItem,
|
|
transitionItem,
|
|
} from '../domain/lifecycle.ts';
|
|
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
|
import { liveStepOf } from '../domain/live-step.ts';
|
|
import { phaseProgress } from '../domain/phase.ts';
|
|
|
|
export type TurnRunnerDeps = {
|
|
readonly state: LoopStateStore;
|
|
readonly sfeel: Sfeel;
|
|
readonly data: TaskDataGateway;
|
|
readonly agent: AgentPort;
|
|
readonly tools: ToolGateway;
|
|
readonly cards: CardGateway;
|
|
readonly listeners: ListenerRegistrar;
|
|
readonly memory: MemoryRecorder;
|
|
readonly views: ViewGateway;
|
|
readonly skills: SkillGateway;
|
|
readonly tasks: TaskContext;
|
|
readonly clock: Clock;
|
|
readonly analyze: AnalyzeGateway;
|
|
};
|
|
|
|
/**
|
|
* 轨迹行里的**实质内容** —— 人答了什么、工具返回了什么。
|
|
*
|
|
* 只给标题的话,「crm.history-price-read 返回」这一行读起来像事情办成了,
|
|
* 而它到底返回了什么,Agent 一个字看不见 —— 于是它照自己的想象往台账里写:
|
|
* 实测三条料的历史价全被写成「无历史 9999 / 生命周期 Unknown」,而 CRM 替身里
|
|
* 三条都有真数据(30 天在产 / 240 天波动 / 15 天 EOL)。
|
|
* 打标于是全成了 B,该判 A 的直通没了、该判 C 的选型也没了 —— **一步看不见,整单判错**。
|
|
*
|
|
* 同理「执行工单回传」也要带上人填的值,不然既写不进台账,也只能再问一遍。
|
|
*
|
|
* 截断是必要的:一次批量工具调用的出参能有几千字,整段铺进去会把局面挤出上下文。
|
|
* 截断留个明记号,让 Agent 知道自己看的是节选。
|
|
*/
|
|
const TRAIL_PAYLOAD_LIMIT = 600;
|
|
/**
|
|
* **最近一拍的出参给足**(2026-07-31)。
|
|
*
|
|
* 批量工具的出参长度随行数走:一单九颗料查历史价,回参就有九组判据、900 多字。
|
|
* 截到 600 字的后果不是「少看几个字」——模型看见前五颗的真判据、后四颗被截掉,
|
|
* 于是**它把剩下的编了出来**:给客户点名要替代的那颗写「在产 · 15 天」,判成 A 直接快答。
|
|
* 编出来的判据比没有判据坏得多,它看起来很像真的(实测,连编三次)。
|
|
* 所以最近这几条按批量出参的实际体量给,旧的照旧压缩 —— 旧账在台账里,不必反复读。
|
|
*/
|
|
const TRAIL_RECENT_LIMIT = 3000;
|
|
|
|
function payloadOf(detail: Record<string, unknown> | undefined, limit: number = TRAIL_PAYLOAD_LIMIT): string {
|
|
if (detail === undefined) return '';
|
|
const parts: string[] = [];
|
|
|
|
const values = detail['values'];
|
|
if (typeof values === 'object' && values !== null && !Array.isArray(values)) {
|
|
const entries = Object.entries(values as Record<string, unknown>);
|
|
if (entries.length > 0) {
|
|
parts.push(`回填:${entries.map(([k, v]) => `${k}=${compact(v, limit)}`).join(';')}`);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* **人选了哪个选项要看得见**。
|
|
*
|
|
* 决策卡的回传落在 `detail.value`(外加可选的 `note`),而这里从前只认
|
|
* `values` / `output` / `error` —— 于是轨迹上只有一行光秃秃的「decision 卡回传」。
|
|
* Agent 看不到人选的是哪条路,当然认为没人答:实测它**又发了一张一模一样的决策卡**,
|
|
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
|
|
*/
|
|
const chosen = detail['value'];
|
|
const note = detail['note'];
|
|
if (typeof chosen === 'string' && chosen !== '') {
|
|
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
|
|
} else if (typeof note === 'string' && note !== '') {
|
|
/*
|
|
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
|
|
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
|
|
* (2026-08-02 实测熔断单)。
|
|
*/
|
|
parts.push(note);
|
|
}
|
|
|
|
const output = detail['output'];
|
|
if (output !== undefined && output !== null) parts.push(`出参:${compact(output, limit)}`);
|
|
|
|
const error = detail['error'];
|
|
if (typeof error === 'string' && error !== '') parts.push(`错误:${error}`);
|
|
|
|
return parts.length === 0 ? '' : ` —— ${parts.join(' | ')}`;
|
|
}
|
|
|
|
function compact(value: unknown, limit: number = TRAIL_PAYLOAD_LIMIT): string {
|
|
const text = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
return text.length <= limit ? text : `${text.slice(0, limit)}…(已截断)`;
|
|
}
|
|
|
|
|
|
/**
|
|
* 尾部**连续被拒**了几拍 —— 死循环的度量。
|
|
*
|
|
* 判据只看「有没有推进」:`行动被拒` 算没推进,写台账 / 调工具 / 迁移算推进。
|
|
* `thought`(装配技能)与 `narration`(对人交代)每拍都有,不算推进也不算没推进 ——
|
|
* 拿它们当分隔符会把连续的被拒切断,数出来永远是 1。
|
|
*
|
|
* 为什么要数:预算会烧完,但那是二三十万 token 之后的事。一个模型反复提同一个
|
|
* 非法行动、每拍被拓扑拒回来,人在界面上只看到「进行中」一动不动 ——
|
|
* 那不是「在办」,是卡死了。Harness 的活就是在这时候把它交回给人。
|
|
*/
|
|
/**
|
|
* 工具入参的短哈希 —— 「同一份入参重调」检查的账。
|
|
* 存整份入参不行(轨迹要进提示词,一份 contacts 数组就是几百字);djb2 足够认同一性,
|
|
* 碰撞的代价只是一次多余的拦截提示,不是数据错误。
|
|
*/
|
|
/** 「验收被打回重做」那条痕的抬头 —— 写它与找它的是同一个常量,改一处两处都跟着改 */
|
|
const REDO_MARK = '验收被打回重做';
|
|
/**
|
|
* 「打回之后没改就再提交」那次拒回里的记号句 —— 软硬两段靠它数第二次。
|
|
* 认的是**句子**不是抬头:同一条拒回经两条路留痕,抬头各不相同
|
|
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
|
*/
|
|
const REFUSE_MARK = '打回之后台账一个字没改';
|
|
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
|
const AUTO_FAIL_MARK = '配方办不下去';
|
|
|
|
export function toolInputHash(input: unknown): string {
|
|
const text = JSON.stringify(input) ?? '';
|
|
let hash = 5381;
|
|
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
|
return `${text.length}:${(hash >>> 0).toString(16)}`;
|
|
}
|
|
|
|
export function stalledTurns(trail: ReadonlyArray<{ kind: string; title: string }>): number {
|
|
let count = 0;
|
|
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
|
const entry = trail[i]!;
|
|
if (entry.kind === 'thought' || entry.kind === 'narration') continue;
|
|
if (entry.kind === 'result' && entry.title.startsWith('行动被拒')) {
|
|
count += 1;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
/*
|
|
* 连续计数**拦不住会「掺沙子」的原地打转**。
|
|
*
|
|
* 实测栽过一次:提交放行被拒(判据名对不上),模型的反应是「那我先 judge 一下再提交」——
|
|
* 于是轨迹成了「拒 → 评估 → 拒 → 评估 →…」,评估那一条把连续段打断,
|
|
* 计数每次归零,熔断永远不触发。32 次被拒,预算烧到 exhausted 才停。
|
|
*
|
|
* 所以再看一眼**同一个理由重复了多少次**:理由一模一样就是同一堵墙,
|
|
* 中间做了什么都不算推进。取两者的大者。
|
|
*/
|
|
const lastReject = [...trail].reverse().find((t) => t.kind === 'result' && t.title.startsWith('行动被拒'));
|
|
if (lastReject === undefined) return count;
|
|
const sameWall = trail.filter(
|
|
(t) => t.kind === 'result' && t.title.startsWith('行动被拒') && rejectReason(t.title) === rejectReason(lastReject.title),
|
|
).length;
|
|
return Math.max(count, sameWall);
|
|
}
|
|
|
|
/**
|
|
* 拒绝的**理由**部分 —— 把「行动被拒(这一串的第 N 个):」这个前缀削掉再比。
|
|
*
|
|
* 不削就比不出同一堵墙:一拍一串行动,同一个理由拒回来时前缀里的序号各不相同
|
|
* (「第 2 个」「第 5 个」),字符串一比就成了 33 堵不同的墙,熔断永不触发。
|
|
* 实测代价:33 次提交被同一条判据拒回,40 万 token 烧到 exhausted 才停,
|
|
* 而那期间界面上只是「进行中」一动不动 —— 正是熔断该管的那种局面。
|
|
*/
|
|
function rejectReason(title: string): string {
|
|
return title.replace(/^行动被拒(\([^)]*\))?[::]\s*/, '');
|
|
}
|
|
|
|
/**
|
|
* 两句问话是不是同一件事 —— 归一之后比,不做模糊匹配。
|
|
*
|
|
* 卡的标题常带序号与变体(「…缺失,请向客户确认」/「…年用量缺失,请确认」),
|
|
* 所以认的是**问句**;而问句里的标点、空格、全半角在两次生成之间常常不一样,
|
|
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
|
|
* 一模糊就会把「问 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();
|
|
const x = norm(a);
|
|
const y = norm(b);
|
|
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 earlyFinishTitleOf(itemId: string): string {
|
|
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
|
|
}
|
|
|
|
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
|
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; readonly detail?: Record<string, unknown> | undefined }>,
|
|
asked: string,
|
|
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
|
|
const trimmed = asked.trim();
|
|
if (trimmed === '') return { kind: 'send' };
|
|
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;
|
|
/*
|
|
* **他选的是什么,当场摆出来**(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} → ${chosenGuidance(chose)}`;
|
|
}
|
|
|
|
/**
|
|
* **人选了什么,下一步就该怎么走** —— 拒回、观察、事实行三处同一句话,按答案分岔。
|
|
*
|
|
* 从前不分岔,一律说「照这个选择把结论落进台账那面旗,再 finish」—— 对「通过」是对的,
|
|
* 对「重做」是把模型往错路上推:2026-08-27 W05 实测,人两次答 `custom:重做一次`,模型照这句
|
|
* 写了 review_approved_total = 1,审核那一步就被收掉、一路推到放行。
|
|
* `custom:` 是平台定义的自由答复,平台知道它**不是任何一个选项**;选项值的语义平台不知道,只能指回说明书。
|
|
*/
|
|
export function chosenGuidance(chose: string | undefined): string {
|
|
if (chose === undefined) return '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值),照说明书里那一档办。';
|
|
if (chose.startsWith('custom:')) {
|
|
const said = chose.slice(7).trim();
|
|
return (
|
|
`**他没选任何一个选项,写了自己的话:「${said}」** —— 这不是通过。按他写的去改台账(同 PK 覆盖写),` +
|
|
'改完**换一种问法**再发一张新卡(标题里写明这一轮改了什么;字面一样的卡会被当成同一张吞掉);' +
|
|
'**不许写「已通过」那类记账旗、不许 finish 这一项**。'
|
|
);
|
|
}
|
|
return `**他选的是「${chose}」** —— 照说明书里「${chose}」那一档办(通过才收步;驳回就改台账、换问法重发)。`;
|
|
}
|
|
|
|
/**
|
|
* 两句交代**说的是不是同一件事** —— 看字的重合度,不看字面是否相同。
|
|
*
|
|
* 「一字不差」拦不住模型的复读:它每拍换个说法把同一件事再说一遍
|
|
* (「9颗料都打上标了:7颗A…」/「这单9颗料都打标完了:5颗A直接报…」),
|
|
* 人在对话区读到的是同一句话说了五遍。
|
|
*
|
|
* 归一后取**短句的字有多少出现在长句里**:八成以上算同一件事。
|
|
* 阈值不敢再低 —— 近义但不同的交代(「我去查历史价」/「查出来了,7 颗有近期成交」)
|
|
* 重合度远低于此;**拦错一句比多说一句更坏**,那会让人以为 AI 没干活。
|
|
*/
|
|
/**
|
|
* **这一拍要把控制权交出去吗** —— 交代该不该进对话区,看的就是它。
|
|
*
|
|
* 口径(2026-08-01 用户定):设定目标 →(看现状 → 定做法 → 动手)× N 轮 → **交代一次**。
|
|
* 一轮的收口就是「轮到人了」:发卡问人(ask)、派工单(human)、摆岔路(decide)、
|
|
* 立监听等外部(listen)、提交放行(submit)。埋头办事的那些拍
|
|
* (write / tool / finish / start / judge …)不出气泡 —— 那一段由「AI 工作中」的办事条覆盖。
|
|
*
|
|
* 起因是用户实测的一段对话区:连着五条「阶段结论」,四条在播报自己下一步要动哪张表
|
|
* (「我这就把标写进分类表,收掉这一步」)。那些不是给人的话,是工序播报:
|
|
* 人既不用知道也帮不上忙,攒五条也没告诉他一件事。
|
|
*
|
|
* 为什么按「打算做什么」判而不是「做成了什么」:交代必须赶在动手之前说出口
|
|
* (人要先看见「我要去办什么」,再看见卡)。等这一拍跑完再补,顺序就反了。
|
|
* 代价是模型说要发卡却被拒的那一拍会白说一句 —— 那种拍本来就该让人看见。
|
|
*/
|
|
export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
|
|
return actions.some((a) => HANDOFF_KINDS.has(a.kind));
|
|
}
|
|
|
|
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
|
|
|
|
/**
|
|
* **这一步是不是终局放行那一步**(2026-08-27,W05 实测:模型在 T_release 先发了一张执行工单
|
|
* 「评审放行与落账确认」,人答完它才 submit —— 同一件事问了人两遍)。
|
|
* 认法:剧本给它声明了人工验收清单、又不是审核卡那一步 —— 放行卡由 `submit` 发,不该再有别的卡。
|
|
*/
|
|
export function isReleaseStep(step: { acceptanceChecklist?: ReadonlyArray<string> | undefined; cardVariant?: string | undefined } | undefined): boolean {
|
|
return step !== undefined && step.acceptanceChecklist !== undefined && step.acceptanceChecklist.length > 0 && step.cardVariant !== 'review';
|
|
}
|
|
|
|
export function sameGist(a: string, b: string): boolean {
|
|
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
|
|
const x = norm(a);
|
|
const y = norm(b);
|
|
if (x === '' || y === '') return false;
|
|
if (x === y) return true;
|
|
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
|
|
// 太短的句子不比重合度:「好的」「查完了」这种两三个字的,随便撞上就成了「同一句」
|
|
if (short.length < 12) return false;
|
|
const pool = new Set(long);
|
|
let hit = 0;
|
|
for (const ch of short) if (pool.has(ch)) hit += 1;
|
|
return hit / short.length >= 0.8;
|
|
}
|
|
|
|
/** 连续这么多拍推不动就交回给人 —— 再问下去只是烧钱 */
|
|
const STALL_LIMIT = 5;
|
|
|
|
/**
|
|
* 熔断检测自己的窗口 —— 与叙事窗分开。
|
|
*
|
|
* 叙事窗是「模型每拍要看多少历史」(越大越贵);这个是「同一堵墙撞了几次」,
|
|
* 只在引擎里数,不进提示词,所以可以大方点。取 60:一次撞墙连带五六条留痕,
|
|
* 够看清十次撞墙。
|
|
*/
|
|
const STALL_WINDOW = 60;
|
|
|
|
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|
const autoRunner = createAutoRunner({
|
|
data: deps.data,
|
|
cards: deps.cards,
|
|
memory: deps.memory,
|
|
analyze: deps.analyze,
|
|
sfeel: deps.sfeel,
|
|
});
|
|
|
|
async function loaded(taskId: TaskId) {
|
|
const found = await deps.state.load(taskId);
|
|
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
|
|
return found;
|
|
}
|
|
|
|
/** 变化面:任务视图里有值的键 —— onPart 的「件到」判据 */
|
|
function changedRefs(scope: Record<string, unknown>): Set<string> {
|
|
return new Set(Object.keys(scope).filter((k) => scope[k] !== undefined && scope[k] !== null));
|
|
}
|
|
|
|
async function publishFor(taskId: TaskId) {
|
|
const { model, parsed, runtime: loadedRuntime } = await loaded(taskId);
|
|
const scope = await deps.data.view(taskId);
|
|
const changed = changedRefs(scope);
|
|
|
|
/*
|
|
* 前提达成即达成的里程碑,引擎自己收 —— 没有谁去按里程碑(见 `achieveMilestones`)。
|
|
* 达成了要留痕:里程碑是人看进度的锚点,悄悄翻过去等于没这回事。
|
|
*/
|
|
const milestones = achieveMilestones(parsed, loadedRuntime, scope, changed, deps.sfeel.condition);
|
|
const runtime = milestones.runtime;
|
|
if (milestones.achieved.length > 0) {
|
|
await deps.state.save(taskId, model, parsed, runtime);
|
|
for (const id of milestones.achieved) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `里程碑达成:${parsed.items.find((i) => i.id === id)?.name ?? id}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 目标缺口 = 未完成的**交付项**。事件监听器不算 —— 它是等待岗不是成品,
|
|
* 摆进缺口里只会让 Agent 每拍都看见「客户改需求」这种自己推不动的东西,
|
|
* 以为局面还差一大截。
|
|
*/
|
|
const gaps = parsed.items
|
|
.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'))
|
|
.filter((i) => runtime.items.find((r) => r.id === i.id)?.state !== 'Completed')
|
|
.map((i) => i.name);
|
|
return {
|
|
model,
|
|
parsed,
|
|
runtime,
|
|
scope,
|
|
enabled: publishEnabled(parsed, runtime, scope, changed, deps.sfeel.condition, gaps),
|
|
};
|
|
}
|
|
|
|
/*
|
|
* **项号挂不上**(没给 / 编的一个不存在的号)—— 与「这一步已经办完了」是两件事:
|
|
* 前者只是记账没对上(写、调工具、发卡都照办,留痕注明),后者是真墙(别往办完的事上派活)。
|
|
* 分成两种错误型,调用方才好各按各的处置;混成一句,要么该拦的不拦,要么该放的不放。
|
|
*/
|
|
class ItemUnattachableError extends Error {}
|
|
|
|
/**
|
|
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
|
|
*
|
|
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
|
|
* 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
|
|
* `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
|
|
* 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
|
|
* 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
|
|
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
|
|
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
|
|
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
|
|
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
|
|
*
|
|
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
|
|
* 只回一句「(不存在)」它下一拍还是照编。
|
|
*/
|
|
function readyToWork(model: CaseModel, runtime: CaseRuntime, itemId: string): CaseRuntime {
|
|
const item = model.items.find((i) => i.id === itemId);
|
|
if (item === undefined) {
|
|
const legal = runtime.items
|
|
.filter((i) => i.state === 'Available' || i.state === 'Enabled' || i.state === 'Active')
|
|
.map((i) => i.id);
|
|
// 空字符串不是「不存在的项」,是**没给项号** —— 两件事说成一句,读的人只能猜
|
|
const what = itemId.trim() === '' ? '没给计划项号' : `计划项不存在:${itemId}`;
|
|
throw new ItemUnattachableError(`${what}(可选:${legal.join('、') || '无'})`);
|
|
}
|
|
/*
|
|
* 父阶段没开着就不许在里面干活 —— 不然 CMMN 的阶段门是装饰。
|
|
* 实测踩过:S_grade 还没 start,里面的「查历史价」就被就地激活跑掉了,
|
|
* 于是阶段的入场前提(三要素齐)从来没被验过。
|
|
*/
|
|
if (item.parent !== undefined) {
|
|
const parent = runtime.items.find((i) => i.id === item.parent)?.state;
|
|
if (parent !== 'Active') {
|
|
throw new Error(`${itemId} 在阶段 ${item.parent} 里,而那个阶段当下是 ${parent} —— 先 start 阶段`);
|
|
}
|
|
}
|
|
const state = runtime.items.find((i) => i.id === itemId)?.state;
|
|
if (state === 'Active') return runtime;
|
|
/*
|
|
* **办完的项就是办完了 —— 别把模型指回一条走不通的路**(2026-08-01 实测逼出来的)。
|
|
*
|
|
* 从前这里一句话打发所有状态:「要先 start 再干活 —— 当下是 Completed」。
|
|
* 而 `start` 一个 Completed 项同样会被拒(非法迁移)—— 模型照这句话去做,
|
|
* 撞第二堵墙,再回来撞第一堵,连拒五拍熔断(实测 c5 那一单死于此:
|
|
* S3_confirm 收完之后模型又想往它上面发一张卡)。
|
|
* **拒回的话是模型下一拍的行动指南**;指错了方向,比不给指引更糟。
|
|
*
|
|
* 所以按状态分开说:收了的说「收了,挂到当下这一步去」并列出当下可为的项;
|
|
* 真正只差 start 的才说 start。
|
|
*/
|
|
if (state === 'Completed' || state === 'Disabled') {
|
|
const live = runtime.items
|
|
.filter((i) => i.state === 'Available' || i.state === 'Enabled' || i.state === 'Active')
|
|
.map((i) => i.id);
|
|
throw new Error(
|
|
`${itemId} 这一步已经${state === 'Completed' ? '办完收掉' : '停用'}了,不能再往它上面挂活 —— ` +
|
|
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
|
|
);
|
|
}
|
|
return transitionItem(runtime, itemId, 'Active');
|
|
}
|
|
|
|
/**
|
|
* 推一帧画面。
|
|
*
|
|
* 模版名**不硬编码**:从前每拍推一个固定的 `case-overview`,而活动声明的模版
|
|
* 未必叫这个名字(A1 的四张一个都不叫)—— 于是每一帧都被悄悄丢掉,
|
|
* 工作区从头到尾空白,日志里连一个字都没有。静默失败比报错坏:
|
|
* 它让「没画面」看起来像「活动还没产出」。
|
|
*
|
|
* 所以:要的那张没有,就退到活动声明的**第一张**(§9.3「一个局面一张模版」——
|
|
* 第一张按约定是全景);一张都没声明才真的无画面可推,那是活动自己的选择。
|
|
*/
|
|
async function showView(
|
|
taskId: TaskId,
|
|
template: string,
|
|
runtime: CaseRuntime,
|
|
/** 这一步剧本声明的画面 —— 模型报了个不存在的名字时退到它,而不是列表第一张 */
|
|
preferred?: string,
|
|
): Promise<{ pushed: boolean; templates: string[] }> {
|
|
const { activityId } = await deps.tasks.of(taskId);
|
|
const { parsed } = await loaded(taskId);
|
|
const scope = await deps.data.view(taskId);
|
|
const enabled = publishEnabled(parsed, runtime, scope, changedRefs(scope), deps.sfeel.condition, []);
|
|
const frame = {
|
|
taskId,
|
|
activityId,
|
|
scope: {
|
|
...scope,
|
|
'case:stages': parsed.items
|
|
.filter((i) => i.type === 'stage')
|
|
.map((i) => ({ name: i.name, state: runtime.items.find((r) => r.id === i.id)?.state ?? '—' })),
|
|
'case:enabled': enabled.entries.map((e) => ({ name: e.name, state: e.requiresManualStart ? '待启动' : '可进行' })),
|
|
'case:gaps': enabled.gaps.map((g) => ({ name: g, state: '未满足' })),
|
|
gapCount: enabled.gaps.length,
|
|
enabledCount: enabled.entries.length,
|
|
activeStage: enabled.activeStages.join('、') || '—',
|
|
},
|
|
};
|
|
const first = await deps.views.push({ ...frame, template });
|
|
if (first.pushed || first.templates.length === 0) return first;
|
|
/*
|
|
* 模型报了个不存在的模版名 —— 兜底退到**这一步该看的那张**(剧本声明的),
|
|
* 不是「列表里的第一张」。
|
|
*
|
|
* 实测栽过:收口那一拍模型写了个包里没有的名字,兜底推了第一张 = 询价受理需求,
|
|
* 于是**放行卡上嵌的是阶段二的执行件** —— 人签的不是他要签的那份。
|
|
* 列表第一张跟当前这一步没有任何关系,拿它兜底等于随机挑一张。
|
|
*/
|
|
const fallback = preferred !== undefined && preferred !== '' && preferred !== template ? preferred : (first.templates[0] ?? '');
|
|
return deps.views.push({ ...frame, template: fallback });
|
|
}
|
|
|
|
/**
|
|
* 计划项号**认简写** —— `S4` → `S4_history`。
|
|
*
|
|
* 模型在说明书里读到的是「S4 查历史价」这样的写法,报 itemId 时常常只写前半截。
|
|
* 从前一律拒回「计划项不存在」,白烧一拍;而这类简写**歧义为零时没有猜的成分**:
|
|
* 只在**恰好唯一**一个计划项以它开头时才认(多于一个照旧拒,免得替它选错项)。
|
|
* 宽进的是称呼,不是权限:认出来之后照旧走原来那几道门。
|
|
*/
|
|
function resolveItemId(model: CaseModel, itemId: string): string {
|
|
const raw = itemId.trim();
|
|
if (raw === '' || model.items.some((i) => i.id === raw)) return raw;
|
|
const uniquePrefix = (probe: string): string | undefined => {
|
|
if (probe === '') return undefined;
|
|
const lower = probe.toLowerCase();
|
|
const hit = model.items.filter((i) => i.id.toLowerCase().startsWith(lower));
|
|
return hit.length === 1 ? hit[0]?.id : undefined;
|
|
};
|
|
/*
|
|
* 先拿整串试;不成再把它拆成词逐个试(长的先)——
|
|
* 模型写过 `S4`(说明书里的写法),也写过 `A1-S4`(把活动代号也带上了)。
|
|
* 两种都只是**称呼**上的出入,歧义为零时没有猜的成分。
|
|
*/
|
|
return (
|
|
uniquePrefix(raw) ??
|
|
[...raw.split(/[^A-Za-z0-9]+/).filter((t) => t !== '')]
|
|
.sort((a, b) => b.length - a.length)
|
|
.map(uniquePrefix)
|
|
.find((x) => x !== undefined) ??
|
|
raw
|
|
);
|
|
}
|
|
|
|
async function applyAction(
|
|
taskId: TaskId,
|
|
rawAction: AgentAction,
|
|
runtime: CaseRuntime,
|
|
/**
|
|
* **这一项声明的 doneWhen,现在算出来是真是假**(undefined = 没声明,或算不出来)。
|
|
* 只有 `finish` 用得上——`doneWhen` 从前只管「引擎自己收要不要收」,没管过
|
|
* 「模型自己 finish 让不让」:一个没有 manualActivation 的普通步骤,模型随时能
|
|
* 显式 finish 掉它,doneWhen 是真是假完全不影响这条路(2026-08-22 实测:
|
|
* S1 该写两行候选客户,模型只写成一行就直接 finish,doneWhen 明明为假,
|
|
* 这一步照样被记成「完成」)。**声明了 doneWhen 就是说「这件事完没完是可以算出来的」**——
|
|
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
|
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
|
*/
|
|
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
|
|
/**
|
|
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
|
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
|
* 模型每拍计划「写记账旗 + 发执行卡」,旗的值没变,排在后面的发卡从没执行过,
|
|
* 连拒五拍熔断。值没变的写**什么也没改**,后面行动的前提未变,不该陪葬:
|
|
* 有后续就降级成「无事发生」放行整串;没有后续(串尾/独苗)仍拒 ——
|
|
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
|
|
*/
|
|
hasFollowUp = false,
|
|
/**
|
|
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
|
|
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
|
|
*/
|
|
/**
|
|
* 这张台账「归人填」吗 —— 归人填就返回拒绝理由(剧本 `humanWrites`,见 contracts)。
|
|
* 只拦 `write`:`drop` 得留着,不然模型写错了撤不掉。
|
|
*/
|
|
humanWritesOf?: (itemId: string | undefined, address: string) => string | undefined,
|
|
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
|
|
): Promise<{
|
|
runtime: CaseRuntime;
|
|
status: TurnOutcome['status'];
|
|
reason?: string;
|
|
saved?: true;
|
|
/** 这个行动自己已经把画面摆好了 —— 回合末别再盖一帧上去 */
|
|
framed?: true;
|
|
/** 这个行动发了一张卡给人,卡是哪一步的 —— 回合末的画面跟着它走(见 runTurn 推帧那段) */
|
|
issuedFor?: string;
|
|
}> {
|
|
const context = await deps.tasks.of(taskId);
|
|
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
|
|
const action: AgentAction =
|
|
'itemId' in rawAction && typeof rawAction.itemId === 'string'
|
|
? { ...rawAction, itemId: resolveItemId((await loaded(taskId)).parsed, rawAction.itemId) }
|
|
: rawAction;
|
|
|
|
switch (action.kind) {
|
|
case 'start': {
|
|
/*
|
|
* manualStart —— 进入 Active 永远出自 Agent 的裁量启动,留痕。
|
|
*
|
|
* **已经开着的项再按一下是无事发生,不是非法迁移。** 从前直接推拓扑,
|
|
* 于是「Active → Active」被判非法、这一拍作废:实测里模型会反复按同一个项,
|
|
* 每按一次烧一拍模型调用,预算就这么淌掉。按钮按两下不该报错。
|
|
*/
|
|
const current = runtime.items.find((i) => i.id === action.itemId)?.state;
|
|
if (current === 'Active') {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `${action.itemId} 已在进行中 —— 无需再启动`,
|
|
// 引擎内部观察:进日志不进对话区 —— 对话区不是机器踪迹的副本
|
|
detail: { internal: true },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
/*
|
|
* **已经收掉的项再按一下也是无事发生**(2026-08-01,与上面那条同一个道理)。
|
|
*
|
|
* 从前它落到下面的拓扑迁移里,`Completed → Active` 判非法、这一拍作废。
|
|
* 而模型会走到这儿,多半是因为上一拍被「这一步已经办完」拒过一次 ——
|
|
* 它读懂了「要先 start」,于是来 start,再撞一堵墙。两堵墙对着撞,连拒五拍熔断。
|
|
* 收掉的项不该被重新打开(那会让「办完了」这件事失效),但**说一声比拒一次有用**:
|
|
* 留痕告诉它这一项已收,让它拿这一拍去办别的。
|
|
*/
|
|
if (current === 'Completed') {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `${action.itemId} 已经办完收掉了 —— 不必再启动,挂到当下这一步去办`,
|
|
detail: { internal: true },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
/*
|
|
* **写那一步的名字,不写计划项号**(2026-08-02 用户:日志「太模糊」)。
|
|
* `manualStart:S3_confirm` 对人是天书;而把 id 整个删掉又丢了「办的是哪一步」——
|
|
* 名字本来就在模型里,取出来就是了。
|
|
*/
|
|
title: `开始办:${(await loaded(taskId)).parsed.items.find((i) => i.id === action.itemId)?.name ?? action.itemId}`,
|
|
detail: { by: 'agent' },
|
|
});
|
|
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'running' };
|
|
}
|
|
|
|
case 'plan': {
|
|
await deps.memory.append({ taskId, kind: 'engine-transition', title: `规划裁量项入场:${action.itemId}` });
|
|
return { runtime: transitionItem(runtime, action.itemId, 'Enabled'), status: 'running' };
|
|
}
|
|
|
|
case 'disable': {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `弃用计划项:${action.itemId}`,
|
|
detail: { reason: action.reason },
|
|
});
|
|
return { runtime: transitionItem(runtime, action.itemId, 'Disabled'), status: 'running' };
|
|
}
|
|
|
|
case 'judge': {
|
|
/*
|
|
* 语义性前提的裁量评估 —— 评估留痕,可回放可归因。
|
|
*
|
|
* `criterion` / `satisfied` **必须结构化落进 detail**:验收判据里 L3/L4 是
|
|
* 「环内实证 / 独立评审」那一类,机器判不了,只能由 Agent 当场裁并说明理由。
|
|
* 从前这两个值只出现在标题字符串里,判据投影读不回来 —— 于是 L3/L4 永远算
|
|
* 未达标,一单跑到底也只能是 5/8,人签的那张卡上永远挂着三条红叉。
|
|
*/
|
|
/*
|
|
* **机检判据裁不动** —— 拒了它,别让模型以为自己把这条办了。
|
|
*
|
|
* L1/L2 是拿 S-FEEL 对台账求值的,判据投影根本不读 Agent 对它们的裁定。
|
|
* 从前这里照收:轨迹上留下一句「语义性前提评估:XXX → 满足」,
|
|
* 模型据此 submit,却被门以**同一条判据**拒回;它再裁一遍、再提交一遍……
|
|
* 连拒五拍熔断(实测 A2 死于此)。那句「满足」是假话:
|
|
* 界面不撒谎这条,对 Agent 的回音也一样算数。
|
|
*/
|
|
if ((await deps.tasks.machineCriteria(taskId)).has(criterionKey(action.criterion))) {
|
|
throw new Error(
|
|
`「${action.criterion}」是机检判据(L1/L2),裁定对它不起作用 —— ` +
|
|
'它由台账现值当场求值。要让它达标,去把台账里对应的汇总列写对(通常是各种 _total 计数列),' +
|
|
'judge 只对 L3/L4 这类机器判不了的裁量判据有效。',
|
|
);
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'evaluation',
|
|
title: `语义性前提评估:${action.criterion} → ${action.satisfied ? '满足' : '未满足'}`,
|
|
detail: { criterion: action.criterion, satisfied: action.satisfied, rationale: action.rationale },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
|
|
case 'tool': {
|
|
const { parsed: toolModel } = await loaded(taskId);
|
|
/*
|
|
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
|
|
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调
|
|
* 赌变卦;出参每次原样摆在轨迹里,它照调不误。26 次调用烧穿 500k 预算的前科在案,
|
|
* 而 stalledTurns 只数「行动被拒」,成功的重调它看不见 —— 没有任何兜底)。
|
|
* 头两次照调;期间**没有任何写/撤动作**(世界没变,读出来的只会一样)的第 3 次
|
|
* identical 调用当场拒 —— 拒回是模型反应最好的通道,也归熔断兜底管。
|
|
* 写过之后计数清零:世界变了,重调是合法的。
|
|
*/
|
|
let sameCalls = 0;
|
|
if (!(await deps.tools.isWriteEffect(action.tool))) {
|
|
const hash = toolInputHash(action.input);
|
|
const recent = await deps.memory.window(taskId, STALL_WINDOW);
|
|
for (let i = recent.length - 1; i >= 0; i -= 1) {
|
|
const entry = recent[i]!;
|
|
if (entry.kind === 'action' && typeof entry.detail?.['ledger'] === 'string') break; // 写/撤过,世界变了
|
|
if (entry.kind === 'result' && entry.title.startsWith(`${action.tool} 返回`) && entry.detail?.['inputHash'] === hash) {
|
|
sameCalls += 1;
|
|
}
|
|
}
|
|
/*
|
|
* 与「重发已答过的卡」同族的软硬两段:重调的头几次**照调**(它是本地纯函数,
|
|
* 调用本身不贵,贵的是拍)但出参标题携带点名警告 —— 不算被拒,模型不撞墙;
|
|
* 到第 5 次identical 才拒(那时是真卡死了,熔断兜底要看得见它)。
|
|
*/
|
|
if (sameCalls >= 4) {
|
|
throw new Error(
|
|
`${action.tool} 用同一份入参已经调过 ${sameCalls} 次,出参就在你的轨迹里,而这期间台账一个字没动 —— ` +
|
|
'它是按入参算数的工具,同样的输入永远同样的结论,再调一遍不会变卦。' +
|
|
'算出来的数与你预想的不一样,先检查入参是不是漏了字段;确认无误就**按这个数往下办**' +
|
|
'(该写的表写上、该收的步收掉),别再调了。',
|
|
);
|
|
}
|
|
}
|
|
/*
|
|
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测):
|
|
* 模型调 rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,
|
|
* 下一拍补上项号原样重调 —— 那次拒回什么都没保护,白烧一拍。
|
|
* 与写行动同款(「写的授权面是台账声明」):itemId 只是记账,
|
|
* 挂不上(空串/项不存在/已收掉)照调不拦,留痕注明;记账挂项尽力而为。
|
|
*/
|
|
let working = runtime;
|
|
let unattached: string | undefined;
|
|
try {
|
|
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
|
|
working = readyToWork(toolModel, runtime, action.itemId);
|
|
} catch (error) {
|
|
unattached = error instanceof Error ? error.message : String(error);
|
|
}
|
|
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
|
|
if (await deps.tools.isWriteEffect(action.tool)) {
|
|
await deps.tools.registerIntent({
|
|
taskId,
|
|
tool: action.tool,
|
|
input: action.input,
|
|
label: `${action.itemId}:${action.tool}`,
|
|
});
|
|
return { runtime: working, status: 'running' };
|
|
}
|
|
const result = await deps.tools.invoke({
|
|
tool: action.tool,
|
|
input: action.input,
|
|
activityId: context.activityId,
|
|
taskId,
|
|
});
|
|
/*
|
|
* 转人代办:这件工具系统里没有,基座已开卡请人做(卡与监听都已就位)。
|
|
* 计划项留在 Active 挂起等人 —— 人填回的值下一拍就是这次调用的出参。
|
|
*/
|
|
if (result.awaitingHuman !== undefined) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `${action.tool} 转人代办 —— 等回填`,
|
|
detail: { workOrderId: result.awaitingHuman.workOrderId, input: action.input },
|
|
});
|
|
return { runtime: working, status: 'awaiting' };
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title:
|
|
`${action.tool} ${result.ok ? '返回' : '失败'}` +
|
|
(unattached === undefined ? '' : ` —— 未挂到计划项(${unattached})`) +
|
|
// 重调的警告写在出参标题上 —— 它随轨迹进下一拍的上下文,正是模型看的地方
|
|
(sameCalls === 0 || !result.ok
|
|
? ''
|
|
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
|
|
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
|
|
/*
|
|
* **小入参原样留痕**(2026-08-27 晚):比对工具的入参从前只存哈希,模型到底喂了哪几对值事后没法审
|
|
* (实测它把剧本示例的值当这单的值念出来)。入参不长(≤ 800 字)就整份存 —— 审得了才谈得上「认真比对每一条」。
|
|
*/
|
|
detail: {
|
|
output: result.output,
|
|
error: result.error?.message,
|
|
inputHash: toolInputHash(action.input),
|
|
...(JSON.stringify(action.input).length <= 800 ? { input: action.input } : {}),
|
|
},
|
|
});
|
|
return {
|
|
// 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
|
|
runtime: result.ok || unattached !== undefined ? working : transitionItem(working, action.itemId, 'Failed'),
|
|
status: 'running',
|
|
};
|
|
}
|
|
|
|
case 'write': {
|
|
/*
|
|
* 写台账草稿 —— 活动过程写的唯一形态(铁律②:登记那一刻世界上什么都没变)。
|
|
*
|
|
* **写的授权面是台账声明,不是计划项。** 地址与列名的把关在装配层的
|
|
* `draftWrite` 里(它认识活动清单)—— 越域写与越权调工具同罪,结构上拦住;
|
|
* 空转(一行都没变)也照拒。这两道是硬门。
|
|
*
|
|
* `itemId` 则只是**记账**:挂得上就挂(项活性化,进「进行中」),
|
|
* 挂不上(项已完成 / 标了人工激活没 start / 是阶段 / 不存在)**不拦写**。
|
|
* 从前把写绑死在项的生命周期上,造出过一个死锁:模型收完 S5 才发现打标
|
|
* 结果没写全,此时所有能挂的项都 Completed,唯一没完成的只剩标人工激活的
|
|
* T_release —— 每一拍的补写都被「要先 start」拒回来,连拒五拍熔断交人,
|
|
* 而那几行数据本身合法合规。活没干完却没有项可挂,是记账问题,不是授权问题。
|
|
*/
|
|
/*
|
|
* **空 rows 的拒回要点名地址,不能只说「什么也没发生」**(2026-08-21 实测:
|
|
* E04 一拍连发三个写,前两个成了,第三个 crm_coverage.ledger_update 带着空
|
|
* rows 被拒——下一拍模型没有回头补那一张,反倒把前两张已经成功的表原样
|
|
* 重写,连拒五拍熔断)。这张表这一拍**一行都没写**,不是「写错了要改」,
|
|
* 是「压根没写」——把地址钉在拒回里,下一拍才有目标可对,不会去改别的表。
|
|
*/
|
|
if (action.rows.length === 0) {
|
|
throw new Error(
|
|
`写台账要给行:${action.address} 这一下 rows 是空的,这一拍什么也没发生 —— ` +
|
|
`这张表这一拍一行都没写(不是内容错了要改,是压根没写),下一拍别去动别的表` +
|
|
'(尤其别把已经写成功的表重写一遍,那只会被当空转再拒一次),回来把这张表真正的行写上。',
|
|
);
|
|
}
|
|
const at = action.address.indexOf('.');
|
|
if (at <= 0 || at === action.address.length - 1) {
|
|
throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`);
|
|
}
|
|
const domain = action.address.slice(0, at);
|
|
const object = action.address.slice(at + 1);
|
|
|
|
/*
|
|
* **人填的表,模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的那几张表,
|
|
* 值只可能从人那儿来。实测模型把台账里原 PN 那一行的规格书逐字抄到了候选料号头上 ——
|
|
* 它不是凭空编,是「手边有一行看着对的就拿来用」。墙立在这儿,路在拒回里(去发那张卡)。
|
|
*/
|
|
const humanOnly = humanWritesOf?.(action.itemId, action.address);
|
|
if (humanOnly !== undefined) throw new Error(humanOnly);
|
|
|
|
// 挂项(尽力):阶段不挂(容器)、挂不上不拦 —— 理由随留痕带出,模型下一拍自己修记账
|
|
const { parsed: model } = await loaded(taskId);
|
|
const target = model.items.find((i) => i.id === action.itemId);
|
|
let ready = runtime;
|
|
let unattached: string | undefined;
|
|
if (target?.type === 'stage') {
|
|
unattached = `${action.itemId} 是阶段(容器),写不挂它 —— 下次挂阶段里正在干的那一项`;
|
|
} else {
|
|
try {
|
|
ready = readyToWork(model, runtime, action.itemId);
|
|
} catch (error) {
|
|
unattached = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
let changed = 0;
|
|
for (const row of action.rows) {
|
|
const done = await deps.data.draftWrite({ taskId, domain, object, key: row.key, patch: row.patch });
|
|
if (done.changed) changed += 1;
|
|
}
|
|
/*
|
|
* **一行都没变的写不是一个行动** —— 当场拒。
|
|
*
|
|
* 先只是如实留痕(「这一拍没有推进局面」),指望 Agent 自己看见了就换个动作;
|
|
* 实测它照写不误 —— 同一张 type_table 连写六拍,30 万 token 淌在原地。
|
|
* 留痕拦不住,判据才拦得住:拒了会走「行动被拒」那条路,把理由摆进下一拍的
|
|
* 上下文里 —— 那条路模型的反应一直很好。
|
|
*
|
|
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
|
|
*/
|
|
if (changed === 0) {
|
|
/*
|
|
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
|
|
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
|
|
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
|
|
*
|
|
* **「该 finish 的项」这半句在一种局面下会把模型指回死路**(2026-08-21
|
|
* 实测:E04 finish 被 worldTouched 那道新门拒了之后,模型没去发卡,
|
|
* 反倒把同一行 verdict 原样重写了五拍,连拒到熔断)——它其实卡在
|
|
* 「有一项标了人工激活,却还没真给人发过卡」,这句泛泛的指路对它不管用。
|
|
* 查一眼当下有没有这样的项,点名指过去:该发的是卡,不是再写一遍这张表。
|
|
*/
|
|
const blockedOnWorld = model.items.find((i) => {
|
|
if (i.manualActivation !== true) return false;
|
|
const state = runtime.items.find((r) => r.id === i.id);
|
|
return (state?.state === 'Enabled' || state?.state === 'Active') && state.worldTouched !== true;
|
|
});
|
|
/*
|
|
* **身后还有行动就不拒:无事发生,串照走**(2026-08-22,E04 活单熔断实测)。
|
|
* 模型每拍计划「写记账旗 + 发 S5 执行卡」—— 旗的值没变,这里一拒整串弃权,
|
|
* 排在后面的发卡从没执行过;提示词的 world-touch 事实行与下面 blockedOnWorld
|
|
* 的指路**都在**,它照样先写旗:措辞已到头,结构治。值没变的写什么也没改,
|
|
* 后面行动的前提未变,不该陪葬。留痕照说(模型下一拍要看见「值已在台账里」),
|
|
* 局面原样交回 —— 无事发生不站上场(与 start 已开项、重发已答卡同族)。
|
|
*/
|
|
if (hasFollowUp) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— 无事发生,这一串后面的行动照走`,
|
|
detail: { internal: true, ledger: action.address, itemId: action.itemId },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
throw new Error(
|
|
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
|
|
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
|
|
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次` +
|
|
(blockedOnWorld === undefined
|
|
? ''
|
|
: ` —— 这一单当下卡在 ${blockedOnWorld.id}(标了人工激活,这一单里还没真给它发过卡、` +
|
|
'也没为它立过监听):别再往这张表上写了,去照说明书发那一张卡(human/entry/decide)' +
|
|
'或立一次监听(listen),这才是当下唯一能往下走的路。'),
|
|
);
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'action',
|
|
title:
|
|
`写草稿:${action.address} ${action.rows.length} 行(${changed} 行有变化)` +
|
|
(unattached === undefined ? '' : ` —— 未挂到计划项(${unattached})`),
|
|
/*
|
|
* `ledger` 是给日志认的记号:同是 action 条目,写草稿与调工具得分得出。
|
|
* `itemId` 一并记下 —— 挂不上时留痕只说得出「计划项不存在」,
|
|
* 说不出它报的是哪一个,排障就只能靠猜(实测猜过一轮)。
|
|
*/
|
|
detail: {
|
|
ledger: action.address,
|
|
itemId: action.itemId,
|
|
rows: action.rows.length,
|
|
changed,
|
|
keys: action.rows.map((r) => r.key),
|
|
},
|
|
});
|
|
// 写完**不收场**:这一项可能还要再写一笔(先逐条记录、再结算总数),
|
|
// 收了就再也动不了它。收场由 Agent 用 finish 宣告。
|
|
return { runtime: ready, status: 'running' };
|
|
}
|
|
|
|
/**
|
|
* **撤掉自己写错的那一行** —— 与 `write` 同一道授权面(没声明的地址撤不了),
|
|
* 只作用在本单草稿层:正式版一个字都动不了,也不抛事件。
|
|
*
|
|
* 一行都没撤掉就当场拒:那多半是主键写法对不上(`DAG…` 与 `DAG…-R0` 是两行)。
|
|
* 静默成功比拒更坏 —— 模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
|
*
|
|
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
|
|
* 条目数报 9(对)而表里躺着 10,判据正确地拦住,而模型改不动 ——
|
|
* 唯一能改的是那个数,改了 `skipped_total` 又不对。13 次拒回、23 拍、234k token。
|
|
*/
|
|
case 'drop': {
|
|
const at = action.address.indexOf('.');
|
|
if (at <= 0 || at === action.address.length - 1) {
|
|
throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`);
|
|
}
|
|
const domain = action.address.slice(0, at);
|
|
const object = action.address.slice(at + 1);
|
|
if (action.keys.length === 0) throw new Error('撤行得说撤哪几行:keys 一个都没给');
|
|
|
|
const gone: Array<Record<string, string | number>> = [];
|
|
const missed: Array<Record<string, string | number>> = [];
|
|
for (const key of action.keys) {
|
|
const done = await deps.data.draftDrop({ taskId, domain, object, key });
|
|
(done.dropped ? gone : missed).push(key);
|
|
}
|
|
if (gone.length === 0) {
|
|
throw new Error(
|
|
`草稿层里没有这 ${missed.length} 行:${missed.map((k) => JSON.stringify(k)).join('、')} —— ` +
|
|
'主键要与写进去时**一字不差**(补个后缀就是另一行);先看读数里那份主键名单,照着抄',
|
|
);
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'action',
|
|
title:
|
|
`撤草稿:${action.address} ${gone.length} 行` +
|
|
(missed.length === 0 ? '' : `(另 ${missed.length} 行草稿层里没有)`) +
|
|
` —— ${action.reason}`,
|
|
detail: { ledger: action.address, itemId: action.itemId, dropped: gone, missed },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
|
|
case 'human': {
|
|
/*
|
|
* 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提。
|
|
*
|
|
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡。
|
|
* 反过来写(先发卡后迁移)会让每一次被拒的回合都白发一张卡 —— 卡收不回,
|
|
* 负责人的工作台上就堆起一摞谁也不知道该怎么答的「请处理」。
|
|
*/
|
|
/*
|
|
* 站上场用 `readyToWork` 而不是硬迁移 —— **它可能已经在场了**。
|
|
*
|
|
* 实测踩过:S2 先发了决策卡(那一步已把它推到 Active),人选完之后
|
|
* 同一项要接着发执行工单,而这里写死 `transitionItem(..., 'Active')`,
|
|
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
|
|
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
|
|
*
|
|
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
|
|
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
|
|
*/
|
|
/*
|
|
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
|
|
*
|
|
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
|
|
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
|
|
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
|
|
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
|
|
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
|
|
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
|
|
*
|
|
* **同一件事不许问第二遍**(2026-08-01,实测账)。
|
|
*
|
|
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
|
|
* 实测一单里同一个缺口连问三遍 —— 人答了、它看了答案觉得不对、再问一遍,
|
|
* 一模一样的卡在工作台上堆了三张,四次调人的预算就此耗尽,单子落 `exhausted`。
|
|
*
|
|
* 认的是**问句**(`ask`):标题常带序号与变体,问句才是「这是同一件事」的身份。
|
|
* 只拦**已经答过**的那一张 —— 还没答的重发是另一回事(那由卡自己的去重管)。
|
|
* 拒回时把上一次的答案指出来:多半问题不在「没人答」,而在它没读懂那个答案。
|
|
*/
|
|
const face = action.card as Record<string, unknown>;
|
|
// 留痕里那一条 `card-sent` 记的是**标题**,所以拿标题比(标题为空才退到问句)
|
|
const asked = String(face['title'] ?? face['ask'] ?? '').trim();
|
|
if (asked !== '') {
|
|
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
|
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' };
|
|
}
|
|
}
|
|
/*
|
|
* **放行那一步不发工单**(2026-08-27 W05 实测):模型在 T_release 发了一张执行工单
|
|
* 「评审放行与落账确认」,人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。
|
|
* 放行卡由 submit 发(基座预检后发验收卡),这一步没有别的卡可发;拒回把路指清。
|
|
*/
|
|
if (faceOf?.(action.itemId)?.release === true) {
|
|
throw new Error(
|
|
'这一步是终局放行:放行卡由 submit 发(基座预检后自己发验收卡),不发执行工单 / 数据工单 —— ' +
|
|
'发了等于把同一件事问人两遍(先一张执行单、再一张验收单)。这一拍改成:judge 该裁的裁量判据,然后 submit。',
|
|
);
|
|
}
|
|
const { parsed: cardModel } = await loaded(taskId);
|
|
/*
|
|
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
|
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 itemId 就是空串 ——
|
|
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下。
|
|
* **最后的活路自己被砌了墙,这一单就真没路了。**
|
|
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
|
|
* 挂不上照发、留痕注明。人工激活那道 worldTouched 门不受影响 ——
|
|
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
|
|
*/
|
|
let advanced = runtime;
|
|
let cardUnattached: string | undefined;
|
|
try {
|
|
advanced = readyToWork(cardModel, runtime, action.itemId);
|
|
} catch (error) {
|
|
// 「这一步已经办完/停用」是真墙,原样抛回去(拒回话术会把模型指向当下那一步)
|
|
if (!(error instanceof ItemUnattachableError)) throw error;
|
|
cardUnattached = error.message;
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `发卡未挂到计划项(${cardUnattached}) —— 卡照发,记账挂不上不拦人`,
|
|
detail: { internal: true, card: action.card },
|
|
});
|
|
}
|
|
const card = await deps.cards.issue({
|
|
taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
itemId: action.itemId,
|
|
workOrder: action.workOrder,
|
|
card: action.card,
|
|
});
|
|
await deps.listeners.register({
|
|
taskId,
|
|
eventType: 'human',
|
|
keyFilter: { cardId: card.id },
|
|
deadline: 'P3D',
|
|
});
|
|
// 挂不上就没有项可标 —— worldTouched 只记在真挂上的那一项上,那道门因此仍然诚实
|
|
return {
|
|
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
|
|
status: 'awaiting',
|
|
issuedFor: action.itemId,
|
|
};
|
|
}
|
|
|
|
case 'ask': {
|
|
/*
|
|
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
|
|
* 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
|
|
* `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
|
|
* (别的调用方直发)就如实拒,并指对路。
|
|
*/
|
|
throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
|
|
}
|
|
|
|
case 'entry': {
|
|
/*
|
|
* 数据录入卡 —— 与补全卡同一条路(发卡 → 立监听 → 本拍收),
|
|
* 差别在**确认那一下由基座直接写库**,不回到你手上再写一遍。
|
|
* 所以这里只管把卡发出去;人确认后草稿已经落好,下一拍你看到的是既成事实。
|
|
*/
|
|
if (action.fields.length === 0) {
|
|
throw new Error('数据录入卡要有格子:一列一个格子,形状 fields:[{key,label,value?,required?}]');
|
|
}
|
|
if (!action.ledger.includes('.')) {
|
|
throw new Error(`落表地址要写成「域.对象」:${action.ledger} —— 人得知道这一下写的是哪张表`);
|
|
}
|
|
const entryReady = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
|
const entryCard = await deps.cards.entry({
|
|
taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
ask: action.ask,
|
|
ledger: action.ledger,
|
|
key: action.key,
|
|
fields: action.fields,
|
|
...(action.also === undefined || action.also.length === 0 ? {} : { also: action.also }),
|
|
...(action.basis === undefined ? {} : { basis: action.basis }),
|
|
});
|
|
await deps.listeners.register({
|
|
taskId,
|
|
eventType: 'human',
|
|
keyFilter: { cardId: entryCard.id },
|
|
deadline: 'P3D',
|
|
});
|
|
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting', issuedFor: action.itemId };
|
|
}
|
|
|
|
case 'decide': {
|
|
/*
|
|
* 发决策卡 = 立监听听人:岔路口摆到对话区,本拍就此挂起等人点。
|
|
*
|
|
* 两条硬门:**至少两个选项**(一个选项的「选择题」是通知,不是决策),
|
|
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
|
|
*/
|
|
if (action.options.length < 2) {
|
|
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}」)—— 别再发一张一样的决策卡。` +
|
|
chosenGuidance(verdict.chose) +
|
|
'真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
|
);
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: echoTitle,
|
|
detail: {
|
|
internal: true,
|
|
note: '人已经在这个岔路口选过了。别再发同一张 —— ' + chosenGuidance(verdict.chose),
|
|
},
|
|
});
|
|
// 什么也没做,局面原样交回
|
|
return { runtime, status: 'running' };
|
|
}
|
|
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
|
const face = faceOf?.(action.itemId);
|
|
const card = await deps.cards.decide({
|
|
taskId,
|
|
itemId: action.itemId,
|
|
...(face?.variant === undefined ? {} : { variant: face.variant }),
|
|
...(face?.checklist === undefined ? {} : { checklist: face.checklist }),
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
ask: action.ask,
|
|
options: action.options,
|
|
...(action.basis === undefined ? {} : { basis: action.basis }),
|
|
...(action.allowCustom === undefined ? {} : { allowCustom: action.allowCustom }),
|
|
});
|
|
await deps.listeners.register({
|
|
taskId,
|
|
eventType: 'human',
|
|
keyFilter: { cardId: card.id },
|
|
deadline: 'P3D',
|
|
});
|
|
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting', issuedFor: action.itemId };
|
|
}
|
|
|
|
case 'listen': {
|
|
/*
|
|
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23 加,
|
|
* 柴浩实测逼出来的:E04 一单卡在依赖断点 —— 模型没写那面记账旗、下一步的哨兵
|
|
* 永不成立,它于是临场「立个 data 监听等回执」;而 E04 一个监听都没声明、
|
|
* 这一单也不会有任何 data 事件进来,单子就此挂在 awaiting-event 15 分钟一动不动)。
|
|
*
|
|
* **立了没人叫醒的监听不是等待,是失联** —— 与「写的授权面是台账声明」
|
|
* 「调工具的授权面是白名单」同一条纪律。拒回同时把两条真路指出来:
|
|
* 要等人办事就发执行工单;卡在断点就回去补那个没写的数。
|
|
*/
|
|
const declared = await deps.listeners.declared(taskId);
|
|
if (!declared.some((d) => d.eventType === action.eventType)) {
|
|
const kinds = declared.map((d) => d.eventType).join('、');
|
|
throw new Error(
|
|
`这个活动没声明过 ${action.eventType} 类监听(声明过的:${kinds || '一个都没有'})—— ` +
|
|
'立了也没人会来叫醒它,这一单会一直挂着。要等人去办事就**发执行工单**(human,' +
|
|
'人在工作区的件上把回音填回来);当下要是没有项可干(依赖断点),' +
|
|
'多半是某个入场前提的数还没写(看「干完的判据」那几行读数,常见的是某面记账旗漏了),' +
|
|
'回去把那一笔补上,门自己就开了。',
|
|
);
|
|
}
|
|
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
|
|
await deps.listeners.register({
|
|
taskId,
|
|
eventType: action.eventType,
|
|
keyFilter: action.keyFilter,
|
|
deadline: action.deadline,
|
|
});
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
|
|
detail: action.keyFilter,
|
|
});
|
|
return { runtime: markWorldTouched(runtime, action.itemId), status: 'awaiting' };
|
|
}
|
|
|
|
case 'asked': {
|
|
/*
|
|
* 开环模式:调人已由**工具总线**在拍内执行(发卡 + 立监听),引擎只收尾。
|
|
* 再发一张就是重复调人 —— 工位上两张一模一样的卡,谁也不知道该答哪张。
|
|
*/
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'thought',
|
|
title: `本回合调人待回(卡 ${action.cardId})`,
|
|
detail: { rationale: action.rationale },
|
|
});
|
|
return { runtime, status: 'awaiting' };
|
|
}
|
|
|
|
case 'finish': {
|
|
/*
|
|
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
|
*
|
|
* 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
|
|
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
|
|
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
|
|
* start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
|
|
* 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
|
|
* 收它的只能是模型自己的这一下 finish。
|
|
*/
|
|
const { parsed: finishModel } = await loaded(taskId);
|
|
/*
|
|
* **已经收掉的项,再 finish 一次就当它已完成** —— 不是非法迁移。
|
|
*
|
|
* 自动收步(`doneWhen` 为真时引擎自己收)与模型的行动串是**并行**发生的:
|
|
* 引擎在拍首把 T_select 收了,而模型这一拍原本就打算 write 完再 finish 它。
|
|
* 从前这里直接 `readyToWork`,Completed → Active 当场抛「生命周期迁移非法」——
|
|
* 而模型只会原样再试一遍,连拒五拍熔断(实测:A2 在 MVP 第一轮栽在这儿)。
|
|
* 与 `start` 那一处同一个道理:按钮按两下不该报错。
|
|
*/
|
|
const already = runtime.items.find((i) => i.id === action.itemId)?.state;
|
|
if (already === 'Completed') {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `${action.itemId} 已经收过了 —— 无需再 finish`,
|
|
detail: { internal: true },
|
|
});
|
|
return { runtime, status: 'running' };
|
|
}
|
|
/*
|
|
* **触达世界的项,没真触达过就不许 finish**(2026-08-21 用户实测逼出来的:
|
|
* E04 的 S5_execute_receipt 从未派发执行回执工单,模型一拍之内就把它
|
|
* finish 掉了,人根本没被问过完成状态/结果摘要,收口那一步拿空回执重算了
|
|
* 覆盖度)。`manualActivation = true` 声明的正是「这一项完成与否**不该由
|
|
* 数据形状推出来**」(AM 12.2 原理一)——可 `finish` 从前只查生命周期迁移
|
|
* 合不合法,从没查过「人真的被问过吗」。凭空写的完成不是完成,是编造。
|
|
*
|
|
* 门槛只认 `worldTouched`(见 lifecycle.ts):human / entry / decide / listen
|
|
* 四个「触达世界」的动作之一真的针对这一项执行过,才算数——不管是这一拍刚发的,
|
|
* 还是更早的拍留下的(reject 之后重发也算,标记不会中途被清)。
|
|
*/
|
|
const item = finishModel.items.find((i) => i.id === action.itemId);
|
|
if (item?.manualActivation === true && runtime.items.find((i) => i.id === action.itemId)?.worldTouched !== true) {
|
|
throw new Error(
|
|
`${action.itemId} 标了人工激活(触达世界),但这一单里从没给它发过人类工具的卡、` +
|
|
'也没为它立过监听 —— finish 不能凭空宣布「人已经办完了」。先照说明书发一张' +
|
|
'卡(human/entry/decide)或立一次监听(listen),真收到回音再 finish;' +
|
|
'如果这一步其实不需要人(说明书写错了),那是活动包的声明问题,不是这一拍能绕过去的。',
|
|
);
|
|
}
|
|
/*
|
|
* **声明了 doneWhen,就不许模型自己一句话绕过去**(2026-08-22 实测:E04 S1
|
|
* 该写两行候选客户,模型只写成了一行就直接 finish——doneWhen 明明为假,
|
|
* 这一步照样被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,
|
|
* 一路悄悄漏到 S5 提交闸才第一次被拦下)。`doneWhen` 是「这件事完没完可以
|
|
* 算出来」的声明,算出来是假,finish 就该被拒——不是让模型自己判断,
|
|
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
|
*/
|
|
const doneWhenNow = doneWhenOf?.(action.itemId);
|
|
if (doneWhenNow?.ok === false) {
|
|
const say =
|
|
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
|
'不能凭这一下 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({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `完成:${finishModel.items.find((i) => i.id === action.itemId)?.name ?? action.itemId}`,
|
|
detail: { rationale: action.rationale },
|
|
});
|
|
return { runtime: transitionItem(ready, action.itemId, 'Completed'), status: 'running' };
|
|
}
|
|
|
|
case 'submit': {
|
|
/*
|
|
* 终局唯放行:提交 = 发验收卡挂起等人裁决,不是自结(引擎不把案计划置为 Completed)。
|
|
*
|
|
* `saved: true` 是要紧的一笔:`raiseAcceptance` 已经把**在途验收**写进状态信封,
|
|
* 而信封的规矩是「pending 省参 = 清空」(不做「不传就保留」的暗默)。
|
|
* 回合末那次兜底 save 不带 pending —— 不打这个招呼,它会把刚记下的在途验收擦掉:
|
|
* 于是下一拍看不见在途验收,又发一张新卡,人的裁决永远落在一张已被遗忘的卡上。
|
|
* 实测踩过:签了「通过」,任务却纹丝不动,工位上多出第二张一模一样的验收单。
|
|
*/
|
|
return {
|
|
...(await raiseAcceptance(taskId, runtime, action.rationale, action.fields)),
|
|
saved: true,
|
|
// 放行页已由 raiseAcceptance 摆好 —— 回合末别再拿别的模版盖掉它
|
|
framed: true,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 提交放行的唯一出口:发**验收卡**、立监听、把在途验收记进状态信封。
|
|
* 信封里有在途验收即不再发第二张(判齐可能被无关事件反复唤起 ——
|
|
* 没有这道持久痕,每次唤起都会往负责人工作台堆一张新卡)。
|
|
*/
|
|
async function raiseAcceptance(
|
|
taskId: TaskId,
|
|
runtime: CaseRuntime,
|
|
rationale: string,
|
|
/** 验收报告上让人当场改的那几个值(带 AI 拟值与落回地址)—— 见 submit 行动的注释 */
|
|
fields?: ReadonlyArray<{ key: string; label: string; ai?: string; ledger?: string }>,
|
|
): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> {
|
|
/*
|
|
* **人打回了这一份成果,改都没改就不许再递上来**(2026-08-22,柴浩实测:点「重做」
|
|
* 写了「重做一下」,6 秒后同一份成果原样又递回 —— 三条裁量判据重判一遍、台账一个字没动。
|
|
* 人看见的是「我说了重做,它当没听见」)。
|
|
*
|
|
* 根子在局面:打回那一刻案例模型的项全是 Completed,模型除了重新 submit 无事可做,
|
|
* 拒回文案与说明书都劝不动它(**措辞治不了的结构治**)。所以由提交这道唯一的出口
|
|
* 自己看一眼:打回之后台账动过没有(写 / 撤都算)——没动就不放行。
|
|
*
|
|
* 软硬两段(与「重发已答过的卡」「同一份入参重调」同族):头一次拒,把路指明;
|
|
* 已经因此拒过一次还坚持原样提交就放行 —— 人还能再打回,墙要配路,不把单子锁死。
|
|
*/
|
|
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
|
let lastRedo = -1;
|
|
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
|
if (lastRedo >= 0) {
|
|
const after = window.slice(lastRedo + 1);
|
|
/*
|
|
* **配方写下的那一笔也算「改过台账」**(2026-08-29 终审 C3)。
|
|
* `kind: 'action'` 只有模型的 write / drop 才写;配方的写留痕是
|
|
* `kind: 'observation'` + `detail.auto` + `detail.ledger`(见 auto-runner 的 project 分支)。
|
|
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
|
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
|
* 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。
|
|
*/
|
|
const revised = after.some(
|
|
(e) => (e.kind === 'action' || typeof e.detail?.['auto'] === 'string') && typeof e.detail?.['ledger'] === 'string',
|
|
);
|
|
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
|
if (!revised && !refusedBefore) {
|
|
throw new Error(
|
|
`人打回了这一份成果,而${REFUSE_MARK} —— 原样再递上去,` +
|
|
'他看见的还是刚被他打回的那一份。先按他的意见改台账(write 覆盖写 / drop 撤行都使得),' +
|
|
'改完再提交;他没写具体意见的话,自己把成果复核一遍、至少改对一处,' +
|
|
'或者发一张卡问清楚他要改什么,别原样重提。',
|
|
);
|
|
}
|
|
}
|
|
const context = await deps.tasks.of(taskId);
|
|
/*
|
|
* **发验收卡之前先把放行那一页摆好**(2026-07-31)。
|
|
*
|
|
* 验收卡上嵌的就是工作区当下那一帧。而提交之后这一单挂起等人裁决、不再有下一拍 ——
|
|
* 画面停在哪儿就是哪儿。实测:人签字时看到的是阶段二的「询价受理需求」,
|
|
* 而他要签的是「统一受理记录」。**签字页错了,签的就不是那件事**。
|
|
* 所以这一页不能指望模型顺手换:提交这一下由引擎把放行页推上去。
|
|
*/
|
|
const releaseStep = (await loaded(taskId)).model.steps?.find((x) => x.item === 'T_release');
|
|
if (releaseStep?.view !== undefined && releaseStep.view !== '') {
|
|
try {
|
|
const shown = await showView(taskId, releaseStep.view, runtime);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `放行页:推「${releaseStep.view}」${shown.pushed ? '成功' : `失败(可选:${shown.templates.join('、')})`}`,
|
|
detail: { internal: true },
|
|
});
|
|
} catch (error) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `放行页推送抛错:${error instanceof Error ? error.message : String(error)}`,
|
|
detail: { internal: true },
|
|
});
|
|
}
|
|
} else {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `放行页:剧本里 T_release 没声明 view(steps 有 ${(await loaded(taskId)).model.steps?.length ?? 0} 条)`,
|
|
detail: { internal: true },
|
|
});
|
|
}
|
|
/*
|
|
* 验收卡的题目用放行那一步在案例模型里的名字(如「评审放行与落账确认(ACT-GATE-03)」)——
|
|
* 「收口验收」是没声明时的兜底,人看见的应该是活动自己给这一步起的名。
|
|
*/
|
|
const { model: releaseModel, parsed: releaseParsed } = await loaded(taskId);
|
|
const gateStep = releaseModel.steps?.find((st) => isReleaseStep(st)) ?? releaseStep;
|
|
const releaseName = gateStep === undefined ? undefined : releaseParsed.items.find((i) => i.id === gateStep.item)?.name;
|
|
const card = await deps.cards.issue({
|
|
taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
workOrder: 'acceptance',
|
|
card: {
|
|
title: releaseName === undefined || releaseName.trim() === '' ? '收口验收' : releaseName,
|
|
basis: [{ k: '提交理由', v: rationale }],
|
|
...(fields === undefined || fields.length === 0 ? {} : { fields: fields.map((f) => ({ ...f })) }),
|
|
},
|
|
});
|
|
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
|
const { model, parsed } = await loaded(taskId);
|
|
await deps.state.save(taskId, model, parsed, runtime, { acceptanceCardId: card.id });
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'thought',
|
|
title: '判定成果已齐,提交放行 —— 验收卡已发,等裁决',
|
|
detail: { rationale, cardId: card.id },
|
|
});
|
|
return { runtime, status: 'awaiting' };
|
|
}
|
|
|
|
return {
|
|
async load(taskId, model: LoopModel) {
|
|
const parsed = parseCmmn(model.cmmnXml);
|
|
const runtime = initialRuntime(parsed);
|
|
await deps.state.save(taskId, model, parsed, runtime);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `装载案例模型:${parsed.name}(${parsed.items.length} 个计划项)`,
|
|
});
|
|
return runtime;
|
|
},
|
|
|
|
async runtime(taskId) {
|
|
return (await deps.state.load(taskId))?.runtime;
|
|
},
|
|
|
|
/** 阶段声明与计划项状态同在一行运行时里 —— 读一次就够,不必再去捞清单 */
|
|
async phaseOf(taskId, terminal) {
|
|
const loaded = await deps.state.load(taskId);
|
|
if (loaded === undefined) return undefined;
|
|
return phaseProgress(loaded.model.phases, loaded.runtime.items, terminal);
|
|
},
|
|
|
|
async publish(taskId) {
|
|
return (await publishFor(taskId)).enabled;
|
|
},
|
|
|
|
/** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
|
async runTurn(taskId): Promise<TurnOutcome> {
|
|
const { model, parsed, runtime, scope, enabled } = await publishFor(taskId);
|
|
const pending = (await loaded(taskId)).pending;
|
|
|
|
/*
|
|
* 在途验收先于一切:没裁就继续等(不重发卡、不空跑模型);裁了按 verdict 分岔:
|
|
* · `pass` → 终局(released),不再问 Agent;
|
|
* · **非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:从前这里也走终局分支,
|
|
* task 层把「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,
|
|
* 却再没有任何事件会叫醒它,一单就此卡死)。重做 = 带着意见退回去再干一遍:
|
|
* 意见用人话进轨迹、在途验收清掉,**这一拍照常往下问模型** ——
|
|
* 模型修订台账草稿后重新 submit,新发的验收卡才是下一次裁决的对象。
|
|
*/
|
|
let redone = false;
|
|
if (pending !== undefined) {
|
|
if (pending.verdict === undefined) {
|
|
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
if (pending.verdict === 'pass') {
|
|
await deps.state.save(taskId, model, parsed, runtime, null);
|
|
return {
|
|
runtime,
|
|
enabled,
|
|
navigation: { kind: 'converged' },
|
|
status: 'finished',
|
|
released: true,
|
|
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }),
|
|
};
|
|
}
|
|
redone = true;
|
|
await deps.state.save(taskId, model, parsed, runtime, null);
|
|
const comment = pending.comment?.trim() ?? '';
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `${REDO_MARK}${comment === '' ? '(人没写具体意见 —— 自己把成果复核一遍,至少一处实质性修订)' : `:${comment}`}`,
|
|
detail: {
|
|
hint:
|
|
'按意见修订台账草稿(write 覆盖写 / drop 撤行都使得),改完重新提交放行(submit)——' +
|
|
'新发的验收卡才是下一次裁决的对象;别原样重提,那只会再被打回一次。',
|
|
},
|
|
});
|
|
}
|
|
|
|
/*
|
|
* **叫了人就等人**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
|
*
|
|
* 这一单还有卡没交回,这一拍就不问模型 —— 不管是谁叫醒的:迟到的回传事件、另一台后端的
|
|
* 推进器、手动 wake。从前每一次唤醒都跑一拍,而事实行对模型说「这一步已经发过卡,人已经被
|
|
* 问过了」,它就照「人交回之后」那一段把步骤收掉 —— 没人答的卡就这样被跳过(实测:S1 的卡
|
|
* 发出 0.3 秒后,上一张卡回传的事件才投到,再一拍就「完成:评估输入与资料补齐确认」)。
|
|
* 在途验收(上面那段)先于它:验收裁决本身就是那张卡的回传。
|
|
*/
|
|
const waiting = await deps.cards.unanswered(taskId);
|
|
if (waiting.length > 0) {
|
|
/*
|
|
* **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。
|
|
* 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」);
|
|
* 但不发新卡、不 submit、不调 AI —— 叫了人就等人。
|
|
*/
|
|
/* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */
|
|
const quietStep = liveStepOf(
|
|
model.steps ?? [],
|
|
new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)),
|
|
[...enabled.inProgress, ...enabled.entries],
|
|
);
|
|
if (quietStep?.auto !== undefined) {
|
|
await autoRunner.run(quietStep.auto, {
|
|
taskId,
|
|
stepId: quietStep.item,
|
|
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
|
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
|
quiet: true,
|
|
carded: true,
|
|
});
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `等人交回:${waiting.map((c) => c.title).join('、')} —— 这一拍不动`,
|
|
detail: { internal: true, cards: waiting.map((c) => c.id) },
|
|
});
|
|
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
|
|
/*
|
|
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
|
|
*
|
|
* 提交要过**落地预检**那道门,预检不过会抛(必交付物缺、意向不可落地、他单改了同一字段)。
|
|
* 抛了不能让回合崩:推进器会一遍遍重来,每遍再撞一次同一道门。
|
|
* 正确做法与「行动被拒」一致 —— 拒绝 + 留痕 + 把理由喂回下一拍,让 Agent 自己去补。
|
|
*
|
|
* **刚被打回重做的那一拍不走这条快道**:判齐恒真的活动会在意见没人读的情况下
|
|
* 立刻再发一张一模一样的验收卡 —— 人看见的是「我说了重做,它原样又递上来了」。
|
|
* 这一拍先交给模型(意见就在轨迹里),它改完自己 submit。
|
|
*/
|
|
if (autoCompleteReady(parsed, runtime) && !redone) {
|
|
try {
|
|
const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项');
|
|
return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status };
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `提交放行被拒:${reason}`,
|
|
detail: { hint: '按理由补齐必交付物或修正动作意向,再提交' },
|
|
});
|
|
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'running' };
|
|
}
|
|
}
|
|
|
|
// 导航即交集:当下可为 ∩ 目标所需
|
|
const navigation = navigate(enabled, new Set(enabled.entries.map((e) => e.id)));
|
|
if (navigation.kind === 'breakpoint') {
|
|
// 断点即人环 —— 由 Agent 定夺调人还是立监听,判定留痕
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'thought',
|
|
title: '依赖断点:当下可为 ∩ 目标所需 = 空',
|
|
detail: { gaps: navigation.gaps },
|
|
});
|
|
}
|
|
|
|
const activityId = (await deps.tasks.of(taskId)).activityId;
|
|
const catalog = await deps.skills.index(activityId);
|
|
|
|
/*
|
|
* 叙事面 = 案例说明书 + 活动记忆选摘 + **技能目录**。
|
|
* 渐进披露(AM 08):常驻上下文只给「有哪些方法、何时适用」,
|
|
* 正文与知识引用等 AI 说要才加载 —— 一次把所有正文塞进去,
|
|
* 上下文会被方法论淹掉,真正的局面反而看不见。
|
|
*/
|
|
// 任务自身的最近轨迹(窗口大小 = 活动声明的 memoryWindow)—— 没有它 Agent 每拍失忆
|
|
const trail = await deps.memory.window(taskId);
|
|
/*
|
|
* 喂给模型的轨迹**掐两刀**(入参就是延迟与钱):
|
|
* · 「装配技能」戳不进叙事 —— 它是回放归因的信封件,每拍一条一模一样,
|
|
* 20 条窗口能被它占掉一半,真正的历史反被挤出去;
|
|
* · 只有**最近 4 条**带全量出参(600 字),更旧的截到 120 字 ——
|
|
* 老出参早已写进台账,轨迹里只要认得出「那一步发生过」。
|
|
*/
|
|
const storyTrail = trail.filter((t) => !(t.kind === 'thought' && t.title.startsWith('装配技能')));
|
|
const narrative = [
|
|
model.narrative,
|
|
...(await deps.memory.loadActivityMemory(activityId, 20)).map((m) => `[${m.layer}] ${m.statement}`),
|
|
storyTrail.length === 0
|
|
? ''
|
|
: `【本任务最近轨迹】\n${storyTrail
|
|
.map((t, i) => {
|
|
const recent = i >= storyTrail.length - 4;
|
|
// 最近几条按批量出参的实际体量给:截掉的那半截,模型会自己编回来
|
|
const payload = payloadOf(t.detail, recent ? TRAIL_RECENT_LIMIT : TRAIL_PAYLOAD_LIMIT);
|
|
return `- [${t.kind}] ${t.title}${recent || payload.length <= 120 ? payload : `${payload.slice(0, 120)}…(旧出参已截,内容在台账)`}`;
|
|
})
|
|
.join('\n')}`,
|
|
catalog.length === 0
|
|
? ''
|
|
: `【可装配的技能(目录)】\n${catalog.map((s) => `- ${s.id} ${s.label}:${s.applicability}`).join('\n')}`,
|
|
]
|
|
.filter((line) => line !== '')
|
|
.join('\n\n');
|
|
|
|
// 装配入信封:这一回合手上有哪些技能及其版本 —— 回放与归因依赖之。
|
|
// **版本变了才重记**:每拍重复同一句不是留痕,是刷屏(日志半屏都是它,实测)。
|
|
if (catalog.length > 0) {
|
|
const stamp = await deps.skills.stamp(activityId, catalog.map((s) => s.id));
|
|
const line = `装配技能:${stamp.map((s) => `${s.id}@${s.version}`).join('、')}`;
|
|
const already = trail.some((t) => t.kind === 'thought' && t.title === line);
|
|
if (!already) {
|
|
await deps.memory.append({ taskId, kind: 'thought', title: line, detail: { skills: [...stamp] } });
|
|
}
|
|
}
|
|
|
|
/*
|
|
* **卡死了就交回给人**,别再问模型。
|
|
*
|
|
* 连续几拍都被拒(同一个非法行动反复提)时,再问一遍还是同一个答案 ——
|
|
* 而预算要二三十万 token 才烧完,这期间界面上只是「进行中」一动不动:
|
|
* 那不是在办,是卡死了。用户实测报的「无限死循环」就是这个。
|
|
*
|
|
* 交回给人时把**理由原话**都摆上:是模型跑偏、还是模型少了一种行动型、
|
|
* 还是案例模型建得不对 —— 这三种的处置完全不同,人得看得见证据才判得了。
|
|
*/
|
|
/*
|
|
* 熔断检测**另开一个更大的窗**:叙事窗(活动声明的 memoryWindow,常是 20)
|
|
* 装不下几次撞墙 —— 一次提交被拒会连带写下交代、三条评估、一次推画面,
|
|
* 五六条才一次。实测:同一条判据拒回 10 次没熔断;另一单拒回 33 次,
|
|
* 40 万 token 烧到 exhausted 才停,而那期间界面上只是「进行中」一动不动。
|
|
*/
|
|
const stalled = stalledTurns(await deps.memory.window(taskId, STALL_WINDOW));
|
|
if (stalled >= STALL_LIMIT) {
|
|
const reasons = trail
|
|
.filter((t) => t.kind === 'result' && t.title.startsWith('行动被拒'))
|
|
.slice(-STALL_LIMIT)
|
|
.map((t) => t.title);
|
|
const stuck = await deps.tasks.of(taskId);
|
|
const card = await deps.cards.issue({
|
|
taskId,
|
|
ownerId: stuck.ownerId,
|
|
humanCallCap: stuck.humanCallCap,
|
|
workOrder: 'execution',
|
|
card: {
|
|
title: `推不动了:连续 ${stalled} 拍的行动都被拒`,
|
|
ask: '这一单卡住了。看下面的拒绝理由判断:是它跑偏了(说清下一步该干什么)、还是案例模型/清单该改(去设计台改完重跑)。',
|
|
basis: reasons.map((r, i) => ({ k: `第 ${i + 1} 次`, v: r })),
|
|
fields: ['decision'],
|
|
},
|
|
});
|
|
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'narration',
|
|
title: `连续 ${stalled} 拍推不动,我先停下交给你判断 —— 拒绝理由都摆在卡上了`,
|
|
detail: { tag: 'conclusion' },
|
|
});
|
|
return { runtime, enabled, navigation: { kind: 'breakpoint', gaps: [...enabled.gaps] }, status: 'awaiting' };
|
|
}
|
|
|
|
const writable = await deps.data.writable(taskId);
|
|
// 这一单能调什么:声明 × 目录编译而来(越权在结构上不可能,清单也得让它看见)
|
|
const tools = await deps.tools.busOf(activityId);
|
|
|
|
/*
|
|
* ── 状态机 · 当前是哪一步 ────────────────────────────────────────
|
|
*
|
|
* 活动可以给每个计划项写一条**剧本**(`model.steps`:intent / guide /
|
|
* writes / tools / doneWhen)。有剧本就按状态组织这一拍:
|
|
* 上下文里**只摊开当前这一步**的做法与它该用的东西,其余步骤只留一行标题。
|
|
*
|
|
* 起因是实测的空转:从前每拍喂整本说明书,模型得自己认「我在第几步」;
|
|
* S1 已经把八字段写齐了,而说明书里 S3 写着「把八字段补到齐」——
|
|
* 模型就把同样三行又写一遍,被「写了等于没写」拒回,下一拍再来一遍。
|
|
*/
|
|
const steps = model.steps ?? [];
|
|
const stepOf = (id: string): PlanStep | undefined => steps.find((x) => x.item === id);
|
|
|
|
/*
|
|
* **成果已经在那儿的步骤,引擎自己收掉,不问模型**(能算的不问 LLM)。
|
|
*
|
|
* `doneWhen` 是对任务视图求值的 S-FEEL 谓词。为真且这一项还在场 → 直接 finish:
|
|
* 省下的不只是一拍的钱,更是那一拍**必然的空转** —— 一个没有活可干的步骤,
|
|
* 你问模型它也只能把上一步的成果再写一遍。
|
|
*
|
|
* 只收 Active/Available 的项;已经收了的不重复收。收完重算 enabled(收掉一项
|
|
* 可能让下一项的前提成立),循环到收不动为止 —— 封顶 8 轮,防剧本写出环。
|
|
*/
|
|
/**
|
|
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
|
|
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
|
|
*
|
|
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
|
|
* 从前一律不收 —— 怕引擎替人签字(T_release 触达世界,收它得人按一下)。可 W05 的两张数据工单实测:
|
|
* 卡人答了、数据也入库了,「这一步办完了没有」是引擎手里现成的事实,却让模型去记得 finish ——
|
|
* 剧本两处白纸黑字「先 finish 再发下一张」,它照样忘,两步同时 Active、件停在上一步、人无处可填。
|
|
* 设计律:**指南让模型判的事,引擎有现成事实的,一律改成读事实。**
|
|
* 门槛仍在:`worldTouched`(卡真发过)—— 卡没发过的人工激活项 doneWhen 再真也不收,那才是替人签字;
|
|
* 走到这儿时没有未答的卡(前面 `cards.unanswered` 把住),拍尾那一遍另把这一拍刚发卡的项排除。
|
|
* 没声明 doneWhen 的人工激活项(T_release / 审核循环 / 交人补缺口)一个字不变。
|
|
*/
|
|
const sweepRipe = async (
|
|
runtimeIn: CaseRuntime,
|
|
enabledIn: ReturnType<typeof publishEnabled>,
|
|
scopeIn: typeof scope,
|
|
exclude?: string,
|
|
): Promise<{ runtime: CaseRuntime; enabled: ReturnType<typeof publishEnabled> }> => {
|
|
let runtimeNow = runtimeIn;
|
|
let enabledNow = enabledIn;
|
|
if (steps.length === 0) return { runtime: runtimeNow, enabled: enabledNow };
|
|
for (let round = 0; round < 8; round += 1) {
|
|
const onStage = [...enabledNow.entries, ...enabledNow.inProgress];
|
|
const ripe = onStage.find((e) => {
|
|
if (e.id === exclude) return false;
|
|
if (e.requiresManualStart && runtimeNow.items.find((i) => i.id === e.id)?.worldTouched !== true) return false;
|
|
const step = stepOf(e.id);
|
|
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return false;
|
|
try {
|
|
return deps.sfeel.condition(step.doneWhen, scopeIn);
|
|
} catch {
|
|
// 谓词写坏了不该拖垮整拍:当作「没算出来」,照旧交给模型
|
|
return false;
|
|
}
|
|
});
|
|
if (ripe === undefined) break;
|
|
const step = stepOf(ripe.id);
|
|
try {
|
|
const active = readyToWork(parsed, runtimeNow, ripe.id);
|
|
runtimeNow = transitionItem(active, ripe.id, 'Completed');
|
|
} catch {
|
|
break;
|
|
}
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `自动收步:${ripe.id} 的成果已在台账里(${step?.doneWhen ?? ''})—— 不必再问模型`,
|
|
detail: { internal: true, item: ripe.id, doneWhen: step?.doneWhen },
|
|
});
|
|
enabledNow = publishEnabled(parsed, runtimeNow, scopeIn, changedRefs(scopeIn), deps.sfeel.condition, [...enabledNow.gaps]);
|
|
}
|
|
return { runtime: runtimeNow, enabled: enabledNow };
|
|
};
|
|
const swept = await sweepRipe(runtime, enabled, scope);
|
|
const stateRuntime = swept.runtime;
|
|
const stateEnabled = swept.enabled;
|
|
|
|
/*
|
|
* 当前这一步 = 当下可为里**第一个有剧本的项**(CMMN 的项序即业务次序)。
|
|
* 认不出就退回整本说明书 —— 没写剧本的活动照旧跑。
|
|
*/
|
|
/*
|
|
* **已经收掉的项不算「当前这一步」** —— 哪怕它还留在可为集里。
|
|
*
|
|
* S1/S2 带 `repetitionRule`(客户改需求要能重来一遍),所以收掉之后
|
|
* 它们仍是 Enabled。而这里取的是「第一个有剧本的」—— 于是整单从头到尾
|
|
* 都把 S1 当成当前这一步:喂给模型的做法是 S1 的、推给人的画面也是 S1 的。
|
|
* 实测:跑到判定去向了,工作区还停在阶段二那份询价受理需求。
|
|
*/
|
|
/*
|
|
* **取 sweep 之后的那份局面**(2026-08-29 终审 I6)。从前取的是 `runtime`(sweep 之前),
|
|
* 而 `liveStepOf` 吃的是 sweep 之后的 `stateEnabled` —— 这一拍刚被 `sweepRipe` 收掉的那一步
|
|
* 不在 `settledIds` 里,照旧被当成「还没干完的那一步」。从前这只影响提示词内容;
|
|
* 配方接进来之后它驱动**副作用**(再写一遍台账、再 prune 一遍、甚至再发一张卡)。
|
|
*/
|
|
const settledIds = new Set(
|
|
stateRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
|
);
|
|
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
|
|
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
|
|
/**
|
|
* 认不出在办哪一步时退回「在场的第一个有剧本的项」—— **这个兜底只服务提示词**
|
|
* (给模型一份做法总比整本说明书强),**不驱动配方**(2026-08-29 终审 I6)。
|
|
* 带 `repetitionRule` 的项收掉之后仍是 Enabled,于是活步都收完的那一拍这里会退回一个
|
|
* **已经 Completed 的步** —— 让它的配方再跑一遍就是再写一遍台账、再 prune 一遍,
|
|
* carded 为假时甚至再发一张卡。提示词读错一段无非白费一点上下文,副作用可不是。
|
|
*/
|
|
const current =
|
|
liveStep ??
|
|
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
|
/*
|
|
* **触达世界的项,「有没有真的问过人」是一个数,不是一句要模型自己去轨迹里找的话**
|
|
* (2026-08-21 用户实测逼出来的:E04 S5_execute_receipt 的 guide 已经写了
|
|
* 「动手之前先在轨迹里找 [card-reply]」,finish 被拒的理由也点名了同一件事,
|
|
* 模型两次都视而不见,一路把同一行 verdict 重写到连拒五拍熔断)。
|
|
* 与「干完的判据」同一条治法:**能算的不问人、也不指望模型自己算**——
|
|
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
|
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
|
*/
|
|
/*
|
|
* **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
|
|
* · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
|
|
* · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
|
|
* · 有 `guide` → 照常往下问模型(半 auto)。
|
|
* 出错 → 留痕交人,不重试、不退回模型。
|
|
*/
|
|
if (liveStep !== undefined && liveStep.auto !== undefined) {
|
|
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
|
|
const current = liveStep;
|
|
const recipe = liveStep.auto;
|
|
const auto = await autoRunner.run(recipe, {
|
|
taskId,
|
|
stepId: current.item,
|
|
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
|
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
|
quiet: false,
|
|
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
|
|
});
|
|
if (auto.failedAt !== undefined) {
|
|
/*
|
|
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
|
*
|
|
* 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
|
* `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。
|
|
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。
|
|
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
|
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
|
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
|
|
*/
|
|
const stuck = await deps.tasks.of(taskId);
|
|
const failed = auto.failedAt;
|
|
const card = await deps.cards.issue({
|
|
taskId,
|
|
ownerId: stuck.ownerId,
|
|
humanCallCap: stuck.humanCallCap,
|
|
workOrder: 'execution',
|
|
card: {
|
|
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
|
ask:
|
|
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
|
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
|
basis: [
|
|
{ k: '这一步', v: current.item },
|
|
{ k: '第几笔', v: `${failed.index + 1}` },
|
|
{ k: '办不下去的原因', v: failed.message },
|
|
],
|
|
fields: ['decision'],
|
|
},
|
|
});
|
|
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
|
/*
|
|
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
|
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
|
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
|
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
|
*/
|
|
return {
|
|
runtime: stateRuntime,
|
|
enabled: stateEnabled,
|
|
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
|
status: 'awaiting',
|
|
};
|
|
}
|
|
if (auto.submit !== undefined) {
|
|
/*
|
|
* **抛了不能让回合崩**(2026-08-29 终审 C3)—— 与 :1738 模型判齐那处同一条理由,
|
|
* 那儿的注释白纸黑字写着:「推进器会一遍遍重来,每遍再撞一次同一道门」。
|
|
* 而这一处从前是**裸着**的:`engine.advance` 不捕获、`task-service.tick` 也不捕获
|
|
* 且 `addConsumption` 排在 advance 之后(一步都不记)、`task-advancer` 只写 stderr 就 break,
|
|
* 任务留在 `running` —— 下一个 interval 再来一遍:**无限热循环,不烧预算、不换状态、界面纹丝不动**。
|
|
* 处置与模型路径一致:拒绝 + 留痕(那条痕同时是重做闸的解锁凭据)+ 这一拍到此为止。
|
|
*/
|
|
try {
|
|
const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
|
|
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `提交放行被拒:${reason}`,
|
|
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
|
|
});
|
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
|
}
|
|
}
|
|
if (auto.issuedCardId !== undefined) {
|
|
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
|
|
const touched = markWorldTouched(stateRuntime, current.item);
|
|
await deps.state.save(taskId, model, parsed, touched);
|
|
/* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
|
|
if (current.view !== undefined && current.view !== '') {
|
|
await showView(taskId, current.view, touched, current.view);
|
|
}
|
|
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
if (auto.wrote > 0 || auto.dropped > 0) {
|
|
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
|
|
const freshScope = await deps.data.view(taskId);
|
|
const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope);
|
|
if (reswept.runtime !== stateRuntime) {
|
|
await deps.state.save(taskId, model, parsed, reswept.runtime);
|
|
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged)——
|
|
这一拍到此为止、下一拍重新导航,与「判齐失败仍要重来」那处(1749 行)同一种写法 */
|
|
return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'converged' }, status: 'running' };
|
|
}
|
|
}
|
|
/*
|
|
* **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立:
|
|
* 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
|
|
* 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
|
|
* 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
|
|
*
|
|
* **半 auto(声明了非空 `guide`)的步不走这条近道**(R9,评审 Minor #1 逼出来的裁定):
|
|
* 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」—— 人答完卡之后那份判断
|
|
* (读人写的自由文本、落到具体的行上)恰恰是模型该干的活。收在它前面,等于把半 auto
|
|
* 的后半截整个删掉。当前没有活动踩到这个组合,但机制不该埋这个坑。
|
|
*/
|
|
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
|
|
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
|
if (noDoneWhen && carded && recipeIssuesCard(recipe) && current.guide.trim() === '') {
|
|
const active = readyToWork(parsed, stateRuntime, current.item);
|
|
const settled = transitionItem(active, current.item, 'Completed');
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
/* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */
|
|
title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`,
|
|
/*
|
|
* **不再标 internal**(2026-08-29,R10)—— 从前的 `internal: true` 会被
|
|
* `task-log.ts` 的 `machinery()` 挡下,这一笔永远进不了工作日志。
|
|
* `engine-transition` 本就在骨架里,标题也不匹配「完成/开始办」的正则,
|
|
* 只要不被 `machinery()` 滤掉,它就会正确地归系统 —— 不必藏成内部痕。
|
|
*/
|
|
detail: { item: current.item, auto: current.item },
|
|
});
|
|
await deps.state.save(taskId, model, parsed, settled);
|
|
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged,
|
|
见上面 2014 行那条注释)—— 这一拍到此为止、下一拍重新导航,同一种写法 */
|
|
return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
|
}
|
|
|
|
if (current.guide.trim() === '') {
|
|
/*
|
|
* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人。
|
|
* **kind: 'observation'**(2026-08-29,R10 第六处)—— 从前是 `kind: 'result'`,
|
|
* 不在 `SKELETON` 里,这条**交人**的消息永远进不了工作日志,单子看着像凭空卡住,
|
|
* 而人打开日志想知道的第一件事就是「为什么停了」。只改 kind,detail 不动。
|
|
*/
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
|
|
detail: { auto: current.item },
|
|
});
|
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。
|
|
* 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。
|
|
*/
|
|
const owesCard =
|
|
current !== undefined &&
|
|
parsed.items.find((i) => i.id === current.item)?.manualActivation === true &&
|
|
stateRuntime.items.find((i) => i.id === current.item)?.worldTouched !== true;
|
|
const currentTouch = ((): string => {
|
|
if (current === undefined) return '';
|
|
const declared = parsed.items.find((i) => i.id === current.item);
|
|
if (declared?.manualActivation !== true) return '';
|
|
const touched = !owesCard;
|
|
/*
|
|
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
|
|
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
|
|
*/
|
|
if (isReleaseStep(current)) {
|
|
return touched
|
|
? '这一步是终局放行:验收卡已经发出去了,人签字之前不要再动台账、不要再发任何卡;被打回重做照剧本改完再 submit。'
|
|
: '这一步是终局放行(触达世界):**唯一合法的动作是 submit(提交放行)** —— 基座预检后自己发验收卡。' +
|
|
'不要发执行工单 / 数据工单 / 决策卡,也不要立监听(发了会被拒,而且等于把同一件事问人两遍)。' +
|
|
'先 judge 该裁的裁量判据,再 submit,同一拍连做。';
|
|
}
|
|
return touched
|
|
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
|
'照下面「人交回之后」那一段办。'
|
|
: '这一步标了人工激活(触达世界):这一单里从没给它发过卡(human/entry/decide)、' +
|
|
'也没为它立过监听(listen)——**没有别的路,唯一合法的动作是先发那一张卡或立那次监听**,' +
|
|
'写台账、finish 都还轮不到,写了也会被拒。';
|
|
})();
|
|
const staged =
|
|
current === undefined
|
|
? narrative
|
|
: [
|
|
`【当前这一步 · ${current.item}】${current.intent}`,
|
|
currentTouch,
|
|
answeredCardFact(trail),
|
|
current.guide,
|
|
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
|
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
|
/*
|
|
* 收步判据要**连着它此刻的读数一起给**(2026-07-31 加)。
|
|
*
|
|
* 从前只摆表达式:「干完的判据:missing_key_total + missing_minor_total = 0」。
|
|
* 模型看不见这两个数**现在是几** —— 它以为自己已经办完了(缺口都补回来了),
|
|
* 于是把同一批行再写一遍想「确认一下」,撞上空转检查被拒;
|
|
* 拒了它还是不知道差在哪,于是再写一遍……连拒五拍熔断(实测 A1 死于此)。
|
|
*
|
|
* 差的那个数摆出来,这一步就从死胡同变成一句话能办的事:
|
|
* 「missing_key_total 现在是 1,要它变成 0」。
|
|
*/
|
|
/*
|
|
* **还欠一张卡的那一步,不摆干完的判据**(2026-08-28,用户送来的实测单)。
|
|
*
|
|
* 判据本意是「引擎自己核,省一拍」,可它同时是**摆在模型眼前的一个目标**,
|
|
* 后面还跟着一句「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish
|
|
* (上面 currentTouch 那句写着「唯一合法的动作是先发那一张卡」)。同一份提示词里两句话
|
|
* 直接打架:实测它选了判据那句,把台账里原 PN 的资料抄到候选头上去凑「必填不缺」,
|
|
* 然后 finish 连撞五拍墙、熔断。**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑。**
|
|
* 卡发出去、人答过之后判据照常摆(那时它本来就该收步)。
|
|
*/
|
|
current.doneWhen === undefined || owesCard
|
|
? ''
|
|
: `干完的判据(引擎会自己核):${current.doneWhen}` +
|
|
`${readingsOf(current.doneWhen, scope)} —— 成立了就 finish ${current.item},` +
|
|
'别再重写一遍已经写过的行(值没变的写入会被当空转拒掉);' +
|
|
/*
|
|
* **先分清差的是数还是行**(2026-08-01 与提交门同一口径)。
|
|
* 只说「差哪个数就把那个数改对」会把模型引向改数 —— 而实测那一单
|
|
* 差的是行(多写了客户说跳过的那颗料),改数只会让别的数跟着错。
|
|
*/
|
|
'差哪个**数**就把那个数改对;要是读数里的主键名单显示多了或少了**行**,' +
|
|
'多的用 drop 撤掉、少的补写 —— 别把同一行再写一次',
|
|
steps.length <= 1
|
|
? ''
|
|
: `【整条路】${steps.map((x) => (x.item === current.item ? `▶ ${x.item} ${x.intent}` : `· ${x.item} ${x.intent}`)).join('\n')}`,
|
|
narrative,
|
|
]
|
|
.filter((line) => line !== '')
|
|
.join('\n\n');
|
|
|
|
/*
|
|
* **这一步要读的表,拎到刀口之前** —— `域.对象` 整表那一段进提示词会按字符截断,
|
|
* 而截断是盲的:砍掉的可能恰是这一步唯一要用的那张。
|
|
* 实测 A1 第一步:来函正文与 BOM 附件占了近 2000 字,`customer.contact` 整个落在刀外,
|
|
* 模型看不见,报「主数据里没找到」—— 而库里那一行一直好端端在,老客户被当成了新客户。
|
|
* 加 `focus:` 前缀交给提示词层优先摆放:声明了读什么,就一定看得见什么。
|
|
*/
|
|
const focused: Record<string, unknown> = { ...scope };
|
|
for (const address of current?.reads ?? []) {
|
|
if (address in scope) focused[`focus:${address}`] = scope[address];
|
|
}
|
|
|
|
const turn = await deps.agent.decide({
|
|
taskId,
|
|
narrative: staged,
|
|
enabled: stateEnabled,
|
|
scope: focused,
|
|
writable,
|
|
tools,
|
|
});
|
|
/*
|
|
* 一拍收**一串行动**(封顶 TURN_ACTION_CAP)—— 拍是按模型调用计费的:
|
|
* 「写记录、写结算、finish、start 下一项」拆成四拍,就是四遍整段上下文重读。
|
|
* 超出封顶的直接丢弃并留痕 —— 静默截断会让模型以为后面的都做了。
|
|
*/
|
|
const planned = turn.actions.slice(0, TURN_ACTION_CAP);
|
|
if (turn.actions.length > planned.length) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `一拍最多 ${TURN_ACTION_CAP} 个行动,超出的 ${turn.actions.length - planned.length} 个已丢弃 —— 下一拍接着给`,
|
|
detail: { internal: true },
|
|
});
|
|
}
|
|
|
|
/*
|
|
* **先交代,再动手**。
|
|
*
|
|
* 顺序有意义:人在对话区看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果 ——
|
|
* 反过来先干完再说,过程中那几十秒他面对的还是一片空白。
|
|
*
|
|
* 交代是每一拍的附带,不是一型行动:让说话与干活抢拍,就等于把「必须说」
|
|
* 设计成了「有空才说」,而预算下的 Agent 一定选干活(实测:对话区从头到尾空白)。
|
|
*/
|
|
if (turn.say !== undefined && turn.say.text.trim() !== '') {
|
|
/*
|
|
* **同一句话不说第二遍**(2026-07-31 加)。
|
|
*
|
|
* 交代是每一拍的附带,而一件事常要好几拍才办完 —— 于是模型每拍都把
|
|
* 「我打算干什么」原样再说一遍。实测试跑 2e091d25:
|
|
* 「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
|
|
* **一字不差连着吐了三拍**(先 start 那一项、又 start 了一次、才发出决策卡),
|
|
* 人在对话区看到的是同一个问题被问了三遍 —— 像卡带,不像同事在说话。
|
|
*
|
|
* 拦在这儿而不是投影层:投影是「把事实摆出来」,不该替事实做取舍;
|
|
* 说了几遍是**这一层的事实**,重复的那一遍压根不该成为事实。
|
|
*
|
|
* 只比**紧邻的上一句**、只认一字不差(去空白后)。隔了几拍又说回同一句
|
|
* 是合理的(局面绕回来了),近义句更不能拦 —— 那是两次不同的交代。
|
|
*/
|
|
/*
|
|
* ── 2026-08-01:一字不差拦不住「换个说法再说一遍」───────────────────
|
|
*
|
|
* 用户实测那一段对话区里,连着五条「阶段结论」说的是同一件事:
|
|
* 「9颗料都打上标了:7颗A…」「9颗料都打标完了:5颗A…」
|
|
* 「这单9颗料都打标完了:5颗A直接报…」——
|
|
* 字面各不相同,人读到的却是同一句话被复读了五遍。
|
|
*
|
|
* 所以改成**看重合度**:两句归一(去标点空白、只留字)之后,
|
|
* 短的那句有八成以上的字出现在长的那句里,就算同一句 —— 压下去。
|
|
* 阈值定在 0.8 而不是更低:近义但不同的交代(「我去查历史价」/「查出来了」)
|
|
* 重合度远低于此,拦错一句比多说一句更坏。
|
|
* 仍只比**紧邻的上一句**:隔了几拍绕回同一件事是合理的。
|
|
*/
|
|
const saidNow = turn.say.text.trim();
|
|
const saidLast = [...trail].reverse().find((t) => t.kind === 'narration');
|
|
const repeated = saidLast !== undefined && sameGist(saidLast.title, saidNow);
|
|
/*
|
|
* ── 一轮一句,不是一拍一句(2026-08-01 用户定)────────────────────
|
|
*
|
|
* 从前每拍都往对话区吐一句。一件事要好几拍办完,于是人看到的是:
|
|
* 「我这就把审定结论落账,收掉这一步,往下走查历史价。」
|
|
* 「我去查这三颗料的历史价,拿到判据就写进分类表。」
|
|
* 「三颗料按 P-03 打标:…我这就把标写进分类表和受理记录,收掉这一步。」
|
|
* 「三颗料都定好向了:…我这就把两张请求单和去向旗写好,收掉这一步。」
|
|
* 「三颗料都定好向了,受理结论也齐了。…我这就提交放行,你过目签字。」
|
|
* —— 五条,四条在播报自己下一步要动哪张表。用户原话:「太密集了」。
|
|
*
|
|
* 那些不是给人的话,是**工序播报**:写台账、调工具、收计划项,人既不用知道
|
|
* 也帮不上忙 —— 那一段该由「AI 工作中」的办事条覆盖,而不是五条气泡。
|
|
*
|
|
* 口径:**设定目标 →(分析现状 → 规划 → 执行)×N → 一个气泡**。
|
|
* 气泡出在**这一轮收口的那一拍** —— 也就是控制权要交出去的时候:
|
|
* 发卡等人(ask / human / decide)、立监听等外部(listen)、提交放行(submit)。
|
|
* 别的拍照旧留痕进工作日志(`internal`),只是不进对话区。
|
|
*
|
|
* 为什么按「planned 里有没有挂起型行动」判,而不是等这一拍跑完再回头补:
|
|
* 交代必须**赶在动手之前**说出口(见上面那段注释)—— 人要先看见「我要去办什么」,
|
|
* 再看见卡。等跑完再说,顺序就反了。
|
|
*/
|
|
if (repeated || !handsOff(planned)) {
|
|
/*
|
|
* 压下去了也要留痕 —— 但只进日志、不进对话区(`internal` 标,投影会滤掉)。
|
|
* 对话区要干净,排障的人却需要知道这一拍模型说了什么。
|
|
*/
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: repeated ? '这一拍的交代与上一拍说的是同一件事,已压下' : '这一拍在往下办事,交代留日志、不进对话区',
|
|
detail: { internal: true, say: saidNow },
|
|
});
|
|
} else {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'narration',
|
|
title: turn.say.text.trim(),
|
|
...(turn.say.tag === undefined ? {} : { detail: { tag: turn.say.tag } }),
|
|
});
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 画面**每拍都推,优先按剧本**:模型指定了用它的,没指定就用这一步声明的那张。
|
|
*
|
|
* 两处都改过(2026-07-31):
|
|
* ① 从前只在模型「说了话」时才推 —— 而收口那一拍它常常只提交、不交代,
|
|
* 于是放行卡上嵌的还是上一段的件:人签的是「询价受理需求」,
|
|
* 而他要签的是「统一受理记录」。**签字页错了,签的就不是那件事**。
|
|
* ② 从前模型不指定就压根不推。「这一步该看哪张件」是写清单时定死的事,
|
|
* 不是模型临场的发挥。
|
|
*/
|
|
/*
|
|
* **剧本声明了就以剧本为准** —— 模型的 `say.view` 只在这一步没声明时才作数。
|
|
*
|
|
* 优先级反过来试过一轮,不行:模型每拍都报一个**合法但不对**的模版名
|
|
* (它偏爱第一张「询价受理需求」),于是从查历史价一直到放行,工作区停在阶段二那份件 ——
|
|
* 人在放行卡上签的是执行件,不是他要签的受理记录。报的名字合法,所以连一条失败留痕都没有。
|
|
* 「这一步该看哪张」是写清单时定死的事:定死了就别再让它临场发挥。
|
|
*/
|
|
/*
|
|
* 取的是**还没干完的那一步**声明的画面(`liveStep`),不是带兜底的 `current`。
|
|
* S1/S2 标了可重复,收掉之后仍留在可为集里 —— 拿兜底那一份,整单收完之后
|
|
* 还会把画面推回阶段二的执行件,把人刚签过的放行页冲掉(实测:定稿归档里
|
|
* 存的不是他签的那份)。都干完了就交给模型自己说要哪张,没说就一帧不推。
|
|
*/
|
|
const wanted = liveStep?.view !== undefined && liveStep.view !== '' ? liveStep.view : turn.say?.view;
|
|
|
|
/*
|
|
* 按序执行这一串。三条纪律(引擎强制,不靠模型自觉):
|
|
* · 谁被拒就停在谁那儿 —— **已执行的前缀留着**(拒绝 + 留痕 + 理由喂回下一拍;
|
|
* AI 提出非法行动是预期内的事,引擎的职责是不执行,不是崩掉);
|
|
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
|
|
* · 挂起型(ask / decide / human / listen / submit / 转人)落地即收拍。
|
|
*/
|
|
let currentRuntime = stateRuntime;
|
|
let status: TurnOutcome['status'] = 'running';
|
|
let blockedReason: string | undefined;
|
|
let saved = false;
|
|
let framed = false;
|
|
let lastAction: AgentAction | undefined;
|
|
/** 这一拍发出去的卡是哪一步的 —— 画面跟卡走 */
|
|
let issuedFor: string | undefined;
|
|
/*
|
|
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
|
|
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
|
|
* `scope` 求值,刚落进草稿的那几行看不见 —— finish 被「算出来是假」误拒,
|
|
* 下一拍引擎又用同一份数据自动收步:同一份数据两个裁决,每单白吃一次被拒。
|
|
* 写/撤落笔后标脏,轮到 finish 时才重取一次任务视图(不是每写一笔就取)。
|
|
*/
|
|
let liveScope = scope;
|
|
let scopeStale = false;
|
|
|
|
for (let index = 0; index < planned.length; index += 1) {
|
|
const action = planned[index]!;
|
|
lastAction = action;
|
|
let applied: {
|
|
runtime: CaseRuntime;
|
|
status: TurnOutcome['status'];
|
|
reason?: string;
|
|
saved?: true;
|
|
framed?: true;
|
|
issuedFor?: string;
|
|
};
|
|
try {
|
|
// AI 不得产生终局迁移 —— start 一个不存在的项按被拒处理,不掀翻整拍
|
|
if (action.kind === 'start' && parsed.items.find((i) => i.id === action.itemId) === undefined) {
|
|
throw new ForbiddenTerminalError('owner');
|
|
}
|
|
if (action.kind === 'finish' && scopeStale) {
|
|
liveScope = await deps.data.view(taskId);
|
|
scopeStale = false;
|
|
}
|
|
applied = await applyAction(
|
|
taskId,
|
|
action,
|
|
currentRuntime,
|
|
(itemId) => {
|
|
const step = stepOf(itemId);
|
|
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
|
try {
|
|
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
|
|
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
|
|
} catch {
|
|
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
|
return undefined;
|
|
}
|
|
},
|
|
index < planned.length - 1,
|
|
/*
|
|
* 归人填的台账:**认行动点名的那一项**,点不出(或那一项没剧本)才退回当下在办的那一步。
|
|
* 与 faceOf 同一套判法。压拍时骨架写在上一步名下、填值写在这一步名下,正好各归各的。
|
|
*/
|
|
(itemId, address) => {
|
|
const named = itemId === undefined ? undefined : stepOf(itemId);
|
|
const step = named ?? current;
|
|
if (step === undefined || !(step.humanWrites ?? []).includes(address)) return undefined;
|
|
return (
|
|
`${address} 这张表归人填 —— 它是「${step.item}」这一步的数据工单要人在件上补的表(剧本声明 humanWrites),` +
|
|
'值只能从人那儿来,你没有来源:别拿别的料号 / 别的行的值顶上(那是假数据,后面会被当证据用)。' +
|
|
'这一步该做的是把那张数据工单发出去(human + execution + 数据工单面孔),人填完交回,判据自己就成立了。' +
|
|
'已经写进去的错值用 drop 撤掉 —— drop 不拦。'
|
|
);
|
|
},
|
|
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
|
|
(itemId) => {
|
|
const named = itemId === undefined ? undefined : stepOf(itemId);
|
|
const step = named?.cardVariant !== undefined || named?.acceptanceChecklist !== undefined ? named : (current ?? named);
|
|
const variant = step?.cardVariant === 'review' ? ('review' as const) : undefined;
|
|
const checklist = step?.acceptanceChecklist;
|
|
const release = isReleaseStep(step);
|
|
if (variant === undefined && checklist === undefined && !release) return undefined;
|
|
return {
|
|
...(variant === undefined ? {} : { variant }),
|
|
...(checklist === undefined ? {} : { checklist }),
|
|
...(release ? { release: true as const } : {}),
|
|
};
|
|
},
|
|
);
|
|
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `行动被拒${planned.length > 1 ? `(这一串的第 ${index + 1} 个)` : ''}:${reason}`,
|
|
detail: { action, hint: '前面的行动已生效;请纠正这一个,或换一件事做' },
|
|
});
|
|
break;
|
|
}
|
|
currentRuntime = applied.runtime;
|
|
status = applied.status;
|
|
if (applied.reason !== undefined) blockedReason = applied.reason;
|
|
if (applied.saved === true) saved = true;
|
|
if (applied.framed === true) framed = true;
|
|
if (applied.issuedFor !== undefined) issuedFor = applied.issuedFor;
|
|
// 挂起型落地即收拍:卡已发 / 监听已立 / 验收已提 —— 这一拍到此为止
|
|
if (applied.status !== 'running') break;
|
|
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
|
|
if (action.kind === 'tool' && index < planned.length - 1) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'observation',
|
|
title: `工具已返回,同拍余下 ${planned.length - index - 1} 个行动作废 —— 下一拍看着出参再定`,
|
|
detail: { internal: true },
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* **拍尾再扫一遍**(2026-08-27 晚):人答卡的那一拍里模型写完东西,上一步的 doneWhen 往往这时才真
|
|
* (值是人在件上填的、旗是这一拍记的);不等下一拍 —— 下一拍可能要等人再答一张卡才来,
|
|
* 而画面与进度条在这之间都得对(实测:P-08 没收、P-10 的卡发了,人对着 P-08 的件无处可填)。
|
|
* 提交放行那一拍(saved)不扫:它已连在途验收一起落盘,再存一次会把信封擦掉。
|
|
* 这一拍刚发卡的项排除(卡还没人答,收它就是替人签字)。
|
|
*/
|
|
if (!saved) {
|
|
if (scopeStale) {
|
|
liveScope = await deps.data.view(taskId);
|
|
scopeStale = false;
|
|
}
|
|
const enabledTail = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, [...stateEnabled.gaps]);
|
|
currentRuntime = (await sweepRipe(currentRuntime, enabledTail, liveScope, issuedFor)).runtime;
|
|
}
|
|
// 已经自己落过笔的行动(提交放行连在途验收一起存)不再兜底保存 —— 见 submit 分支的注释
|
|
if (!saved) await deps.state.save(taskId, model, parsed, currentRuntime);
|
|
|
|
/*
|
|
* ── 一回合推一帧,**推在行动之后** ────────────────────────────────
|
|
*
|
|
* 这一下从前在行动**之前**,于是工作区里那份件永远比数据早一拍:
|
|
* S2 那一拍把三条料写进草稿,而那一帧是写之前推的 —— 清单一行都没有。
|
|
* 偏偏这一拍以「发卡问客户」收尾,整单就挂在那儿等人:人打开工作台,
|
|
* 看到的是一张空表,合理地判断「PN 没抽出来」(用户实测报的正是这个)。
|
|
* **画面是投影,投影就得投最新的那一刻。**
|
|
*
|
|
* 模版名:写了剧本的按「还没干完的那一步」声明的那张;没写剧本的退到 `case-overview`
|
|
* (`showView` 再退到活动声明的第一张)。活步都收完且模型没点名 → 一帧不推,
|
|
* 让最后摆上去的那张(放行页)留在档案里。
|
|
* `framed` 是提交那一下打的招呼:放行页已由 `raiseAcceptance` 摆好,别再盖掉。
|
|
*/
|
|
/*
|
|
* 挑哪一张:剧本声明的(或模型点名的)优先;都没有时,**只给没写剧本的活动**
|
|
* 兜一张 `case-overview`(`showView` 再退到它声明的第一张)。
|
|
* 写了剧本却挑不出 —— 活步都收完了 —— 就一帧不推。
|
|
*/
|
|
/*
|
|
* **挑画面要按「这一拍办完之后」在办的那一步**(2026-08-23,E04 S4→S5 实测)。
|
|
*
|
|
* `liveStep` 是**拍首**算的,而一串行动常常把案子往前推一步:那一拍收掉 S4
|
|
* (经营计划审批)、当场发出 S5 的执行卡,画面却仍按 S4 推 —— 人打开工作台,
|
|
* 卡上写着「把回执填到件上」,屏上摆的是审批那份只读件,**一个可填的格子都没有**;
|
|
* 提交闸如实拦住「缺完成状态、结果摘要、关键人变动」,而他无处可填。
|
|
* 这是设计律第一条「墙要配路」的又一次现形,病根与 `liveScope` 那条同源:
|
|
* **拿拍首的局面去决定拍尾的事**。推之前按当下的局面重算一次。
|
|
*
|
|
* 只在真的算出了一步时才顶掉 `wanted` —— 活步都收完那种情形照旧走老路
|
|
* (`framed` 或一帧不推),免得把人刚签过的放行页冲掉。
|
|
*/
|
|
if (scopeStale) {
|
|
liveScope = await deps.data.view(taskId);
|
|
scopeStale = false;
|
|
}
|
|
const settledNow = new Set(
|
|
currentRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
|
);
|
|
const enabledNow = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, []);
|
|
const stepNow = [...enabledNow.inProgress, ...enabledNow.entries]
|
|
.filter((e) => !settledNow.has(e.id))
|
|
.map((e) => stepOf(e.id))
|
|
.find((x) => x !== undefined);
|
|
/*
|
|
* **画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:「当前这一步不是应该给我推
|
|
* P-10 的数据工单吗?怎么还是推送的芯片主数据创建,这样我都没法填数据了」)。
|
|
* 上面那条「按办完之后在办的那一步」在**两步同时 Active** 时仍会挑错:模型没收掉 S0d_p08(剧本白纸黑字
|
|
* 写了「先 finish 再发下一张」,两处;措辞治不了),直接发了 S0d_p10 的数据工单 —— 「第一步没干完的」
|
|
* 算出来还是 P-08,人拿着 P-10 的卡对着 P-08 的件,提交闸拦「缺资料」而屏上没有资料表可填。
|
|
* 卡是发给人的那件事,件必须是那件事的件:这一拍发了哪一步的卡(human / entry / decide),
|
|
* 那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。
|
|
*/
|
|
const issuedStep = issuedFor === undefined ? undefined : stepOf(issuedFor);
|
|
const issuedView = issuedStep?.view !== undefined && issuedStep.view !== '' ? issuedStep.view : undefined;
|
|
const wantedNow = issuedView ?? (stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted);
|
|
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
|
|
if (!framed && frame !== undefined && frame !== '') {
|
|
try {
|
|
const shown = await showView(taskId, frame, currentRuntime, issuedView ?? stepNow?.view ?? liveStep?.view);
|
|
if (!shown.pushed) {
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `换画面失败:这个活动没有名为「${frame}」的模版(可选:${shown.templates.join('、') || '无'})`,
|
|
});
|
|
}
|
|
} catch {
|
|
// 呈现失败不影响回路:画面丢一帧,事实一件不少
|
|
}
|
|
}
|
|
|
|
return {
|
|
runtime: currentRuntime,
|
|
enabled,
|
|
navigation,
|
|
...(lastAction === undefined ? {} : { action: lastAction }),
|
|
status,
|
|
...(blockedReason === undefined ? {} : { blockedReason }),
|
|
};
|
|
},
|
|
|
|
/** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */
|
|
async onEvent(taskId, event) {
|
|
const { model, parsed, runtime, pending } = await loaded(taskId);
|
|
// 投递痕由 task 层的 deliver 记(它是投递缝);这里再记一条就是同一事件日志两行
|
|
|
|
let next = runtime;
|
|
// 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed
|
|
const listener = parsed.items.find((i) => i.id === event.ref && i.type.endsWith('EventListener'));
|
|
if (listener !== undefined) next = occur(runtime, listener.id);
|
|
|
|
/*
|
|
* 验收裁决到达:落进状态信封,终局由下一拍(deliver → tick → runTurn)裁定。
|
|
* 只记不跑 —— 推进(可能含一次模型决策)统一发生在 tick,预算才有一个口径。
|
|
*/
|
|
let nextPending = pending ?? null;
|
|
if (event.kind === 'human' && pending?.acceptanceCardId === event.ref) {
|
|
const verdict = event.payload['verdict'] === 'pass' ? ('pass' as const) : ('reject' as const);
|
|
const ports = event.payload['selectedPorts'];
|
|
// 打回重做时人写的意见也要落进信封 —— 重做那一拍模型看的就是它
|
|
const comment = event.payload['comment'];
|
|
nextPending = {
|
|
...pending,
|
|
verdict,
|
|
...(typeof comment === 'string' && comment.trim() !== '' ? { comment: comment.trim() } : {}),
|
|
...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record<string, string> } : {}),
|
|
};
|
|
}
|
|
|
|
await deps.state.save(taskId, model, parsed, next, nextPending);
|
|
},
|
|
|
|
/** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */
|
|
async decide(taskId, decision) {
|
|
const { model } = await loaded(taskId);
|
|
const scope = await deps.data.view(taskId);
|
|
const order = evaluationOrder(model.dmn, decision);
|
|
const enriched: Record<string, unknown> = { ...scope };
|
|
let last = undefined as ReturnType<typeof evaluateTable> | undefined;
|
|
|
|
for (const step of order) {
|
|
const table = model.dmn.tables.find((t) => t.id === step);
|
|
if (table === undefined) continue;
|
|
const result = evaluateTable(table, enriched, deps.sfeel.expression, deps.sfeel.unaryTest);
|
|
last = result;
|
|
if (result.kind !== 'decided') break;
|
|
Object.assign(enriched, result.trace.outputs);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'evaluation',
|
|
title: `DMN 求值:${table.label} → 命中 ${result.trace.hitRules.join('、')}`,
|
|
detail: { inputs: result.trace.inputs, outputs: result.trace.outputs, table: table.id },
|
|
});
|
|
}
|
|
|
|
return last ?? { kind: 'no-match', inputs: {} };
|
|
},
|
|
|
|
/** 驳回:相关计划项以新实例重新入场(repetitionRule),记为纠正信号 */
|
|
async reenter(taskId, itemId) {
|
|
const { model, parsed, runtime } = await loaded(taskId);
|
|
const next = reenterItem(runtime, itemId);
|
|
await deps.state.save(taskId, model, parsed, next);
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
title: `驳回重入:${itemId}(新实例)`,
|
|
});
|
|
return next;
|
|
},
|
|
};
|
|
}
|