/** * 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 | 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); 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 = '配方办不下去'; /** * 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 —— * 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支, * 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面 * `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。 */ const AUTO_SUBMIT_REFUSE_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>): 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 | 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 lastCardAnswer( window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record | undefined }>, ): { readonly title: string; readonly chose: string | undefined } | undefined { 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 undefined; /* 后面又发了别的卡 = 有新问题在等,这条回传已经翻篇 */ if (window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title))) return undefined; const value = reply.detail?.['value']; return { title: reply.title, chose: typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined }; } /** * **人的这条答复算不算「这一步办完了」**(2026-08-31 用户实测:「我重做后怎么直接推进了? * 不应该是重做刚才那一步吗?」)。 * * 病:「卡答过了就收步」那条近道(全 auto 的步、人答完就收)**不看人答的是什么** —— * 审核卡上点「重做」并写了意见,与点「确认通过」在它眼里一模一样:当场收掉审核那一步、 * 发出落账放行的验收卡。意见倒是落进了轨迹,可再没有任何东西会去读它。 * * 平台只判它知道的那两件(选项值的语义平台不知道 —— 见 `chosenGuidance`): * · `custom:` 开头 = 他没选任何一个选项、写的是自己的话 —— 「重做」那一钮交上来的正是它; * · `reject` = 验收面孔约定的驳回项(`review-face.ts` 按这个值找那一钮)。 * 空答复照旧算**照原样确认**(既有口径,见下面 `answeredCardFact` 那句话)。 */ export function answerSettlesStep(chose: string | undefined): boolean { const value = chose?.trim() ?? ''; return value !== 'reject' && !value.startsWith('custom:'); } export function answeredCardFact( window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record | undefined }>, ): string { const answer = lastCardAnswer(window); if (answer === undefined) return ''; const head = `【这张卡人已经答过了】「${answer.title.length <= 60 ? answer.title : `${answer.title.slice(0, 60)}…`}」`; return answer.chose === undefined ? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。` : `${head} → ${chosenGuidance(answer.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 | 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): Set { 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; 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> = []; const missed: Array> = []; 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; // 留痕里那一条 `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,那条痕永远不写:**这堵墙对配方是永远开不了的**。 * * **配方那一路要求真写了或真撤了**(2026-08-29 复审 M1/M2 二审):`project` 动作里 * `localExisting`(已在库、这一笔不用建)与 `localUnkeyed`(主键算不出来没写)也会 * 留痕(M1/M2 立的「一行没写也要出声」),两者台账其实一个字没动。只看 * `detail.auto` + `detail.ledger` 这两个键在的话,一颗料 skipExisting、或一行主键 * 算不出来,这条痕就照样解锁 —— 人打回重做,配方原样重跑,闸却开了(正是这道闸 * 要拦的空转重提)。auto-runner 现在把「真改过」摆成 `detail.changed`,这里照它认; * `kind:'action'` 那一支不必看这个键 —— 模型的 write/drop 一行没变就直接拒(见 * :980/:1075),能落成 `kind:'action'` 的一定是真改过的。 */ const revised = after.some((e) => { if (typeof e.detail?.['ledger'] !== 'string') return false; if (e.kind === 'action') return true; return typeof e.detail?.['auto'] === 'string' && e.detail?.['changed'] === true; }); 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 { 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], ); let quietWrote = false; if (quietStep?.auto !== undefined) { const outcome = 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, }); quietWrote = outcome.wrote > 0 || outcome.dropped > 0; } /* * **这一拍真写下过东西,收拍前把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查 * 实测:人给候选料选好芯片类型、按保存,上面的配方铺出 7 行新参数、红带也点名要填 * 这 7 格,而这条路从不推帧 —— 任务停在 `awaiting-event`,人刷新页面看到的仍是保存前 * 那份快照(件是服务端渲好推来的快照,重放不重渲),被点名的格子屏上根本不存在, * 走查到此卡死。**这极可能与 e2e 反复撞见的「答完卡不推进」异常同一个短路**: * 数据工单的下一轮填报要看这一步刚铺出来的新字段,画面不重推,人 / 脚本都无从下手, * 之后再没有任何事件会把它唤醒。 * * 走到这儿状态机什么都没变(quiet 只跑 project/say,不迁移计划项),`runtime` 仍是 * 拍首那份,画面按 `quietStep` 声明的那张推就够 —— 与「拍首跑配方」发卡那条同一份 * 判断(下面 `issuedCardId` 那处:「这一步声明了画面才推,没声明就沿用上一帧」)。 */ if (quietWrote && quietStep?.view !== undefined && quietStep.view !== '') { try { await showView(taskId, quietStep.view, runtime, quietStep.view); } catch { // 呈现失败不影响回路:画面丢一帧,事实一件不少(与末尾推帧同一条纪律) } } 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, scopeIn: typeof scope, exclude?: string, ): Promise<{ runtime: CaseRuntime; enabled: ReturnType }> => { 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; /* * **被打回就重开一轮**(2026-08-31 用户实测:「我重做后怎么直接推进了?」)。 * * 「发过就不再发」的幂等挡的是**同一问被重复发出去**;而人点「重做」正是说 * 「这一轮不算,按我的意见改了再问我一遍」—— 那不是重复,是下一轮。 * 于是这一拍的 `carded` 让位:配方重新跑一遍(该重算的重算),再把卡发出去。 * 收步那条近道下面也认同一件事 —— 两处共用 `answerSettlesStep`,一处真源。 */ const answer = lastCardAnswer(trail); const settled = answerSettlesStep(answer?.chose); const touchedItem = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true; if (touchedItem && !settled) { await deps.memory.append({ taskId, kind: 'observation', title: `人把这一步打回重做${answer?.chose?.startsWith('custom:') === true ? `:${answer.chose.slice(7).trim()}` : ''}`, detail: { item: current.item, hint: '这一步不算办完 —— 按他的意见重算之后再问他一遍(卡会重新发一张,不是同一张的复读)。', }, }); } 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: touchedItem && settled, /* 打回那句话交给配方(`case_redo_note`)—— 活动据它决定这一轮要不要把意见交回给 AI 重跑 */ ...(settled || answer?.chose === undefined ? {} : { redoNote: answer.chose.startsWith('custom:') ? answer.chose.slice(7).trim() : answer.chose }), }); if (auto.failedAt !== undefined) { /* * **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。 * * 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡 * `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。 * 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。 * 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。 * 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了), * 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。 * * **这一句「由调人预算兜底」本身就是引爆点**(2026-08-29 复审 I1 二审): * `deps.cards.issue` 走的是 `human-tool-service.assertBudget`,调人预算一旦触顶 * 就抛 `HumanCallBudgetExceededError` —— 而这一段从前是**裸着**调的,一抛就精确 * 复现 C3 那种无限热循环(`engine.advance` / `task-service.tick` 都不捕获, * `task-advancer` 只写 stderr 就 break,任务停在 `running`、界面纹丝不动、每 2 秒重来)。 * 与 :2073(C3 那处)同款收场:抛了不能让回合崩,退回旧行为(留痕 + `awaiting`) * 总好过热循环。 */ const stuck = await deps.tasks.of(taskId); const failed = auto.failedAt; try { 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:配方那一笔归系统灰)。 */ } 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: `本该交给人的那句话:「第 ${failed.index + 1} 笔 —— ${failed.message}」。` + '发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去),' + '也可能是活动包配方写错了(去设计台改完重跑)。', }, }); } 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); /* * **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。 * * 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步 * (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`, * `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个 * `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因 * (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈: * 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份 * 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算, * 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记, * `stallAfterMs` 那道熔断因此永远不会提前触发)。 * * **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的 * 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次 * 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对, * 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙: * 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式—— * 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。 */ const already = (await deps.memory.window(taskId, STALL_WINDOW)).some( (t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`, ); await deps.memory.append({ taskId, kind: 'result', title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`, detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' }, }); if (!already) { return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' }; } const stuck = await deps.tasks.of(taskId); try { const card = await deps.cards.issue({ taskId, ownerId: stuck.ownerId, humanCallCap: stuck.humanCallCap, workOrder: 'execution', card: { title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`, ask: '这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' + '也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' + '数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' + '判据或配方本身有问题就找开发改完重跑。', basis: [ { k: '这一步', v: current.item }, { k: '拒回原话', v: reason }, ], fields: ['decision'], }, }); await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' }); } catch (issueError) { const issueReason = issueError instanceof Error ? issueError.message : String(issueError); await deps.memory.append({ taskId, kind: 'result', title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`, detail: { auto: current.item, hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`, }, }); } return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] }, status: 'awaiting', }; } } 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); } /* * **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停,第二次卡了一整例: * 卡 02:06:13.250 发出、**219ms 后**就被答了,而这一拍 .636 才收尾)。 * * 为什么会没人叫醒:**决策卡的回传不发事件,只调 `wake`** —— 而 `wake` 只翻停着的单子, * 那一刻这一拍还在 `running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`, * 再没有第三样东西会来叫(工单回传那一路还有人事件兜底,决策卡这一路一样都没有)。 * * 所以收尾前再看一眼**刚发的这张卡还在不在等**:已经答了就照实说「还在跑」, * 推进器下一轮接着处理那份答复。**只认这一张卡** —— 别的「等外部回音」照旧是等, * 不会因此空转成热循环。 */ const stillPending = await deps.cards.unanswered(taskId); const answeredAlready = !stillPending.some((c) => c.id === auto.issuedCardId); if (answeredAlready) { await deps.memory.append({ taskId, kind: 'observation', title: '这张卡刚发出去就被答了 —— 这一拍不算等人,接着跑', detail: { cardId: auto.issuedCardId, item: current.item, internal: true }, }); } return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: answeredAlready ? 'running' : '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; /* * **人签了字才叫办完 —— 他说重做就不是**(2026-08-31,见 `answerSettlesStep` 的头注: * 打回与通过从前在这儿一模一样,于是「重做」当场变成了「推进」)。 * 走到这儿说明配方这一拍没能重新发出卡(条件不成立 / 预算见底)—— * 那就停在这一步不收,下面那条「配方跑完了,可这一步还没算办完」把话说给人听, * 而不是替他把字签了。 */ if (noDoneWhen && carded && settled && 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 = { ...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 } : {}), }; } 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 = { ...scope }; let last = undefined as ReturnType | 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; }, }; }