/** * 范式引擎与任务运行时的装配 —— app 是唯一组装层(编码规范 §5.2)。 * * 这里把 M4/M5 的模块用**端口**接起来:跨层协作一律经端口,模块之间不互相认识。 */ import { sql } from 'drizzle-orm'; import type { Db } from '@shared/db.ts'; import type { LandedFacts } from '@modules/task'; import type { EventService } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { MemoryService } from '@modules/memory'; import type { ToolboxService, ToolContract, ToolHandoffStore, ToolInvoker } from '@modules/toolbox'; import type { TraceService } from '@modules/trace'; import { callKeyOf, coerceBySchema, compileToolBus, handoffFields } from '@modules/toolbox'; import type { ExecutionWorkOrderDraft, HumanToolService } from '@modules/human-tool'; import { renderView } from '@modules/interaction'; import type { InteractionService } from '@modules/interaction'; import type { MessageService } from '@modules/message'; import type { OrgService } from '@modules/org'; import type { LlmService, ModelGateway } from '@modules/llm'; import type { ActivityRegistryService } from '@modules/activity-registry'; import type { SkillEngineService } from '@modules/skill-engine'; import type { ReleaseService } from '@modules/release'; import { idempotencyKey } from '@modules/release'; import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine'; import type { LoopEngineService } from '@modules/loop-engine'; import { createSkillEngineService } from '@modules/skill-engine'; import { createTaskService, createTaskStore } from '@modules/task'; import type { TaskService } from '@modules/task'; import { createProcessService, createProcessStore, createSelfHostedRuntime } from '@modules/process'; import type { FlowRuntime, ProcessServiceDeps } from '@modules/process'; import type { ProcessService } from '@modules/process'; import { createTriggerService, createTriggerStore } from '@modules/trigger'; import type { TriggerService } from '@modules/trigger'; import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel'; import type { StaffId, TaskId } from '@contracts'; import { cardIdSchema, staffIdSchema } from '@contracts'; import { archiveAssetFiles } from '../asset-archive.ts'; import { createAgentRunner } from '../agent-runner.ts'; import { createMastraAgentRunner } from '../mastra-agent-runner.ts'; import type { Clock, IdGenerator } from '../runtime.ts'; export type EngineWiringDeps = { readonly db: Db; readonly clock: Clock; readonly ids: IdGenerator; readonly event: EventService; readonly ledger: LedgerService; readonly memory: MemoryService; readonly toolbox: ToolboxService; readonly toolInvoker: ToolInvoker; /** 工具转人台(§6.2 未命中转人)—— 人是工具的一种实现 */ readonly toolHandoff: ToolHandoffStore; readonly humanTool: HumanToolService; readonly interaction: InteractionService; readonly message: MessageService; readonly org: OrgService; readonly llm: LlmService; readonly modelGateway: ModelGateway; readonly registry: ActivityRegistryService; readonly skills: SkillEngineService; readonly release: ReleaseService; readonly schemas: { validate(schema: Record, value: unknown): { ok: boolean; errors: string[] } }; /** 全链观测:节点起落、工具/模型/人卡各打一个 span —— 观测尽力而为,不掀翻业务 */ readonly trace: TraceService; }; export type EngineWiring = { readonly task: TaskService; readonly process: ProcessService; readonly trigger: TriggerService; /** 案例引擎本体 —— 工作台要按它算阶段进度(`phaseOf`);推进走 task,不从这里走 */ readonly loop: LoopEngineService; /** * **此刻这一单的判据长什么样** —— 按当下数据现算。 * * 发卡那一刻算死是不行的:人在件上补一格、撤一行,判据就该跟着变。 * 与提交门读的是**同一段**(`criterionCheck`)—— 两处各算各的, * 就会出现「卡上说达标、提交门说不行」,那种矛盾人是判不出对错的。 */ readonly checks: { of(taskId: TaskId): Promise>> }; /** 视图闸 —— 推一帧;`repush` 按当下数据重渲同一张(人在件上改完要用它) */ readonly views: { push(input: { taskId: TaskId; activityId: string; template: string; scope: Record }): Promise<{ pushed: boolean; templates: string[]; }>; repush(taskId: TaskId): Promise; }; }; function writeLog(line: string): void { globalThis.process.stderr.write(`${line}\n`); } /** S-FEEL 三件套 —— 求值异常按「不成立」处置,不让流转停摆 */ const sfeel = { condition: (expression: string, scope: Readonly>): boolean => { try { return evaluateCondition(expression, scope); } catch { return false; } }, expression: (expression: string, scope: Readonly>): unknown => { try { return evalSfeel(expression, scope); } catch { return undefined; } }, unaryTest: (cell: string, value: unknown, scope: Readonly>): boolean => { try { return evaluateUnaryTest(cell, value, scope); } catch { return false; } }, }; /** * 台账列的归型 —— **写进草稿之前就把型归好**(见 `draftWrite` 里的注释)。 * * 三种结局:`value` 写这个值 / `empty` 这一列没有值(不写)/ `reject` 当场拒。 * 归不出来宁可拒:留到 `releaseWrite` 才炸,是在人签完字之后炸 —— 最坏的失败点。 * * 纯函数、导出可测:这类归型规则错一条,症状是几天后某一单在放行时莫名其妙挂掉。 */ /** * 本单每张表写下了几行 —— `{ 'mine:域.对象': 行[] }` → `{ '域_对象_rows': 行数 }`。 * * 名字为什么是下划线:S-FEEL 的路径不认冒号也不认点号,`mine:域.对象` 那一份取不到; * 而判据要能写成 `quote_intake_record_rows = line_total` 这样一句才有用。 * * 它存在的理由只有一个:**让机检判据有一边不是活动自报的**。 * 两边都由活动写,它数错一次就一起错、一起「对上」(实测:条目数报 9、表里躺着 10, * 派单数与三类条数同错同过)—— 那样的对账防的是疏忽,防不了口径。 */ /** * **算出这一单走哪一口** —— 选择组内按序求值,首个命中即出(与 DMN 的 FIRST 同一口径)。 * * 组内**每一口都得写 `when`** 才算得出来:少一口就说明这组分支有一段没有判据, * 那时宁可退回「人选一口」,也不要按半套规则替他决定。 * 谓词求值抛错当作不命中:一条写坏的谓词不该让整单卡在放行门前。 */ export function routePort( group: { group: string; ports: ReadonlyArray<{ name: string; description?: string | undefined; when?: string | undefined }>; }, scope: Record, evaluate: (expression: string, scope: Record) => boolean, ): { group: string; name: string; label?: string; because: string } | undefined { if (!group.ports.every((p) => p.when !== undefined && p.when.trim() !== '')) return undefined; for (const port of group.ports) { let hit = false; try { hit = evaluate(port.when ?? '', scope); } catch { hit = false; } if (hit) { return { group: group.group, name: port.name, ...(port.description === undefined ? {} : { label: port.description }), because: port.when ?? '', }; } } return undefined; } /** * 存的是 UTC,摆到屏上要**本地时刻**。 * * 直接切 ISO 串那八个字符,屏上就写着 `07-31 20:16`,而人这边是 `08-01 04:16` —— * 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实。 * 归零到分:报告上摆秒没有意义,还会让同一单的两份件看起来不一样。 */ export function localStamp(iso: string): string { const at = new Date(iso); if (Number.isNaN(at.getTime())) return ''; const two = (n: number): string => String(n).padStart(2, '0'); return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; } export function rowReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string }> = [], ): Record { const out: Record = {}; /* * **一行没写也是一个读数:0** —— 先按声明把每张表铺成 0,再让实际行数盖上去。 * * 只数「写过的表」栽过一次(2026-08-01,全 A 类那条路):一单没有 C 类, * 于是替代评估请求那张表一行都没写,`quote_intake_alt_request_rows` 整个键**不存在**; * 而判据是 `quote_intake_alt_request_rows = type_c_total`(0 = 0 本该成立), * 未绑定的名字参与比较求值为假 —— 这条判据**永远过不去**,模型连撞五拍熔断, * 而数据本身完全合规。「没写过」与「写了 0 行」在业务上是同一件事,读数上也得是。 * * 铺的是**这个活动声明过的表**:声明面就是它的授权面,不在声明里的表本来也数不着。 */ for (const { domain, object } of declared) out[`${domain}_${object}_rows`] = 0; for (const [key, list] of Object.entries(mine)) { if (!key.startsWith('mine:')) continue; out[`${key.slice('mine:'.length).replace(/\./g, '_')}_rows`] = list.length; } return out; } /** * 本单每张表写下的**是哪几行** —— `{ '域_对象_keys': 'A、B、C' }`。 * * 行数类判据拒回时只给两个数(「10 ≠ 9」),模型看得见对不上、看不见多的是哪一行, * 于是只能猜:改数、重写、再提 —— 三条路都不对,连拒五拍熔断(实测三种形态全是这一类)。 * 主键名单一摆就是一句话能办的事:多的那个 PN 就在名单里对着。 * * 摆的是**主键**(声明里 `primaryKey` 那几列),不是整行:整行铺出来会把拒回理由撑爆, * 而认一行只需要它的主键。单行封顶 40 字、整张表封顶 12 行 —— * 再多就不是「差在哪」而是把台账抄了一遍;超出的如实写「还有 N 行」。 */ /** * 本单每张表**缺必填的有几行,是哪几行** —— `{ '域_对象_missing': N, '…_missing_keys': '…' }`。 * * 存在的理由只有一个:**让机检判据读得到基座数出来的数**。 * A1 的「必填齐(PN / 年用量)」是 L1 机检,读的却是活动自己写的 `missing_key_total` —— * 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算; * 它算漏一次,这条判据就永远绿不了(连跑十轮里三轮死在这,每次都是连拒五拍熔断)。 * 活动自报的数不是门,是一句「改哪个数能过」的提示。 * * 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里, * 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。 */ /** * **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。 * * 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、 * 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里** * 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。 * `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。 */ export function rowLabel(id: Readonly>, caseKey: string | undefined): string { const parts = Object.entries(id) .filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '') .map(([, v]) => String(v)); return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/'); } export function missingReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; label?: string | undefined; primaryKey: boolean; required?: boolean | undefined; expect?: boolean | undefined; requiredWhen?: { column: string; value: string } | undefined; }>; }>, ): Record { const out: Record = {}; for (const { domain, object, caseKey, fields } of declared) { const rows = mine[`mine:${domain}.${object}`] ?? []; const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); const blank = (one: Record, c: string): boolean => one[c] === undefined || one[c] === null || String(one[c]).trim() === ''; /* * 两组读数同一套算法,只是看哪一个标记: * · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准); * · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。 * 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。 * * `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸): * 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。 */ for (const [mark, suffix] of [ ['required', 'missing'], ['expect', 'gaps'], ] as const) { const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined)); if (watched.length === 0) continue; const bad: string[] = []; for (const row of rows) { const one = (typeof row === 'object' && row !== null ? row : {}) as Record; const due = watched.filter( (f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value, ); const gaps = due.filter((f) => blank(one, f.name)); if (gaps.length === 0) continue; const id: Record = {}; for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`); } out[`${domain}_${object}_${suffix}`] = bad.length; if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';'); } } return out; } /** * **按值现数** —— `{ '域_对象_<列>_<值>': 行数 }`,取值域由台账声明的 `tally` 给。 * * 存在的理由与 `_rows` / `_missing` 是同一条:**能由数据算出来的,别让活动自报**。 * 三分类条数从前是活动写的 `type_a_total` 之流,而分布条、放行时的去向谓词、验收判据 * 读的全是它们 —— 于是人在放行页把九条料全改成 A,类徽变了,分布条还是 5/3/1、 * 去向还是「去选型」:逐行数据变了,汇总数没人重算。 * 而**手工修改的全部意义就是纠正 AI 的判断**;改了类标而流向不动,这个兜底就是假的。 * * 声明给取值域,所以**没出现的值也铺 0** —— 零条也是一个数,判据才算得下去 * (与 `rowReadings` 铺 0 同一口径,那一条是全 A 类那条路踩出来的)。 * 值消毒成小写标识符(与 `classToken` 同一把尺),`A` → `..._type_tag_a`。 */ export function tallyReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; tally?: readonly string[] | undefined }>; }>, ): Record { const out: Record = {}; for (const { domain, object, fields } of declared) { for (const field of fields) { if (field.tally === undefined || field.tally.length === 0) continue; const rows = mine[`mine:${domain}.${object}`] ?? []; const slug = (v: string): string => v.toLowerCase().replace(/[^a-z0-9_]/g, ''); for (const value of field.tally) { const key = `${domain}_${object}_${field.name}_${slug(value)}`; if (key.endsWith('_')) continue; // 值里一个合规字符都没有 —— 不给一个歧义的钩子 out[key] = rows.filter((row) => { const one = (typeof row === 'object' && row !== null ? row : {}) as Record; return String(one[field.name] ?? '').trim() === value; }).length; } } } return out; } /** * **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。 * * 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」): * 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据 * 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。 * 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。 * 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数; * 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数 * 恰恰就是这次数漏的那一个。 * * 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。 * 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。 */ export function distinctReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>; }>, ): Record { const out: Record = {}; for (const { domain, object, fields } of declared) { for (const field of fields) { if (field.distinct !== true) continue; const rows = mine[`mine:${domain}.${object}`] ?? []; const seen = new Set(); for (const row of rows) { const one = (typeof row === 'object' && row !== null ? row : {}) as Record; const value = String(one[field.name] ?? '').trim(); if (value !== '') seen.add(value); } out[`${domain}_${object}_distinct_${field.name}`] = seen.size; } } return out; } /** * **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。 * * 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿), * 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、 * Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。 * 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加; * 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤: * 本轮碰过哪几行,就摆那几行的叠加后全貌。 */ export function touchedRows( merged: ReadonlyArray, draftKeys: ReadonlyArray>>, ): unknown[] { const rows = merged.filter((r): r is Record => typeof r === 'object' && r !== null); return draftKeys .map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? '')))) .filter((r) => r !== undefined); } /** * **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。 * * 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally), * **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」, * 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」, * 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。 * 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」), * **措辞治不了的结构治**(设计律第六条)。 * * 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、 * 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。 * * 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。 * 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠); * 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假, * 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。 */ export function toolCallReadings( trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>, declared: ReadonlyArray<{ readonly name: string }>, ): Record { const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`; const out: Record = {}; for (const { name } of declared) out[key(name)] = 0; for (const entry of trail) { if (entry.kind !== 'result') continue; for (const { name } of declared) { if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1; } } return out; } export function rowKeyList( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>, ): Record { const out: Record = {}; for (const { domain, object, fields } of declared) { const rows = mine[`mine:${domain}.${object}`]; if (rows === undefined || rows.length === 0) continue; const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); if (keys.length === 0) continue; /* * **名单要能直接照抄进 `drop` 的 keys** —— 所以摆成 JSON,不摆成 `A/B` 拼串。 * * 实测栽过一次:名单写作 `RFQ MD14.1/XTSD04GLGEAQG-I`,模型得自己猜第一段是哪一列; * 列名它猜对了,值却把空格写成了横杠(`RFQ-MD14.1` 是更常见的写法)—— * 连撤三次都撤不到,而那一行就在名单里。 * 主键是**认行的钥匙**,一个字符都不能差;那就别让它转写,给它能原样粘的形状。 */ const shown = rows.slice(0, 12).map((row) => { const one = (typeof row === 'object' && row !== null ? row : {}) as Record; const pairs: Record = {}; for (const k of keys) { const v = one[k]; if (v === undefined || v === null || String(v) === '') continue; const text = String(v); pairs[k] = text.length <= 60 ? text : `${text.slice(0, 60)}…`; } return Object.keys(pairs).length === 0 ? '' : JSON.stringify(pairs); }); const listed = shown.filter((t) => t !== ''); if (listed.length === 0) continue; out[`${domain}_${object}_keys`] = listed.join('、') + (rows.length > shown.length ? `(还有 ${rows.length - shown.length} 行)` : ''); } return out; } export type Coerced = { kind: 'value'; value: unknown } | { kind: 'empty' } | { kind: 'reject' }; export function coerceToColumn(value: unknown, type: string): Coerced { // null / undefined / 空串一律是「没有值」——「原文没写的不要补」在数据面的落点 if (value === null || value === undefined) return { kind: 'empty' }; if (typeof value === 'string' && value.trim() === '') return { kind: 'empty' }; switch (type) { case 'number': case 'integer': { if (typeof value === 'number') return Number.isFinite(value) ? { kind: 'value', value } : { kind: 'reject' }; if (typeof value === 'string') { const n = Number(value.trim()); return Number.isFinite(n) ? { kind: 'value', value: n } : { kind: 'reject' }; } return { kind: 'reject' }; } case 'boolean': { if (typeof value === 'boolean') return { kind: 'value', value }; if (typeof value === 'string') { const t = value.trim().toLowerCase(); if (t === 'true' || t === 'false') return { kind: 'value', value: t === 'true' }; } return { kind: 'reject' }; } case 'json': // json 列什么都装得下(对象、数组、标量)—— 不归型 return { kind: 'value', value }; default: // text / date / timestamp:字符串原样;对象序列化(比把 [object Object] 写进库强) return { kind: 'value', value: typeof value === 'object' ? JSON.stringify(value) : String(value), }; } } /** 延后绑定的流程层引用 —— 只用到「任务终态上报」这一件 */ function createDeferredProcess(): { onTaskTerminal: ProcessService['onTaskTerminal']; bind(real: ProcessService): void; } { let real: ProcessService | undefined; return { onTaskTerminal: async (taskId, terminal, ports) => real?.onTaskTerminal(taskId, terminal, ports), bind(next) { real = next; }, }; } /** 录入卡的一格 —— 主段与 `also` 各段同一形状 */ function entryField(f: { key: string; label: string; value?: string; required?: boolean }) { return { key: f.key, label: f.label, required: f.required ?? false, ...(f.value === undefined || f.value === '' ? {} : { value: f.value }), }; } /** L1/L2 才机检 —— L3 环内实证、L4 独立评审、L5 人放行,都不由引擎代答 */ function machineCheckable(level: string): boolean { return level === 'L1' || level === 'L2'; } /** * 判据机检:带 predicate 的用 S-FEEL 对任务视图求值。 * **没写 predicate 的 L1/L2 判 false**(不是 true)—— 装不出证据就不算过; * 安装期 `acceptance-level-misreported` 本就该挡住这种漏配,这里再兜一层。 */ function evaluatePredicate(predicate: string | undefined, scope: Readonly>): boolean { if (predicate === undefined || predicate.trim() === '') return false; return sfeel.condition(predicate, scope); } /** * 一条判据此刻算不算达标 —— **卡面与提交门读的是同一段**。 * * 三类各有各的口径: * · **机检(L1/L2)** —— 谓词对任务视图求值,不掺裁断; * · **人放行(L5)** —— 只有人按下去才算,引擎与 Agent 都不许代答; * · **裁量(L3/L4)** —— 读 Agent 的裁定(`judge` 行动),没裁过就是没达标。 * * ── 裁量项**带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)──────────── * * 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题:前半句 * (**已补齐**)基座数得出来,后半句(**人确认先受理**)才需要人。 * 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了, * 它照样裁「未满足」,理由是「NE555P 缺目标价,客户未提供」。用户原话: * 「这个价格是我填写的,但是它判错了」。实测复现的那一单更离谱: * 两行的年用量都填着,它写「CJTB 缺 EAU、LM358DR-NEW 缺 EAU 与客户料号,共 4 处」—— * 而 EAU 压根是必填不是选填,两行也都有值。 * * **数得出来的就别让它自报**(这条纪律第三次被违反)。谓词为真即达标, * 裁断连同那句理由一起丢掉:一件已经办成的事,不需要谁再解释一遍为什么没办成。 * 谓词为假才轮到裁量 —— 那时理由才有意义,而它该照基座的 `_gaps_keys` 写。 */ export function criterionCheck( c: { key: string; label: string; level: L; predicate?: string | undefined }, scope: Readonly>, judgement: { satisfied: boolean; rationale?: string | undefined } | undefined, ): { key: string; label: string; level: L; pass: boolean; settled?: boolean; rationale?: string } { const machine = machineCheckable(c.level); const hasPredicate = c.predicate !== undefined && c.predicate.trim() !== ''; const settledByData = !machine && c.level !== 'L5' && hasPredicate && evaluatePredicate(c.predicate, scope); const pass = machine ? evaluatePredicate(c.predicate, scope) : c.level === 'L5' ? false : settledByData || (judgement?.satisfied ?? false); return { key: c.key, label: c.label, level: c.level, pass, /** 裁量项:裁过没有(裁成什么是另一回事)—— 提交门看的是这个;数据已经说了话的也算裁过 */ ...(machine || c.level === 'L5' ? {} : { settled: settledByData || judgement !== undefined }), /* * **裁断的理由跟着上卡** —— 裁成「不满足」的那一条,理由才是签字的人要看的。 * 没有它,卡面上「裁了但办不到」与「压根没裁」都是一个红叉,人只能凭感觉签。 * 但数据已经说了「办成了」的,理由一律丢掉:留着就是一句与事实相反的话。 */ ...(settledByData || judgement?.rationale === undefined ? {} : { rationale: judgement.rationale }), }; } export function wireEngines(deps: EngineWiringDeps): EngineWiring { const { db, clock, ids } = deps; /** * 必交付物「有没有产出」—— 数**本单自己写下的**草稿行。 * * 数行数是硬证据:活动说交付了、台账里一行没有,预检当场拒收。 * 但数的必须是**本单的**行:任务视图 = 正式版 + 本单草稿,拿视图去数, * 别单放行过的记录就成了自己的交付 —— 实测踩过:一单什么都没写就提交放行, * 三件必交付物全判「有」(上一单落地的行还躺在同一张表里),卡照发、人照签。 * 这道门于是只在**第一单**有效,之后永远形同虚设。 * * 没声明地址的退回作用域键判断:有值即算有(作用域顶层铺的也只是本单草稿)。 */ function deliverablePresent( address: string | undefined, key: string, scope: Readonly>, mine: ReadonlySet, ): boolean { if (address === undefined) { const value = scope[key]; return value !== undefined && value !== null && value !== ''; } return mine.has(address); } /* ── 任务上下文:活动 ID / 负责人 / 调人预算 —— 引擎共用 ─────────── */ const taskStore = createTaskStore(db); const taskContext = { async of(taskId: TaskId) { const task = await taskStore.get(taskId); if (task === undefined) throw new Error(`任务不存在:${taskId}`); return { activityId: task.activityId, ownerId: task.ownerId, humanCallCap: task.caps.humanCalls }; }, /** * Agent 对**裁量判据**的裁定 —— `judge` 行动落在任务记忆里的那些。 * * 读的是 `detail.criterion` / `detail.satisfied`(结构化的),不解析标题字符串: * 标题是给人看的一句话,措辞随时会改,拿它当协议迟早对不上。 * 同一条判据裁过多次以**最后一次**为准 —— 人改了主意、Agent 补了证据都算数。 * * 键一律**归一化后再存**(见 `criterionKey`):判据名是让模型照抄的一句中文, * 而它抄的时候会顺手把半角标点写成全角、把空格吃掉 —— 一个标点差异就让 * 这条判据永远对不上,提交被拒到预算烧完(实测:32 次被拒 → exhausted)。 */ async judgements(taskId: TaskId): Promise> { const out = new Map(); for (const line of await deps.memory.window(taskId, 400)) { if (line.kind !== 'evaluation') continue; const detail = line.detail; if (typeof detail !== 'object' || detail === null) continue; const criterion = (detail as Record)['criterion']; const satisfied = (detail as Record)['satisfied']; /* * **理由跟着裁断一起收** —— 裁成「不满足」的那一条,理由才是签字的人要看的东西。 * * 从前只收 `satisfied` 这个布尔:卡面上于是「裁了但裁成不满足」与「压根没裁」 * 长得一模一样 —— 都是一个红叉「未达标」。人看到一条永远不绿的项, * 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。 * 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。 */ const rationale = (detail as Record)['rationale']; if (typeof criterion === 'string' && typeof satisfied === 'boolean') { out.set(criterionKey(criterion), { satisfied, ...(typeof rationale === 'string' && rationale.trim() !== '' ? { rationale: rationale.trim() } : {}), }); } } return out; }, /** * 机检判据名一览 —— key 与 label 都归一化后放进来, * 因为模型两种写法都可能用(`judgements` 认的也是这两种)。 */ async machineCriteria(taskId: TaskId): Promise> { const task = await taskStore.get(taskId); if (task === undefined) return new Set(); const manifest = await deps.registry.manifestOf(task.activityId); const out = new Set(); for (const c of manifest.contract.acceptanceCriteria) { if (!machineCheckable(c.level)) continue; out.add(criterionKey(c.key)); out.add(criterionKey(c.label)); } return out; }, }; /* * 视图流控网关 —— 两个范式共用一份。 * 按名取模版 → 填数 → 推给前端。模版取自**该任务装载时的资产版本**(经 registry), * 不是「当前最新」:在途任务按其启动时版本跑完,呈现也该跟着那一版。 */ const viewGateway = { async push({ taskId, activityId, template, scope, }: { taskId: TaskId; activityId: string; template: string; scope: Record; }): Promise<{ pushed: boolean; templates: string[] }> { const manifest = await deps.registry.manifestOf(activityId); const templates = manifest.assets.viewTemplates.map((t) => t.name); const found = manifest.assets.viewTemplates.find((t) => t.name === template); // 查不到就如实回「没推上」+ 有哪些可推 —— 不再静默丢帧(见 ViewGateway 的注释) if (found === undefined) return { pushed: false, templates }; /* * 视图作用域 = 任务视图 + **本单自己写下的行**。 * 两者要分得开:任务视图里的 `域.对象` 是正式版 + 我的草稿的合并结果, * 拿它画「本单交付」会把别单放行过的记录一并列出来。 * 本单的行放在 `mine:域.对象` 下 —— 整键,取值不歧义。 */ const own: Record = {}; for (const draft of await deps.ledger.taskDrafts(taskId)) { const key = `mine:${draft.domain}.${draft.object}`; (own[key] ??= []).push({ ...draft.key, ...draft.patch }); } /* * **这一单是什么时候起来的** —— 来件那一块要写「几时入站」, * 而入参里没有时刻:信是外面来的,时刻是基座记的。 * 起单即入站(邮件触发那一路,通道收到信就起单),所以取任务的 startedAt。 * 只给到分钟:报告上摆秒没有意义,还会让两份同一单的件看起来不一样。 */ const task = await taskStore.get(taskId); const openedAt = task === undefined ? '' : localStamp(task.startedAt); /* * **这一单是怎么来的、归了谁** —— 报告上那枚归属签。 * * 两样都是基座知道的事实(触发来源、任务负责人),活动写不出也不该写: * 让它自报「归属解析 → 冯兵」等于让被指派的人自己说自己是谁。 * 认不出就留空 —— 模版那一格自会空着,不摆一个假名字。 */ const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined); const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`; const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站'; /* * **这一单的任务号** —— 工单模版卡头「关联任务」那一格(E04 目标件 §6.2 点名的 * 共用动态位)。与归属签同理:任务号是基座的事实,活动写不出也不该写; * 全长 UUID 如实给,摆多长由模版自己的样式定(截断是排版,不是数据)。 */ const taskRef = task === undefined ? '' : String(taskId); /* * **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。 * * 关键是**别让报告自己再算一遍**:模版里没有表达式(填数只有取值/展开/上色), * 真让它算就只能把判据抄成静态文字,而抄本与判据是两份东西 —— * 改了 `when` 忘了改报告,屏上那一行就一直指着旧规则。 * 这里调的是放行时发口用的同一个 `routePort`,同一份 scope、同一次求值口径: * 命中第几条 → `r1/r2/r3`(经 `data-class-of` 消毒成 `v-r1`,行自己上色)。 * 算不出来(有一口没写谓词、或一条都不命中)三个键留空 —— 那一行不高亮、 * 结论横幅空着,而不是随便点亮一行。 */ const group = manifest.contract.output.selective[0]; const routed = group === undefined ? undefined : routePort(group, scope, (e, s) => evaluatePredicate(e, s)); const at = group === undefined || routed === undefined ? -1 : group.ports.findIndex((p) => p.name === routed.name); const route: Record = routed === undefined || at < 0 ? {} : { 'case:routeKey': `r${at + 1}`, 'case:routePort': routed.name, 'case:routeTo': routed.label ?? routed.name, 'case:routeWhy': routed.because, }; await deps.interaction.pushViewHtml( taskId, renderView(found.html, { ...scope, 'case:openedAt': openedAt, 'case:origin': origin, 'case:owner': ownerLabel, 'case:taskId': taskRef, ...route, ...own, }), template, ); return { pushed: true, templates }; }, /** * **按当下的数据把同一张模版再渲一遍** —— 人在件上改完东西之后要用它。 * * 件是**投影**:只读那几格是他自己敲的,而由数据算出来的东西 * (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。 * 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」—— * 一半新一半旧,那不是没更新,是界面在撒谎。 * * 只重渲不换模版:换哪一张是剧本的事,这里只负责「同一张,用新数据」。 * 没推过帧(还没到那一步)就什么也不做。 */ async repush(taskId: TaskId): Promise { const current = await deps.interaction.viewOf(taskId); const template = current?.template; if (template === undefined || template === '') return false; const task = await taskStore.get(taskId); if (task === undefined) return false; const scope = await taskData.view(taskId); const out = await this.push({ taskId, activityId: task.activityId, template, scope }); return out.pushed; }, }; /* ── 任务数据:引擎读**任务视图** = 正式版 + 本任务草稿层 ───────── */ const taskData = { /** * 任务视图 = 正式版 + 本任务草稿层(AM 4.2),再叠上起单入参。 * * 作用域的**形状要对得上声明的写法**:步声明里写的是裸字段名 * (`input: { pn: 'pn' }`),不是 `域.对象.行号.字段`。所以这里三层铺开: * ① 起单入参在底 —— 契约的 data 是这单的出发事实; * ② 各对象**最新一行**的字段名铺到顶层 —— 过程工作区的数据形态即草稿层; * ③ `域.对象` 仍保留整表,要整表的照样取得到。 * 顶层同名以「后写的赢」为准:草稿层是这一单最新的过程事实,盖过起单入参。 */ async view(taskId: TaskId) { const task = await taskStore.get(taskId); if (task === undefined) throw new Error(`任务不存在:${taskId}`); const objects = await deps.ledger.listObjects(); const scope: Record = { ...task.data }; for (const address of objects) { const page = await deps.ledger.taskView(taskId, { address, where: {}, orderBy: [], limit: 200, offset: 0, }); scope[`${address.domain}.${address.object}`] = page.rows; } /* * 铺到顶层的只能是**这一单自己写下的**事实(草稿层 = 读己之写)。 * 不能拿「该对象最新一行」来铺:那一行很可能是别的任务放行后的正式版记录, * 一铺就把别人的结论当成了自己的输入 —— 打标会照着上一单的情报出结果。 * 别人的事实仍在 `域.对象` 整表里,要用得显式去取。 * * 同时把本单的行**整行**收在 `mine:域.对象` 下(与视图模版同一约定)。 * 为什么必须有这一份:`域.对象` 是正式版全表 —— 别单放行过的记录也在里面。 * Agent 分不清哪几行是自己写的,就会拿别单的数据当自己的结论讲出来 * (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。 */ const mine: Record = {}; const draftKeysOf: Record>> = {}; for (const draft of await deps.ledger.taskDrafts(taskId)) { (mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch }); (draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key }); for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) { if (value !== null && value !== undefined) scope[field] = value; } } /* * `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿), * 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有 * 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 —— * 内容与 mine/整表重叠,白占截断窗)。 */ const touched: Record = {}; for (const [address, draftKeys] of Object.entries(draftKeysOf)) { const merged = scope[address]; if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys); } /* * **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。 * * 起因是实测的一次「对账过关而账是错的」:活动把条目数记成 9(line_total), * 而受理记录里实际躺着 10 行(有一条 PN 抄漏了后缀,补写时又成了新的一行 —— * 主键写错一次就是多一行)。验收的机检判据读的全是活动自己写的汇总列, * 两边同源,自然对得上;人签完字,下游拿到的是一份多一行的交付物。 * * 名字取 `域_对象_rows`:S-FEEL 的路径不认冒号与点号,`mine:域.对象` 那份取不到; * 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。 * 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。 */ const manifest = await deps.registry.manifestOf(task.activityId); const declared = manifest.declarations.ledger; // 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的 const trail = await deps.memory.window(taskId, 400); /* * **开案时刻要真的在 Agent 的作用域里**(2026-08-22,E04 实测逼出来的): * 说明书写着「round_ref = case:openedAt 按 YYYYMMDDHHmmss 格式化(引擎注入的 * 开案时刻)」,而这个键从前只注进**视图渲染**那一侧,Agent 的任务视图里没有 —— * 模型只能自己编;正式表里一旦有别单的案号,它就照抄(A2 治过的「从台账借号」 * 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进 * 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。 */ const two = (n: number): string => String(n).padStart(2, '0'); const opened = new Date(task.startedAt); const openedAt = Number.isNaN(opened.getTime()) ? '' : `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` + `${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`; const enriched: Record = { 'case:openedAt': openedAt, ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...distinctReadings(mine, declared), ...toolCallReadings(trail, manifest.declarations.tools), ...mine, ...touched, }; return enriched; }, /** * 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。 * * 这不是便利函数,是**授权面**:活动能写哪儿由清单定死,与工具白名单同一道理。 * 同时也是给 Agent 的字典 —— 不告诉它有哪些列,它只会照自己的想象编列名。 */ async writable(taskId: TaskId) { const { activityId } = await taskContext.of(taskId); const manifest = await deps.registry.manifestOf(activityId); return manifest.declarations.ledger.map((d) => ({ domain: d.domain, object: d.object, label: d.label ?? `${d.domain}.${d.object}`, keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })), })); }, /** * 过程写入 —— 一律落草稿层(放行前不落事实),且**先验地址再落笔**。 * * 两道验都不能省: * · 地址不在活动清单声明的台账对象内 → 拒。越域写与越权调工具同罪, * 得在结构上拦住,不能靠活动自律。 * · 列名不在声明的字段里 → 拒。`ledger.draftWrite` 对陌生列是**静默丢弃**, * 于是「写成功了」而字段没落,验收判据求值取不到值 —— 查起来一层皮。 * 宁可当场拒,把可用列名摆给它看。 * * 拒是抛错:回合那边捕获成「行动被拒」喂回下一拍,Agent 自己改对。 */ async draftWrite(input: { taskId: TaskId; domain: string; object: string; key: Record; patch: Record; /** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */ dryRun?: boolean; }) { const allowed = await taskData.writable(input.taskId); const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); if (target === undefined) { throw new Error( `台账地址未声明:${input.domain}.${input.object}(本活动可写:${ allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无' })`, ); } const known = new Set([...target.keys, ...target.fields.map((f) => f.name)]); const strayed = [...Object.keys(input.key), ...Object.keys(input.patch)].filter((c) => !known.has(c)); if (strayed.length > 0) { throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`); } /* * **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列, * 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的 * contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 + * 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住; * 现在写入那一刻当场拒,拒回指路(墙要配路)。 */ for (const field of target.fields) { if (field.refersTo === undefined) continue; const raw = input.key[field.name] ?? input.patch[field.name]; const value = raw === undefined || raw === null ? '' : String(raw).trim(); if (value === '') continue; const [rd, ro, rc] = field.refersTo.split('.'); if (rd === undefined || ro === undefined || rc === undefined) continue; const referenced = await deps.ledger.taskView(input.taskId, { address: { domain: rd, object: ro }, where: {}, orderBy: [], limit: 500, offset: 0, }); const exists = referenced.rows.some((r) => String((r as Record)[rc] ?? '') === value); if (!exists) { const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`; throw new Error( `${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` + `${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` + '要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' + '或者把这一笔挂到已存在的那一行名下', ); } } /* * **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。 * * 模型用 JSON 说话:「这个字段原文没给」它写成 `""`。草稿层照单全收没问题, * 可 `releaseWrite` 是往真表里写 —— 空串进 numeric 列,PG 直接掀桌: * `invalid input syntax for type double precision: ""`。**而那是人已经签完字之后**, * 整单落成 `blocked`(实测:NE555P 原文确实没给目标价,业务判断完全正确, * 却在最后一步炸掉)。最坏的失败点就是这里 —— 前面全对,签完才说不行。 * * 归型三条: * · 空串 = **没有值** → 从补丁里去掉(草稿是合并语义,不写 = 不动,列留 NULL); * · 数值/布尔列的字符串 → 转成真类型(模型给 `"12000"` 是常态); * · 转不出来 → 当场抛,理由喂回下一拍让它改 —— 比签完字再炸早得多。 */ const typeOf = new Map(target.fields.map((f) => [f.name, f.type])); const patch: Record = {}; for (const [column, value] of Object.entries(input.patch)) { const coerced = coerceToColumn(value, typeOf.get(column) ?? 'text'); if (coerced.kind === 'reject') { throw new Error(`列 ${column} 要 ${typeOf.get(column)},给的是 ${JSON.stringify(value)} —— 换个值或留空`); } // 'empty' = 没有值:不写这一列(合并语义下等于不动它) if (coerced.kind === 'value') patch[column] = coerced.value; } /* * 写之前先看一眼:值和现有草稿一模一样就是**空转**。 * 照写不误也不会错,但 Agent 从此看不出自己在原地打转 —— 实测同一行 * verdict 被反复重写十几拍。所以照写,但如实回报「这一笔什么也没改」。 */ const before = (await deps.ledger.taskDrafts(input.taskId)).find( (d) => d.domain === input.domain && d.object === input.object && Object.entries(input.key).every(([k, v]) => String(d.key[k] ?? '') === String(v)), ); const changed = before === undefined || Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); if (input.dryRun === true) return { changed }; await deps.ledger.draftWrite({ taskId: input.taskId, address: { domain: input.domain, object: input.object }, key: input.key, patch, }); return { changed }; }, /** * **撤掉自己写错的那一行** —— 授权面与写同一道:没声明的地址撤不了。 * * 撤的只是本单草稿层那一笔,正式版一个字都动不了。 * 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上 * (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。 */ async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record }) { const allowed = await taskData.writable(input.taskId); const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); if (target === undefined) { throw new Error( `台账地址未声明:${input.domain}.${input.object}(本活动可写:${ allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无' })`, ); } const dropped = await deps.ledger.draftDrop({ taskId: input.taskId, address: { domain: input.domain, object: input.object }, key: input.key, }); return { dropped }; }, }; const memoryRecorder = { append: async (input: { taskId: TaskId; kind: string; title: string; detail?: Record }) => { await deps.memory.append({ taskId: input.taskId, kind: input.kind as never, title: input.title, ...(input.detail === undefined ? {} : { detail: input.detail }), stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, }); }, }; const listeners = { async register(input: { taskId: TaskId; eventType: 'data' | 'time' | 'human'; keyFilter: Record; deadline: string }) { const listener = await deps.event.registerListener(input.taskId, { origin: 'dynamic', eventType: input.eventType, keyFilter: input.keyFilter, semantic: 'complete-step', deadline: input.deadline, escalation: ['nudge', 'escalate'], }); return { id: listener.id }; }, /** 立监听的授权面 —— 活动清单声明过的那几种(见 port.ts 的注释) */ async declared(taskId: TaskId) { const { activityId } = await taskContext.of(taskId); const manifest = await deps.registry.manifestOf(activityId); return manifest.declarations.listeners.map((l) => ({ eventType: l.eventType, keyFilter: l.keyFilter })); }, }; /** * 录入卡的一段 —— **表名换人读名、自增列不摆给人填**(2026-08-02 用户两条)。 * * ① 卡面摆的是台账登记的表注释(「客户档案」),不是 `customer.profile` —— * 界面不摆系统内的英文名。登记里没有人读名就退回地址:宁可笼统,不可编。 * ② 声明里标了 `auto` 的列(联系人编码这类内部流水号)**从表单里拿掉**, * 另摆一句「由系统生成」。让人填是白占一格他答不上来的表单 * (他哪知道这家客户已经有几位联系人),让模型编更糟 —— 编重了就是覆盖别人那一行。 * 但不能一声不吭地拿掉:那样他会以为这张档少了一格。 */ const entryGroup = async ( taskId: TaskId, address: string, key: Readonly>, fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>, ) => { const [domain, object] = address.split('.'); const declared = (await deps.ledger.listObjects()).find((o) => o.domain === domain && o.object === object); const { columns: autoCols, labels: autoLabels, all: colLabels } = await autoColumns(taskId, address); const kept = fields.filter((f) => !autoCols.has(f.key)); /* * 那句话按**声明**说,不按「模型这次塞没塞进来」说 —— 一度写成后者, * 于是模型照新说明书没写 `contact_code`,卡上就一个字都没有: * 人看到联系人档只要姓名与邮箱,不知道编码去哪了。 * 自增是这张表的事实,与模型这一拍写了什么无关。 */ const auto = [...autoCols].map((n) => autoLabels.get(n) ?? n); return { ledger: address, ...(declared?.label === undefined || declared.label === '' ? {} : { ledgerLabel: declared.label }), // 自增的主键也从卡面的主键行里摘掉 —— 那一格此刻还不存在,摆个空值是撒谎 key: Object.fromEntries(Object.entries(key).filter(([k]) => !autoCols.has(k))), /* * 主键那几列的**中文列名** —— 卡面上摆它,不摆 `customer_code`(界面不摆英文列名)。 * 表单里有的那几列自带 label,这里补的是**只在主键里出现**的那些 * (如联系人档的客户编码:它由上一段定死,不在这一段的表单里)。 */ keyLabels: Object.fromEntries( Object.keys(key) .filter((k) => !autoCols.has(k)) .map((k) => [k, colLabels.get(k) ?? k]), ), fields: kept.map(entryField), ...(auto.length === 0 ? {} : { autoNote: `${auto.join('、')}由系统按顺序生成,不用你填` }), }; }; /** 这张表哪几列是基座自己生成的(连它们的中文列名)—— 认本单活动清单里的 `auto` 声明 */ const autoColumns = async ( taskId: TaskId, address: string, ): Promise<{ columns: Set; labels: Map; all: Map }> => { const [domain, object] = address.split('.'); const { activityId } = await taskContext.of(taskId); const declared = (await deps.registry.manifestOf(activityId)).declarations.ledger.find( (l) => l.domain === domain && l.object === object, ); const auto = (declared?.fields ?? []).filter((f) => f.auto === true); return { columns: new Set(auto.map((f) => f.name)), labels: new Map(auto.map((f) => [f.name, f.label ?? f.name])), all: new Map((declared?.fields ?? []).map((f) => [f.name, f.label ?? f.name])), }; }; const cards = { /** * 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。 * 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。 */ /** * 数据录入卡 —— 人按下确认那一刻由**基座**写草稿层(见 `desk.ts` 的回传口), * 不回到 Agent 手里再转写一遍。这里只管把卡发出去。 */ async entry(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; ledger: string; key: Readonly>; fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>; /** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */ also?: ReadonlyArray<{ title: string; ledger: string; key: Readonly>; fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>; }>; basis?: ReadonlyArray<{ k: string; v: string }>; }) { const issued = await deps.humanTool.issueChatCard( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { k: 'entry', id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), ask: input.ask, ...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }), ...(await entryGroup(input.taskId, input.ledger, input.key, input.fields)), ...(input.also === undefined || input.also.length === 0 ? {} : { also: await Promise.all( input.also.map(async (g) => ({ title: g.title, ...(await entryGroup(input.taskId, g.ledger, g.key, g.fields)), })), ), }), }, ); await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); return { id: issued.card.id }; }, async ask(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; fields: ReadonlyArray<{ key: string; label: string; hint?: string; value?: string }>; basis?: ReadonlyArray<{ k: string; v: string }>; }) { const cardSpan = deps.trace.beginAction({ taskId: input.taskId, name: `补全卡:${input.ask.slice(0, 24)}`, kind: 'human', input: { fields: input.fields.map((f) => f.key) }, }); const issued = await deps.humanTool.issueChatCard( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { k: 'elicitation', id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), ask: input.ask, ...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }), fields: input.fields.map((f) => ({ key: f.key, label: f.label, ...(f.hint === undefined ? {} : { placeholder: f.hint }), /* * **预填值必须搬过来** —— 这里从前只搬 key/label/hint, * 模型照剧本给的 `value` 到这一步被静默丢掉:剧本让它"尽量先把空填上", * 它照做了,人拿到的还是一排空格,而两边都不知道东西丢在哪儿。 */ ...(f.value === undefined || f.value === '' ? {} : { value: f.value }), })), }, ); // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } }); return { id: issued.card.id }; }, /** * 发**决策卡** —— 岔路口摆给人点,不是让人敲字。 * 与补全卡同一条路(对话卡族 → 对话区 → 立监听),差别只在卡面:那边是表单,这边是选项。 */ async decide(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; options: ReadonlyArray<{ value: string; label: string; detail?: string; consequence?: string; recommended?: boolean; }>; basis?: ReadonlyArray<{ k: string; v: string }>; allowCustom?: boolean; }) { const cardSpan = deps.trace.beginAction({ taskId: input.taskId, name: `决策卡:${input.ask.slice(0, 24)}`, kind: 'human', input: { options: input.options.map((o) => o.value) }, }); const recommended = input.options.find((o) => o.recommended === true); const issued = await deps.humanTool.issueChatCard( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { k: 'decision', id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), ask: input.ask, basis: input.basis === undefined ? [] : [...input.basis], ...(recommended === undefined ? {} : { recommend: recommended.value }), options: input.options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }), ...(o.consequence === undefined ? {} : { consequence: o.consequence }), ...(o.recommended === undefined ? {} : { recommended: o.recommended }), })), allowCustom: input.allowCustom ?? false, }, ); await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } }); return { id: issued.card.id }; }, async issue(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; workOrder: 'execution' | 'acceptance'; card: Record; }) { const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }; const title = String(input.card['title'] ?? '请处理'); // 人卡一张一个 span:发给谁、问了什么、什么工单 —— 「等人」这一段也要在时间轴上 const cardSpan = deps.trace.beginAction({ taskId: input.taskId, name: `人卡:${title}`, kind: 'human', input: { workOrder: input.workOrder, ownerId: input.ownerId }, }); /* * 依据快照:优先用活动给的结构化 `basis`(`{k,v}[]`);没给才把卡面其余键铺成依据。 * 铺的时候要跳过 `ask`(它是问题不是依据)与空值,嵌套对象转 JSON —— * 不然人看到的是一行 `[object Object]`,那还不如不给。 */ const declared = input.card['basis']; const basis = Array.isArray(declared) ? declared .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) .map((b) => ({ k: String(b.k), v: String(b.v) })) : Object.entries(input.card) .filter(([k, v]) => !['title', 'fields', 'ask', 'basis'].includes(k) && v !== null && v !== undefined && v !== '') .map(([k, v]) => ({ k, v: typeof v === 'object' ? JSON.stringify(v) : String(v) })); /* * 卡面声明的字段 → 表单域。执行与验收共用这一份解析: * 验收工单从前写死 `fields: []`,可 `acceptanceWorkOrderSchema` 本来就有 `fields` —— * 是这里没填,不是设计上不要。放行时人要修订的值(如「这一单到底要不要选型」) * 就靠它;答完经 `writeOutput` 落台账,覆盖上游步写的同一列。 */ /* * 卡面声明的字段 → 表单域。**两种写法都收**: * · 一串字段名 `['result']` —— 执行工单常见,人从空白填起; * · 一串对象 `[{key,label,ai,ledger}]` —— 验收工单要的形态: * `ai` 是**AI 拟值**(PRD:验收报告「AI 已填好、可编辑」), * `ledger` 是这个值改完该落回哪个台账地址(`域.对象.列`)。 * * 没有 `ai`,「可编辑」就没有可编辑的东西;没有 `ledger`,人改了也只留在工单留痕里, * 落地时用的还是 AI 的原值 —— 修订永远落不下来(第 12 章③)。 */ const declaredFields = Array.isArray(input.card['fields']) ? (input.card['fields'] as unknown[]) .map((raw) => { if (typeof raw === 'string') { return raw === '' ? undefined : { key: raw, label: raw, kind: 'text' as const, required: true }; } if (typeof raw !== 'object' || raw === null) return undefined; const f = raw as Record; const key = String(f['key'] ?? '').trim(); if (key === '') return undefined; const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']); const ledger = String(f['ledger'] ?? '').trim(); return { key, label: String(f['label'] ?? key), kind: 'text' as const, required: f['required'] !== false, ...(ai === undefined ? {} : { ai }), ...(ledger === '' ? {} : { ledger }), }; }) .filter((f): f is NonNullable => f !== undefined) : undefined; if (input.workOrder === 'acceptance') { /* * 提交放行**只走 release.submitForRelease** —— 它才是那道门(OS 7.3): * 预检全过 → 写类意向从 drafted 推到 prechecked → 已验证动作清单上卡。 * * 从前这里直接 `issueAcceptance`,绕过了预检:后果是写类动作登记完永远停在 * drafted,`land()` 只执行 prechecked,于是**放行时一个外部动作都不执行**; * 卡面 `actions` 恒空、`criteria` 恒是一条硬编码 L5 —— 人在验收单上看不见 * 「通过之后系统会做什么」,那这一下「通过」就是闭着眼签的。 * * 预检不过不发卡(抛 PrecheckFailedError / DeliverablesMissingError), * 由回合循环捕获成「行动被拒」喂回下一拍 —— 打回环内修正,正是规范要的。 */ const { activityId } = await taskContext.of(input.taskId); const manifest = await deps.registry.manifestOf(activityId); const selective = manifest.contract.output.selective[0]; const scope = await taskData.view(input.taskId); /* * 必交付物:按声明的台账地址数行(任务视图 = 正式版 + 本单草稿,读己之写)。 * 没声明地址的退回作用域键判断 —— 有值即算有,没值即缺。 */ // 本单写过的台账地址 —— 「读己之写」的那一份,别单的落地行不算自己的交付 const mine = new Set((await deps.ledger.taskDrafts(input.taskId)).map((d) => `${d.domain}.${d.object}`)); const deliverables = manifest.contract.requiredDeliverables.map((d) => ({ key: d.key, label: d.label, present: deliverablePresent(d.ledger, d.key, scope, mine), })); /* * 判据逐条标注真实等级:L1/L2 带 predicate 的**当场机检**(S-FEEL 对任务视图求值), * L3–L5 交人 —— 等级不得谎报(安装期 acceptance-level-misreported 已挡住漏配)。 */ /* * 裁量判据(L3/L4)读 **Agent 的裁定**,不再一律判 false。 * * 从前这里写死 `machineCheckable ? predicate : false`:L3「吃不准降 B」、 * L4「去向判断合乎业务常理」这类机器判不了的,于是**永远**是红叉 —— * 一单办得再干净,卡面也停在 5/8,人签字时面对三条谁也消不掉的未达标, * 「达标数」这个读数就此失去意义(反正它永远不满)。 * * 它们本就有归属:`judge` 行动 —— 「评估留痕,可回放可归因」。 * 裁定按 key 或 label 认(模型两种都可能写),没裁过的照旧算未达标: * **没人裁过 ≠ 通过**,那是这道门的意义所在。 */ const judged = await taskContext.judgements(input.taskId); /* 判据 key → 谓词:拒回时好把「这条读的是哪几个数」摆给模型看 */ const criteriaOf = new Map( manifest.contract.acceptanceCriteria .filter((c) => c.predicate !== undefined && c.predicate.trim() !== '') .map((c) => [c.key, c.predicate ?? '']), ); const verdictOf = (c: { key: string; label: string }): { satisfied: boolean; rationale?: string } | undefined => judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label)); const criteria = manifest.contract.acceptanceCriteria.map((c) => criterionCheck(c, scope, verdictOf(c)), ); /* * **只许剩放行那一条**(2026-07-31 用户定的口径),但两类判据把的是两道不同的关: * * · **机检(L1/L2)**:数不对就是活没干完 —— 必须为真才让提交。 * · **裁量(L3/L4)**:必须**裁过**,裁成什么不拦。 * * 后半句是 2026-08-01 用实测改回来的。从前这里对裁量项也要求「为真」, * 而裁量的本义恰恰是**可以裁出「不满足」**,且不满足正是最该交给人的那种情形。 * 于是模型只剩两条路:改数据,或者改裁断 —— 两条都是撒谎,它两条都走过: * ① 一颗料只缺目标价,它如实记 missing_minor_total = 1,被判据连拒四次, * 第五拍把那个数改回 0,判据当场变绿(台账从此写着「缺选填 0 条」); * ② 换成 L4 之后,它如实裁了「未满足」并写了理由,提交照样被拒 —— * 判、提、拒、再判……十二轮 21 拍 187k token,最后一拍把裁断翻成「满足」才过。 * **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。** * * 裁成「不满足」的,连着理由一起进验收卡:那正是签字的人要看的东西 —— * 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。 */ const unmet = criteria.filter((c) => c.level === 'L5' ? false : c.settled === undefined ? !c.pass : !c.settled, ); if (unmet.length > 0) { /* * 判据名要**用引号夹起来**,等级摆在引号外(2026-07-31 修)。 * * 从前写成 `${label}(${level})`,后面又说「criterion 照判据名原样写」—— * 模型照做了:它把 `(L3)` 一并抄进 criterion,于是裁定的键与判据名对不上, * 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。 * 让模型抄什么,就得让那个东西在句子里**看得出边界**。 */ /* * 机检项要**连着它此刻的读数**一起摆(2026-07-31 加,与收步判据同一口径)。 * 只说「C 类都开了替代评估请求 没达标」,模型看不见 alt_request_total 与 * type_c_total 现在各是几 —— 它以为自己开齐了,原样再提一遍,连拒五拍熔断 * (实测 A1 死于此)。差的那个数摆出来,才是一句话能办的事。 */ const detail = unmet.map((c) => { const head = `「${c.label}」[${c.level}]`; if (c.settled === false) return `${head} 还没裁过`; const predicate = criteriaOf.get(c.key); return predicate === undefined ? head : `${head} 判据 ${predicate}${readingsOf(predicate, scope)}`; }); /* * **建议要分清「数不对」与「行不对」**(2026-08-01 实测改)。 * * 从前只有一句「去把台账里那几个数改对(通常是汇总表的 _total 计数列)」—— * 而实测那一单差的根本不是数:客户说本轮跳过的那颗料被多写了一行, * `line_total = 9` 是对的、表里 10 行是错的。这句话把模型直直引向改数, * 它照做(把 9 改成 10),`skipped_total` 又不对,再拒 —— 五拍熔断。 * 读数里已经把两份主键名单摆出来了,一比就看得出多的是哪一行; * **指路要指到那一行上去**,不是指向那个数。 */ /* * **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。 * * 那一单跳过了打标与定去向就来提交,判据 `…_type_tag_a + _b + _c = …_rows` * 正确地拦住了 —— 而拒回的话给了两条路:改数、改行。两条都不对: * 数是对的(它诚实地报了「3 条没打标」)、行也是对的(记录三行一行不多), * **真正该做的是回去把那一步办了**。于是它只能原样重提,连撞五拍熔断。 * * 判法很硬:判据里点名的那张表,**这一单一行都没写**(基座数的 `_rows` 是 0), * 那就不是数错也不是行错,是那一步压根没办。 * 「拒回的话是模型下一拍的行动指南,指错方向比不给指引更糟」—— * 指向两扇打不开的门,比不指还糟。 */ const empty = [ ...new Set( unmet.flatMap((c) => [...(criteriaOf.get(c.key) ?? '').matchAll(/([a-z][a-z0-9_]*)_rows\b/g)] .map((m) => m[1] ?? '') .filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0), ), ), ]; const undone = empty.length === 0 ? '' : `。**${empty.join('、')} 这一单一行都没写** —— 那不是数错也不是行错,` + '是产出它的那一步还没办:回去把它办了(该调的工具调、该写的行写),再来提交。'; throw new Error( `还不能提交放行:除「人放行」外还有 ${unmet.length} 条判据没过 —— ` + detail.join(';') + undone + '。机检项先分清差的是**数**还是**行**:' + '① 数不对 —— 汇总表的 _total 与实情对不上,把那个数改对;' + '② 行不对 —— 上面两份主键名单一比就看得出多了或少了哪一行:' + '多的用 drop 撤掉、少的补写。**名单里那一段 JSON 原样粘进 keys 就行**,' + '一个字符都别改(值里的空格就是空格,换成横杠就撤不到那一行了)。' + '**数是台账的结论,别为了过判据去改它** —— 名单说多了一行,那就是真多了一行。' + '别原样重提;裁量项(L3/L4)用 judge 行动裁一次即可 —— **裁成「不满足」照样能提交**,' + '把理由写清楚交给放行的人定;criterion 只抄引号里那一句,别把后面的等级标记带上。', ); } /* * 可修订字段 = **契约声明的那几个**,拟值从台账现读。 * * 设计期定「哪几个值该让人改、改完落哪儿」(`contract.revisableAtRelease`), * 运行期填「此刻它是什么值」—— 拟值不让模型重述:重述就会和台账里的值不一致, * 而以哪个为准说不清。活动在 submit 上另给的字段也收,与声明的并到一处。 */ const revisable = manifest.contract.revisableAtRelease.map((r) => ({ key: r.key, label: r.label, ledger: r.ledger, ...(scope[r.key] === undefined || scope[r.key] === null ? {} : { ai: String(scope[r.key]) }), })); /* * 同名字段**只留第一个** —— 表单按 key 索引,两个同 key 的字段本就摆不出来。 * * 从前只拿声明字段去重 `revisable`,没拿它去重**自己**: * A4 一单三条料,模型很自然地把三条的 `quoted_price` 都列成可改字段 —— * 基座以「表单字段 key 重复」拒发,而模型看不出该删哪一个(三个都是真的), * 于是原样再提一遍,连拒五拍熔断(实测 A4 死于此)。 * 逐行改值本来就不走这张表单(它是整单级的修订面),去重是如实的收敛,不是丢数据。 */ const seenField = new Set(); const mergedFields = [...revisable, ...(declaredFields ?? [])] .filter((f) => { if (seenField.has(f.key)) return false; seenField.add(f.key); return true; }) .map((f) => ({ kind: 'text' as const, required: false, ...f })); /* * **验收页上那张可就地改的成果表**(2026-08-01)。 * * 必交付物声明了 `editableAtRelease` 的,基座在放行卡上铺一张绑台账的表: * 整份行都在、逐格可改。验收是最重的一道门 —— 人得能在成果本身上改, * 而不只是在旁边几个整单级字段上改;改完由基座写回草稿层、随放行合并,AI 不经手。 */ const editable = await Promise.all( manifest.contract.requiredDeliverables .filter((d) => d.ledger !== undefined && (d.editableAtRelease?.length ?? 0) > 0) .map(async (d) => { const address = d.ledger ?? ''; const declared = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address); const fields = d.editableAtRelease ?? []; return { t: 'table' as const, title: `${d.label}(可就地改)`, editable: true, ledger: address, fields: [...fields], cols: fields.map((f) => declared?.fields.find((x) => x.name === f)?.label ?? f), rows: [], }; }), ); const releaseBlocks = await fillBoundTables(input.taskId, editable); const submitted = await deps.release.submitForRelease({ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap, deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'), title, basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }], deliverables, /* * **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡, * 也不该显示在这里 —— 这里是判据」)。 * * L5 是**责任关**,而这张卡本身就是那道关。把它列进判据表,等于在放行卡上 * 摆一条恒为「未达标」的项:人一路核到底,最后一行永远是红的, * 而消掉它的动作就是按下同一张卡上的「确认通过」。 * 判据是**签字前要核的东西**,不是签字这件事自己; * 「n/m 达标」那个数也因此永远差一条,看着像还有活没干完。 * * 声明照旧留在契约里(它说明这份交付物必须由人放行,并由 `T_release` * 的人工激活落实),提交门与预检也照旧把它排除在外 —— 少的只是这一处呈现。 */ criteria: criteria.filter((c) => c.level !== 'L5'), ...(mergedFields.length === 0 ? {} : { fields: mergedFields }), ...(releaseBlocks.length === 0 ? {} : { blocks: releaseBlocks as Array> }), }); /* * 选择组上卡由 humanTool 侧补齐:组是契约的事实,不在卡面声明。 * * **能算就别问**:组内每一口都写了 `when`,按序求值(首个命中即出)—— * 卡上只如实告诉人走哪一口、凭哪一条。去向多半是台账里那几个数的函数 * (有没有 C 类、有没有 B 类);让人凭印象点一下,等于把一个可回放的判定 * 换成一次不可回放的手感。有一口没写谓词就算不出来,退回「人选一口」。 */ if (selective !== undefined) { const routed = routePort(selective, scope, (expression, s) => evaluatePredicate(expression, s)); if (routed === undefined) { await deps.humanTool.attachPortChoice(submitted.workOrderId, { group: selective.group, ports: selective.ports.map((port) => ({ name: port.name, label: port.description })), }); } else { await deps.humanTool.attachRoutedPort(submitted.workOrderId, routed); await memoryRecorder.append({ taskId: input.taskId, kind: 'engine-transition', title: `去向已算出:${selective.group} → ${routed.name}(${routed.because})`, detail: { group: selective.group, port: routed.name, because: routed.because }, }); } } await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } }); return { id: submitted.workOrderId }; } /* * **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)—— * 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩 * 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea: * 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。 */ const fields = declaredFields ?? []; /* * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 * * 执行工单的契约一直收 `blocks`,而这里从没往下传:活动照说明书派了一张 * 「带可编辑需求表」的执行单,人收到的却只有一句话,一张表都没有(实测)。 * 形状交给契约层校验(`viewBlockSchema`),这里只负责别把它弄丢; * 认不出的块由 zod 拒下,不至于把脏东西端上卡面。 */ const raw = Array.isArray(input.card['blocks']) ? input.card['blocks'] : undefined; const blocks = raw === undefined ? undefined : await fillBoundTables(input.taskId, raw); /* * **作业指导书要到得了卡面**(2026-08-02 用户:「执行卡应该是查看工单按钮和作业指导书」)。 * * 活动照骨架写了 `steps` / `standard`,这里从前**一个字都不往下传** —— * 于是卡上只剩一句 ask 和一张表:人拿到「请核对确认」四个字, * 不知道该核什么、核到什么程度算完。空着不是简洁,是把交代省掉了。 * (「步序声明」那句旧注说的是 flow 期的步序,与这份给人看的指导书不是一回事。) */ const steps = Array.isArray(input.card['steps']) ? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '') : []; const standard = String(input.card['standard'] ?? '').trim(); const issued = await deps.humanTool.issueExecution(context, { title, basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }], ask: String(input.card['ask'] ?? input.card['reason'] ?? title), fields, ...(steps.length === 0 ? {} : { steps }), ...(standard === '' ? {} : { standard }), ...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }), }); // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } }); return { id: issued.order.id }; }, }; /** * 绑了台账的可编辑表:**行由基座从草稿层铺,不由活动塞进回复里**。 * * 人要在卡上改的是那份清单本身(PRD 的执行页就是整份逐格可改)。让活动把整份塞进 * 回复里有两处死结:回复有长度上限,铺长了半途截断整拍作废(实测); * 而那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。 * * 铺不出来(地址写错、这一单还没写过那张表)就**原样留着活动给的行** —— * 宁可少一份便利,不要把人正在办的那张卡弄成空表。 */ const fillBoundTables = async (taskId: TaskId, blocks: readonly unknown[]): Promise => { const drafts = await deps.ledger.taskDrafts(taskId); return blocks.map((block) => { if (typeof block !== 'object' || block === null) return block; const b = block as Record; if (b['t'] !== 'table' || typeof b['ledger'] !== 'string' || !Array.isArray(b['fields'])) return block; const address = b['ledger']; const fields = (b['fields'] as unknown[]).map((f) => String(f)); const rows = drafts .filter((d) => `${d.domain}.${d.object}` === address) .map((d) => { const row = { ...d.key, ...d.patch } as Record; return fields.map((f) => { const v = row[f]; return v === null || v === undefined ? '' : String(v); }); }); return rows.length === 0 ? block : { ...b, rows }; }); }; /** * 未命中转人(§6.2)—— **人是工具的一种实现**。 * * 系统里没有这件工具时,从前是抛错:Agent 拿到一个异常,既不知道该等谁,也没人被通知, * 于是这一单就停在那儿 —— 名义上「工具未注册」,实际上是**一步走不动**。 * 现在改成开一张执行卡请人做,人填回的表单值就是这次调用的出参,下一拍直接取到。 * * 两种不命中,卡面不一样: * · `tool-unimplemented` 有契约无实现 —— 卡面 = 契约的 `guide` 段 + 本次入参 + 按出参 Schema 生成的回填表单; * · `tool-unregistered` 连契约都没有 —— 如实说明系统里没有这件工具,只给一格自由文本。 * * 一次调用只发一张卡:入参指纹 `callKey` 做内容地址,重问拿同一份答案。 */ const requestHandoff = async ( input: { tool: string; input: Record; taskId: TaskId }, contract: ToolContract | undefined, ): Promise<{ ok: boolean; output?: Record; awaitingHuman?: { workOrderId: string } }> => { const key = callKeyOf(input.tool, input.input); const existing = await deps.toolHandoff.find(input.taskId, key); if (existing?.state === 'answered') { // 人已答:值即出参(按出参 Schema 归型后交回活动) return { ok: true, output: coerceBySchema(contract?.output, existing.output ?? {}) }; } if (existing !== undefined) return { ok: false, awaitingHuman: { workOrderId: existing.workOrderId } }; const context = await taskContext.of(input.taskId); const name = contract?.displayName ?? input.tool; const guide = contract?.guide; const issued = await deps.humanTool.issueExecution( { taskId: input.taskId, ownerId: context.ownerId, humanCallCap: context.humanCallCap }, { title: `请代办:${name}`, // 依据 = 本次调用的入参原文:人得看见「拿什么去做」,不然只能猜 basis: [ { k: '工具', v: name }, ...Object.entries(input.input).map(([k, v]) => ({ k, v: typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v), })), ], ask: guide?.ask ?? (contract === undefined ? `系统里还没有这件工具(${input.tool})。这一步请你直接做,把结果填在下面。` : `这件工具(${name})系统里还没有可用实现。请你按契约把这一步做掉,把结果填在下面。`), standard: guide?.standard ?? '照实填写;做不了就说明卡在哪,不要编数。', ...(guide?.steps === undefined ? {} : { steps: [...guide.steps] }), fields: handoffFields(contract?.output).map((f) => ({ ...f })), submitLabel: '提交结果', }, ); await deps.toolHandoff.open({ id: ids.next(), taskId: input.taskId, tool: input.tool, callKey: key, input: input.input, workOrderId: issued.order.id, }); // 立监听听人:卡回传驱动下一拍(不立就没人叫醒这一单) await listeners.register({ taskId: input.taskId, eventType: 'human', keyFilter: { cardId: issued.order.id }, deadline: 'P3D' }); await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); await memoryRecorder.append({ taskId: input.taskId, kind: 'engine-transition', title: `${name} 无可用实现 —— 已转人代办`, detail: { tool: input.tool, workOrderId: issued.order.id, registered: contract !== undefined }, }); return { ok: false, awaitingHuman: { workOrderId: issued.order.id } }; }; const toolGateway = { invoke: async (input: { tool: string; input: Record; activityId: string; taskId: TaskId; stepNo?: number }) => { // 每次工具调用一个 span —— 观测面要看得见活动内部到底调了什么、花了多久 const span = deps.trace.beginAction({ taskId: input.taskId, name: `工具:${input.tool}`, kind: 'tool', input: input.input }); const result = await deps.toolInvoker .invoke({ tool: input.tool, input: input.input, activityId: input.activityId, taskId: input.taskId, ...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }), }) .catch(async (error: unknown) => { await span?.fail(error); throw error; }); // 未注册 / 有契约无实现:不是失败,是「这件事该由人做」 if (!result.ok && (result.error?.code === 'tool-unregistered' || result.error?.code === 'tool-unimplemented')) { const handoff = await requestHandoff(input, await deps.toolbox.get(input.tool)); await span?.end({ output: handoff.ok ? (handoff.output ?? {}) : { handoff: handoff.awaitingHuman?.workOrderId }, attributes: { handoff: 'human' }, }); return handoff; } await span?.end({ output: result.ok ? (result.output ?? {}) : { error: result.error?.message } }); return { ok: result.ok, ...(result.output === undefined ? {} : { output: result.output }), ...(result.error === undefined ? {} : { error: { message: result.error.message, retryable: result.error.retryable } }), }; }, isWriteEffect: async (tool: string) => (await deps.toolbox.get(tool))?.effect === 'write', /** * 这一单能调什么 —— 声明 × 目录编译而来,**只给功能工具**。 * * 系统内化那几件(`human.ask` / `ledger.write` / LLM 五件)在单拍路径上**不是工具**, * 是独立的行动型(human / write / …)。把它们混进「可调工具」里,Agent 就会 * 拿 `tool: ledger.write` 去调 —— 缝里查无此工具,当场转人代办: * 一张「请代办:ledger.write」的卡送到人手上,而那本是它自己一句话就能干的事(实测踩过)。 * 开环路径反过来:那边它们**就是**工具(经 `busOf(taskId)` 拿全套)。 */ busOf: async (activityId: string) => { const report = await busForActivity(activityId); return report.tools .filter((t) => t.kind === 'function') .map((t) => ({ id: t.id, description: t.description, input: t.input, effect: t.effect })); }, registerIntent: async (input: { taskId: TaskId; tool: string; input: Record; label: string }) => { // 写类动作登记为动作意向:入参冻结,放行后由系统执行落地 /* * 幂等键**只有一个公式** —— `${taskId}:${intentId}`,与落地时 `land()` 用的同一个 * (`idempotencyKey` 从 release 导出,两边共用)。 * * 从前登记侧自拼 `${taskId}:${tool}:${随机}`,落地侧另算一个:两把钥匙对不上, * 「同一动作至多执行一次」就成了空话 —— 重试会以另一把键再执行一遍。 */ const intentId = ids.next(); await deps.release.registerIntent(input.taskId, { id: intentId, tool: input.tool, input: input.input, idempotencyKey: idempotencyKey(input.taskId, intentId), label: input.label, }); }, }; /** * 把人在验收报告上改过的值落回草稿层 —— 放行合并前的最后一笔。 * * 只认**声明了台账地址**的字段(`FormField.ledger`),且值确实与 AI 拟值不同才写: * 一个字没改就不该在台账上留一笔「改过」的痕。 */ const applyRevisions = async (taskId: TaskId): Promise => { const orders = await deps.humanTool.allWorkOrders(taskId); const drafts = await deps.ledger.taskDrafts(taskId); for (const order of orders) { if (order.mode !== 'acceptance' || order.done === undefined) continue; const answered = order.done.values ?? {}; for (const field of order.fields ?? []) { const address = field.ledger; if (address === undefined || address === '') continue; const value = answered[field.key]; if (value === undefined || value === field.ai) continue; const at = address.indexOf('.'); if (at <= 0) continue; const domain = address.slice(0, at); const object = address.slice(at + 1); const rows = drafts.filter((d) => d.domain === domain && d.object === object); if (rows.length !== 1) { // 不猜是哪一行:如实记痕,人看得见「这一改没落下去」 await memoryRecorder.append({ taskId, kind: 'result', title: `修订未落台账:${field.label}(${address} 本单有 ${rows.length} 行,改哪一行说不清)`, detail: { field: field.key, address, rows: rows.length }, }); continue; } await taskData.draftWrite({ taskId, domain, object, key: rows[0]!.key as never, patch: { [field.key]: value } }); await memoryRecorder.append({ taskId, kind: 'ledger-write' as never, title: `人工修订:${field.label} → ${String(value)}(落 ${address})`, // `by` = 这一改是**谁**改的 —— 日志按它认主语(没有它就是基座自己落的账) detail: { field: field.key, address, from: field.ai, to: value, by: (await taskContext.of(taskId)).ownerId }, }); } } }; /* ── skill-engine ─────────────────────────────────────────────── */ const skills = createSkillEngineService({ catalog: { skillsOf: async (activityId) => (await deps.registry.manifestOf(activityId)).assets.skills, allowlistOf: (activityId) => deps.toolbox.allowlistOf(activityId), }, tools: { invoke: (input) => toolGateway.invoke({ ...input, ...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }) }), }, memory: memoryRecorder as never, clock, }); void skills; /** * 模型网关加观测:每次模型调用一个 span(用途名、标签、用量)—— * 「活动内部到底问了几次模型」在时间轴上要看得见。 * 包一层不改语义:录制回放、四元绑定、凭证隔离全在原网关里,这里只旁观。 */ const tracedGateway: ModelGateway = { ...deps.modelGateway, generate: async (call) => { const span = call.taskId === undefined ? undefined : deps.trace.beginAction({ taskId: call.taskId, name: `模型:${call.label ?? call.model}`, kind: 'model', input: { model: call.model, label: call.label }, }); try { const result = await deps.modelGateway.generate(call); await span?.end({ output: { chars: result.text.length }, /* * 入/出分开记,推理长度单独记 —— 排「一拍为什么慢」全靠这三个数: * 只有总数时,「入参膨胀」与「推理跑长」两种病看起来一模一样 * (实测 56s 离群拍:可见出参 719 字对不上多出的 4k token, * 拆开才看得见是隐藏推理在烧解码)。 */ attributes: { tokens: result.usage.inputTokens + result.usage.outputTokens, tokIn: result.usage.inputTokens, tokOut: result.usage.outputTokens, reasonChars: result.reasoning?.length ?? 0, mode: result.mode, }, }); return result; } catch (error) { await span?.fail(error); throw error; } }, }; /* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */ /** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */ const modelOf = async (taskId: TaskId) => { const { activityId } = await taskContext.of(taskId); const manifest = await deps.registry.manifestOf(activityId); return manifest.declarations.models[0] ?? 'default'; }; const onAgentUsage = (taskId: TaskId, tokens: number) => taskStore.addConsumption(taskId, { tokens }).then(() => undefined); /** * 工具总线:**声明 × 目录**,编译期定死这一单能调什么(声明面在 toolbox/domain)。 * 功能工具按活动白名单查目录;人类工具恒在(Harness 给的能力); * LLM 五件仅当活动声明了模型用途名才给 —— 没声明模型却会问模型,是越权也无从归因。 */ const busForActivity = async (activityId: string) => { const manifest = await deps.registry.manifestOf(activityId); const catalog = new Map( (await deps.toolbox.list()).map((c) => [ c.name, { name: c.name, description: c.description, input: c.input, ...(c.output === undefined ? {} : { output: c.output }), effect: c.effect === 'read' ? ('read' as const) : ('write' as const), }, ]), ); const report = compileToolBus({ declaredTools: manifest.declarations.tools.map((t) => t.name), catalog, declaredModels: manifest.declarations.models, }); return report; }; /** * 开环路径要的那份(带任务上下文)——「缺件如实记痕」发生在这一层: * 声明了却查无此工具 / 已停用,Agent 手上就少了件东西,得在任务轨迹上看得见。 */ const busOf = async (taskId: TaskId) => { const { activityId } = await taskContext.of(taskId); const report = await busForActivity(activityId); if (report.missing.length > 0 || report.disabled.length > 0) { await memoryRecorder.append({ taskId, kind: 'observation', title: `工具总线缺件:未装 ${report.missing.join('、') || '无'};已停用 ${report.disabled.join('、') || '无'}`, } as never); } return report.tools; }; /* * 开环 or 单拍:`JCAI_AGENT_OPEN_LOOP=1` 时用 Mastra Agent 放开拍内工具循环。 * 缺省仍是单拍版 —— 开环把「一拍调几次模型」交给了模型自己, * 得有第一个真 loop 活动压着测过才好当缺省(见 debt M5-2)。 */ const agentRunner = globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1' ? createMastraAgentRunner({ gateway: tracedGateway, modelOf, busOf, onUsage: onAgentUsage, contextOf: async (taskId) => { const ctx = await taskContext.of(taskId); return { taskId, ownerId: ctx.ownerId, humanCallCap: ctx.humanCallCap }; }, ports: { invoke: async (input) => { const { activityId } = await taskContext.of(input.taskId); return toolGateway.invoke({ ...input, activityId }); }, isWriteEffect: (tool) => toolGateway.isWriteEffect(tool), registerIntent: (input) => toolGateway.registerIntent(input), // 台账草稿写:与单拍路径共用同一道把关(地址与列名都验) draftWrite: async (input) => { const at = input.address.indexOf('.'); if (at <= 0 || at === input.address.length - 1) { throw new Error(`台账地址要写成「域.对象」:收到 ${input.address || '(空)'}`); } const domain = input.address.slice(0, at); const object = input.address.slice(at + 1); /* * **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写, * 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」—— * 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6), * 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。 * 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。 */ for (const row of input.rows) { await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true }); } for (const row of input.rows) { await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch }); } return { written: input.rows.length }; }, issueCard: (input) => cards.issue(input), listen: (input) => listeners.register(input), generate: async (input) => { const result = await deps.modelGateway.generate(input); await onAgentUsage(input.taskId, result.usage.inputTokens + result.usage.outputTokens); return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) }; }, }, }) : createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf, /* * 输出不成形就地重问时,把模型原文存进工作日志(内部痕)—— * 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。 */ note: async (taskId, title, raw) => { await deps.memory.append({ taskId, kind: 'result', title, detail: { internal: true, raw }, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, }); }, }); /* ── loop-engine ──────────────────────────────────────────────── */ const loop = createTurnRunner({ state: createLoopStateStore(db), sfeel, data: taskData, agent: agentRunner, tools: toolGateway, cards, listeners, views: viewGateway, skills: { index: (activityId) => deps.skills.index(activityId), stamp: (activityId, ids) => deps.skills.stamp(activityId, ids), }, memory: { append: memoryRecorder.append as never, loadActivityMemory: async (activityId: string, limit: number) => (await deps.memory.loadActivityMemory(activityId, { limit })).map((m) => ({ layer: m.layer, statement: m.statement })), // 任务自身的最近轨迹:窗口大小是活动声明的唯一 Harness 记忆配置(manifest.declarations.harness.memoryWindow) window: async (taskId, want) => { const { activityId } = await taskContext.of(taskId); // 不指定就按活动声明的窗口(叙事面);熔断检测会显式要一个更大的 const size = want ?? (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow; // detail 一起带上 —— 人回填的值在 detail.values 里,不给就等于把答案藏了起来 return (await deps.memory.window(taskId, size)).map((m) => ({ kind: m.kind, title: m.title, ...(m.detail === undefined ? {} : { detail: m.detail }), })); }, }, tasks: taskContext, clock, }); /** * process 晚于 task 构造 —— 延后绑定(与 container 里 `TriggerMatcher` 同一手法)。 * 未绑定时静默不报:装配尚未完成的那一瞬没有任务会终态。 */ const processRef = createDeferredProcess(); /** * 任务的输出口载荷。 * * 载荷来自**落地收敛的事实**(`facts`),不是从作用域里再捞一遍: * 放行那一刻草稿层已合并并清空,此时再捞只剩起单入参,下游节点会因缺字段起不来。 * 输出口声明的属性名与台账列名可能不同形(驼峰 / 下划线),两种写法都认。 */ function outputPortsOf( manifest: { contract: { output: { mandatory: ReadonlyArray<{ name: string; payload: unknown }>; selective?: ReadonlyArray<{ group: string; ports: ReadonlyArray<{ name: string; payload: unknown }> }>; }; }; }, landed: LandedFacts, ): Record> { const snake = (name: string): string => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); const ports: Record> = {}; // 必交付口与选择组的口都要装配:活动可以只有选择组(如 A6 的成交/有异议/拒绝), // 只看必交付口会得到一张空表,下游连分支判据都读不到。 const declaredPorts = [ ...manifest.contract.output.mandatory, ...(manifest.contract.output.selective ?? []).flatMap((g) => g.ports), ]; for (const port of declaredPorts) { const payload = port.payload as { properties?: Record }; const declared = Object.entries(payload.properties ?? {}); const picked: Record = {}; for (const [field, spec] of declared) { // 数组属性 = 这一单落地的那些行(逐行的键 + 值);标量属性 = 铺平后的事实 if (spec.type === 'array') { picked[field] = landed.rows.map((r) => ({ ...r.key, ...r.values })); continue; } const value = landed.flat[field] ?? landed.flat[snake(field)]; if (value !== undefined) picked[field] = value; } // 声明之外的落地事实一并带上:边映射也可能直接引用台账列名 ports[port.name] = { ...landed.flat, ...picked }; } return ports; } /* ── task(L6):活动的运行时 —— 只有一种引擎(2026-07-29 起) ────── */ const task = createTaskService({ store: taskStore, activities: { async load(activityId) { const manifest = await deps.registry.manifestOf(activityId); const envelope = await deps.registry.loadEnvelope(activityId); return { caps: manifest.contract.resources.budget, envelope: { ...envelope }, inputSchema: manifest.contract.input.data, ...(manifest.contract.input.inboundComm === undefined ? {} : { inboundComm: manifest.contract.input.inboundComm }), businessModel: manifest.assets.businessModel, stallAfter: manifest.declarations.harness.stallAfter, }; }, }, /* * 沟通记录 —— 一单往来的原件存档。直接落库而不绕模块:它是**任务的一件附属事实**, * 只有起单归档与工作台那一屏读它两处用,不值得为它立一个模块出口。 */ comms: { async record(input) { await db.execute(sql` insert into sys.task_comm (id, task_id, direction, channel, party, subject, body, attachments, note) values (${ids.next()}, ${input.taskId}, ${input.direction}, ${input.channel}, ${input.party}, ${input.subject}, ${input.body}, ${JSON.stringify(input.attachments)}::jsonb, ${input.note})`); }, }, schemas: deps.schemas, /* * 唯一的引擎(2026-07-29 用户定:活动不再分 flow 和 loop,内部实现都是 Loop)。 * 从前这里是两个引擎按 paradigm 分派;flow 那一路连同 flow-engine 模块一并退役 —— * 一件事该怎么办由 Agent 依案例模型临场决定,不再有「照谱执行」的第二种活动。 */ engine: { load: (taskId, model) => loop.load(taskId, model as never).then(() => undefined), advance: async (taskId) => { const outcome = await loop.runTurn(taskId); return { status: outcome.status, ...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }), // 终局唯放行:runTurn 依在途验收的裁决给出 finished 时,released/选口一并带回 ...(outcome.released === undefined ? {} : { released: outcome.released }), ...(outcome.selectedPorts === undefined ? {} : { selectedPorts: outcome.selectedPorts }), }; }, // 只记不跑:登记事件/收裁决即返,推进统一归 deliver 随后的 tick —— 预算一个口径 onEvent: async (taskId, event) => { await loop.onEvent(taskId, event); return { status: 'running' as const }; }, }, landing: { /** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口(OS 7.3) */ async land(input) { /* * **人在验收报告上改的值,放行前落进草稿层** —— 这样 `releaseWrite` 合并的是修订后的事实。 * * 不做这一步的后果:改动只留在工单留痕里,落地时用的还是 AI 的原值 —— * 「验收报告 AI 已填好、可编辑」就成了摆设(第 12 章③ 的另一半)。 * * 地址由字段自己声明(`FormField.ledger` = `域.对象`),只改**本单在该对象的那一行**: * 该对象本单写了多行时不猜是哪一行,如实记一条痕跳过 —— 逐行的修改得回环里改, * 在一张验收单上改几十行是另一件事。 */ await applyRevisions(input.taskId); /* * **产出的资料文件在这儿冻住** —— 修订刚落回草稿层、合并还没发生: * 存下来的正是他签字那一刻的样子(与工单的 `finalHtml` 快照同一个道理)。 * * 为什么非存不可,而不是回头照台账现造:资料常落在**主数据**上, * 而试跑单放行时主数据只留在活动缓存、不落正式表 —— 不存这一份, * 试跑单一放行,人在工作成果里就什么都点不开了。正式单则是另一头的理由: * 台账后来被下一单改了,归档件不该跟着改口。 * 存不下来不拦放行(资料是成果的一份,不是放行的前提):记一条痕,人看得见。 */ try { const manifest = await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId); const saved = await archiveAssetFiles({ db, taskId: input.taskId, ids, declarations: manifest.declarations.ledger, rowsOfAddress: async (address) => ( await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 }) ).rows as Array>, }); if (saved > 0) { await memoryRecorder.append({ taskId: input.taskId, kind: 'ledger-write' as never, title: `产出的资料已存档:${saved} 份(工作成果里点得开)`, detail: { assetFiles: saved }, }); } } catch (error) { await memoryRecorder.append({ taskId: input.taskId, kind: 'result', title: `资料存档没做成:${error instanceof Error ? error.message : String(error)}`, }); } const outcome = await deps.release.land({ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap, selectedPorts: input.selectedPorts, }); return { converged: outcome.verdict.kind === 'converged', emittedPorts: outcome.emittedPorts, ledgerRowsMerged: outcome.ledgerRowsMerged, // 输出口载荷 = 已落地的事实:铺平供标量取值,逐行供数组取值 facts: { flat: { ...outcome.merged.reduce>( (acc, row) => ({ ...acc, ...row.key, ...row.values }), {}, ), /* * **选择组发射的那一口就是这次运行的 outcome**。 * 「恰发一口,随放行一并确认」——那一口本身就是这一单的分支结论, * 下游异或网关的判据读的就是它(如 G4:成交 / 有异议 / 拒绝)。 * 只有选择组才产生 outcome;必交付口是「都要交」,不构成选择。 */ ...(Object.values(input.selectedPorts).length > 0 ? { outcome: Object.values(input.selectedPorts)[0] } : {}), }, rows: outcome.merged.map((r) => ({ key: r.key, values: r.values })), }, }; }, }, memory: { append: memoryRecorder.append as never, seal: (taskId) => deps.memory.seal(taskId), distill: (input) => deps.memory.distill(input), hasHistory: async (taskId) => (await deps.memory.history(taskId)).length > 0, }, listeners: { hasArmedListener: async (taskId) => (await deps.event.listListeners(taskId)).some((l) => l.state === 'armed'), }, persistence: { hasDrafts: async (taskId) => (await deps.ledger.checkConflicts(taskId)) !== undefined, hasRuntimeState: async (taskId) => (await loop.runtime(taskId)) !== undefined, }, events: { publishControl: async (taskId, semantic, by, reason) => { await deps.event.publish({ type: 'control', correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'governance', ref: by }, keys: {} }, payload: { data: { semantic, issuedBy: by, reason } }, occurredAt: clock.now().toISOString(), source: { kind: 'governance' }, }); }, publishTerminal: async (taskId, terminal, reason, facts) => { await deps.event.publish({ type: 'data', correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'engine', ref: 'terminal' }, keys: { terminal } }, payload: { data: { terminal, reason } }, occurredAt: clock.now().toISOString(), source: { kind: 'engine', detail: 'task' }, }); /* * 任务终态到达 → 通知流程层(L7)。 * * task 在 L6、process 在 L7,task 不得反向依赖 process —— 这一跳由 app 这层缝合。 * 「工作流状态是聚合投影」的前提是节点实例的终态**报得上去**:不报, * 流就停在那个节点上,既不流转也不走失败走向。 * 用延后绑定是因为 process 在本函数里晚于 task 构造。 */ const task = await taskStore.get(taskId); const manifest = task === undefined ? undefined : await deps.registry.manifestOf(task.activityId); const ports = manifest === undefined ? {} : outputPortsOf(manifest, facts); await processRef.onTaskTerminal(taskId, terminal, ports); }, }, notifier: { notify: async (input) => { // 改派留痕并**通知双方** for (const to of [input.from, input.to]) { await deps.message.notify({ staffId: to, title: `任务改派:${input.title}`, body: `任务 ${input.taskId} 的负责人由 ${input.from} 改为 ${input.to}(操作人 ${input.by})`, }); } }, }, interaction: { setPhase: async (taskId, phase) => void (await deps.interaction.setPhase(taskId, phase)) }, /* * 受理补全台(宽进):缺必填 → 补全卡进任务对话流 + 武装 human 监听等答复。 * 不占活动的人卡预算 —— 补全发生在活动开跑之前,是受理的事,不是活动的打扰。 */ intake: { askCompletion: async (input) => { const card = { id: cardIdSchema.parse(ids.next()), at: clock.now().toISOString(), k: 'elicitation' as const, ask: `受理这一单还缺几个信息 —— 补上,流程才跑得下去;实在没法填就写明原因。`, fields: input.fields.map((f) => ({ key: f.key, label: f.label })), }; await deps.humanTool.issueChatCard( { taskId: input.taskId as never, ownerId: input.ownerId as never, humanCallCap: 1_000_000 }, card, ); await listeners.register({ taskId: input.taskId as never, eventType: 'human', keyFilter: { cardId: card.id }, // 时限是 ISO-8601 **时长**(P2D = 两天),不是时刻 —— 到点抛时间事件,无死等 deadline: 'P2D', }); }, }, clock, ids, }); /* ── process(L7) ─────────────────────────────────────────────── */ const processDeps: ProcessServiceDeps = { store: createProcessStore(db), activities: { async faces() { const cards = await deps.registry.list(); const map = new Map> extends never ? never : never>(); void map; const faces = new Map(); for (const card of cards) { const manifest = await deps.registry.manifestOf(card.activityId); const input = manifest.contract.input.data as { required?: string[]; properties?: Record }; const outputs: Record> = {}; for (const port of manifest.contract.output.mandatory) { const payload = port.payload as { properties?: Record }; outputs[port.name] = Object.fromEntries( Object.entries(payload.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']), ); } faces.set(card.activityId, { activityId: card.activityId, requiredInputs: input.required ?? [], inputTypes: Object.fromEntries( Object.entries(input.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']), ), outputs, }); } return faces; }, }, org: { positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)), candidates: async (position) => { const staff = await deps.org.staffOfPosition(position); const withLoad = await Promise.all( staff.map(async (s) => ({ staffId: s.id, name: s.name, openTasks: (await task.openTasksOf(s.id)).length, })), ); return withLoad; }, // 起单时按来源人取其工作组(ADR-011);没建组即 undefined,全走四策略 workGroupBinding: async (staffId) => deps.org.bindingOf(staffIdSchema.parse(staffId)), isActive: async (staffId) => { const staff = await deps.org.getStaff(staffIdSchema.parse(staffId)).catch(() => undefined); return staff?.state === 'active'; }, }, tasks: { start: async (input) => { const started = await task.start({ activityId: input.activityId, ownerId: input.ownerId, title: input.title, workflowId: input.workflowId, nodeId: input.nodeId, data: input.data, ...(input.message === undefined ? {} : { message: input.message }), }); // 节点 span:活动内的每一步、每次调用都会挂在它下面 deps.trace.beginNode({ workflowId: input.workflowId, nodeId: input.nodeId, label: input.title, taskId: started.id, assigneeId: input.ownerId, input: input.data, }); return started; }, }, approvals: { async raise(input) { // 审批点:卡到审批人工位 —— 与活动级的放行不同层 deps.trace.beginNode({ workflowId: input.workflowId, nodeId: input.nodeId, label: `审批:${input.subject}`, assigneeId: input.approverId, input: Object.fromEntries(input.basis.map((b) => [b.k, b.v])), }); const issued = await deps.humanTool.issueExecution( { taskId: input.workflowId as never, ownerId: input.approverId, humanCallCap: 99 }, { title: `审批:${input.subject}`, basis: input.basis.length > 0 ? [...input.basis] : [{ k: '节点', v: input.nodeId }], ask: '请裁决:通过 / 驳回(驳回必附意见)', fields: [ { key: 'verdict', label: '裁决', kind: 'radio', required: true, options: [ { value: 'pass', label: '通过' }, { value: 'reject', label: '驳回', negative: true }, ], }, { key: 'comment', label: '依据 / 意见', kind: 'textarea' }, ], }, ); return { id: issued.order.id }; }, }, sfeel, schemas: deps.schemas, events: { publish: async (input) => { await deps.event.publish({ type: 'data', correlation: { target: { kind: 'workflow', id: input.workflowId as never }, cause: { kind: 'engine', ref: input.kind }, keys: {}, }, payload: { data: input.detail }, occurredAt: clock.now().toISOString(), source: { kind: 'engine', detail: 'process' }, }); }, }, receipts: { notify: async (input) => { await deps.message.notify({ staffId: input.to, title: `工作流回执:${input.subject}`, body: `工作流 ${input.workflowId} 状态:${input.state}`, }); }, }, clock, ids, }; /* * 观测的挂点:**root span 必须先于任何节点 span**。 * `runtime.begin` 正是「单已建好、还没进第一个节点」那一刻 —— 挂在这儿, * 后面所有节点/工具/模型/人卡的 span 才认得到父亲。挂在 `start` 返回之后就晚了: * 起单是同步走到第一个节点的,节点 span 会先出生、另起一棵孤树(实测)。 */ const selfRuntime = createSelfHostedRuntime(processDeps); const tracedRuntime: FlowRuntime = { ...selfRuntime, async begin(record, pkg) { deps.trace.beginWorkflow({ workflowId: record.id, subject: record.subject, input: record.initiation }); await selfRuntime.begin(record, pkg); }, }; const process: ProcessService = createProcessService({ ...processDeps, runtime: tracedRuntime }); /* ── trigger(L7) ────────────────────────────────────────────── */ const trigger = createTriggerService({ store: createTriggerStore(db), process: { start: async (input) => process.start(input), requiredInputs: async (packageId) => { // 与起单取版同口径:读**生效版本**的调用接口(一条流恰一个生效版本) const all = await process.listPackages(); const pkg = all.find((x) => x.id === packageId && x.state === 'active'); const input = pkg?.callInterface.input as { required?: string[] } | undefined; return input?.required ?? []; }, }, sfeel, /* * 确认台:命中但勾了「需人工确认」—— 带来件内容的系统消息送到指定人工作台, * `eventRef` 挂待确认单号,工作台消息列表凭它贴出「确认起单」那枚钮。 */ confirmDesk: { request: async (input) => { const lines = [ `触发器「${input.triggerName}」命中一件来件,按运营配置须你确认后才起单。`, input.preview['from'] === undefined ? undefined : `发件人:${input.preview['from']}`, input.preview['subject'] === undefined ? undefined : `主题:${input.preview['subject']}`, input.preview['body'] === undefined ? undefined : `\n${input.preview['body'].slice(0, 600)}`, ].filter((l): l is string => l !== undefined); await deps.message.notify({ staffId: input.staffId as never, title: `确认起单:${input.subject}`, body: lines.join('\n'), eventRef: `trigger-pending:${input.pendingId}`, }); }, }, deadLetters: { // 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃 report: async (input) => { writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`); const { staff } = await deps.org.listStaff(); for (const person of staff) { const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern'); if (!governs) continue; await deps.message.notify({ staffId: person.id, title: `触发器起单失败:${input.triggerId}`, body: input.reason, }); } }, }, ledger: { scan: async (input) => { const page = await deps.ledger.query({ address: { domain: input.domain, object: input.object }, where: {}, orderBy: [], limit: 200, offset: 0, }); return page.rows.flatMap((row) => { const date = row[input.dateField]; if (typeof date !== 'string') return []; return [{ key: { id: String(row['id'] ?? '') }, date, row }]; }); }, }, clock, ids, }); // 延后绑定落定:此后任务终态才报得到流程层 processRef.bind(process); /** * 流程服务加观测:起单开一条 trace(root span),终态收尾。 * 包一层不改语义 —— 节点、工具、模型、人卡的 span 都会挂进这条 trace。 */ const tracedProcess: ProcessService = { ...process, async onTaskTerminal(taskId, terminal, ports) { /* * 节点 span 先收(这一步办完了),再让流程往下走 —— 时间轴上不倒序。 * **非放行终态标成错**:blocked / exhausted / 失败都要在时间轴上看得出来, * 收成一条无害的「已结束」等于把事故藏起来(实测:落地失败的那几单, * 观测面上与走完的单长得一模一样)。原因从任务上现读。 */ const failed = terminal !== 'released'; const reason = failed ? await task .get(taskId as TaskId) .then((t) => JSON.stringify(t.reason ?? terminal)) .catch(() => terminal) : undefined; await deps.trace.endNode({ taskId, output: { terminal }, ...(reason === undefined ? {} : { error: `${terminal}:${reason}` }), }); const state = await process.onTaskTerminal(taskId, terminal, ports); return state; }, async onApprovalVerdict(workflowId, nodeId, verdict, payload) { await deps.trace.endNode({ workflowId, nodeId, output: { verdict } }); const state = await process.onApprovalVerdict(workflowId, nodeId, verdict, payload); if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state }); return state; }, async refreshState(workflowId) { const state = await process.refreshState(workflowId); if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state }); return state; }, }; /** 此刻这一单的判据 —— 与提交门同一段(`criterionCheck`),按当下数据现算 */ const checks = { of: async (taskId: TaskId) => { const { activityId } = await taskContext.of(taskId); const manifest = await deps.registry.manifestOf(activityId); const scope = await taskData.view(taskId); const judged = await taskContext.judgements(taskId); return manifest.contract.acceptanceCriteria.map((c) => criterionCheck(c, scope, judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label))), ); }, }; return { task, process: tracedProcess, trigger, loop, checks, views: viewGateway }; }