This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/app/agent-runner.ts
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

1097 lines
58 KiB
TypeScript

/**
* loop 的 Agent 回路 —— `AgentPort` 的实现(app 层装配,编码规范 §5.2)。
*
* 「回合由 usecase 驱动,**不放开自由 maxSteps**,预算在环外由 Harness 强制」——
* 所以这里只做**一次**决断:把 Enabled 集与红绿灯喂给模型,要它在三型行动里选一个。
*
* 一切模型调用经 `ModelGateway`(两缝之一),replay 模式下自动走录制 —— 回放审看得见。
* 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway,
* 端口不变,换实现时 usecase 不动。
*/
import type { AgentAction, AgentTurn, EnabledSet } from '@modules/loop-engine';
import { TURN_ACTION_CAP } from '@modules/loop-engine';
import type { ModelGateway } from '@modules/llm';
import type { TaskId } from '@contracts';
/** 一个行动项 —— 三型行动之一(外加规划 / 弃用 / 评估 / 完成 / 提交),schema 把它框住 */
const ACTION_ITEM_SCHEMA = {
type: 'object',
required: ['kind'],
properties: {
kind: {
enum: ['start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'entry', 'decide', 'human', 'listen', 'finish', 'submit'],
},
itemId: { type: 'string' },
tool: { type: 'string' },
input: { type: 'object' },
/* 写台账草稿:地址 + 多行(键 + 补丁)—— 一拍写完一份 BOM,不一行一拍 */
address: { type: 'string', description: '台账地址「域.对象」—— 照可写台账字典原样抄一个,不要拆开' },
rows: {
type: 'array',
items: {
type: 'object',
required: ['key', 'patch'],
properties: { key: { type: 'object' }, patch: { type: 'object' } },
},
},
/* 撤草稿:同一个 address,外加要撤的那几行主键 —— 写错了得有退路 */
keys: {
type: 'array',
description: 'drop 用:要撤掉的那几行的主键,与写进去时**一字不差**',
items: { type: 'object' },
},
workOrder: { enum: ['execution', 'acceptance'] },
/*
* 卡面结构由 schema 框住 —— 「调用必附上下文,**裸调用不得发出**」(AM 06)。
* 一张只写「请处理」的卡等于没说话:收卡的人不知道要答什么、凭什么答。
*/
card: {
type: 'object',
required: ['title', 'ask'],
properties: {
title: { type: 'string' },
ask: { type: 'string' },
reason: { type: 'string' },
basis: { type: 'array' },
fields: { type: 'array' },
/*
* 作业指导书两件(2026-08-03 补进 schema):`steps` 一行一条 + `standard` 收口标准。
* 它们从前不在这份 schema 里,能到卡面纯靠厂商 JSON 模式宽松放行 ——
* 而本仓早有实测纪律:「结构化出参只吐 schema 里声明过的键,光在提示词里交代不算」
* (ai/ledger 那两个键当年就是这么丢的)。指导书是执行卡的正文,不能靠运气。
*/
steps: { type: 'array', items: { type: 'string' }, description: '作业指导书:一行一条,纯文本(不写星号记号)' },
standard: { type: 'string', description: '收口标准:办到什么程度算完,一句话' },
/*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
*
* 执行工单本来就收 `blocks`(契约里一直有),可这份 schema 里没有它 ——
* 结构化出参只吐 schema 里声明过的键,于是模型写了也会被整个丢掉。
* 实测:说明书让它派一张「带可编辑需求表」的执行单,它照做了,
* 而人收到的卡上一张表都没有,只剩一句「请向客户补全这两颗料的缺口」。
*
* 最要紧的是 `table` + `editable: true`:空格子界面会自己标红「待填写」。
*/
blocks: {
type: 'array',
description: '卡面正文块:table(可 editable)/ kv / callout / ul —— 把数据摆到表单旁边',
items: {
type: 'object',
required: ['t'],
properties: {
t: { enum: ['table', 'kv', 'callout', 'ul'] },
title: { type: 'string' },
cols: { type: 'array', items: { type: 'string' }, description: 'table:列名' },
rows: {
type: 'array',
items: { type: 'array', items: { type: 'string' } },
description: 'table:一行一条,**没有的值给空字符串**(界面自己标红待填写);绑了 ledger 就不必给',
},
/*
* 绑台账:**行让基座去铺**,别自己抄一遍。
*
* 人要在卡上改的是那份清单本身;而把整份塞进这一份回复里有两处死结 ——
* 回复有长度上限,铺长了半途截断整拍作废(实测);
* 那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会。
*/
ledger: { type: 'string', description: 'table:绑台账地址(域.对象),行由基座按本单草稿铺满' },
fields: {
type: 'array',
items: { type: 'string' },
description: 'table:与 cols 一一对应的台账列名 —— 绑了 ledger 就必须给',
},
editable: { type: 'boolean', description: 'table:让人当场改' },
editableCols: { type: 'array', items: { type: 'integer' }, description: '只让改这几列(下标)' },
items: { type: 'array', description: 'kv:[{k,v}];ul:字符串数组' },
tone: { type: 'string', description: 'callout:语气' },
text: { type: 'string', description: 'callout:正文' },
},
},
},
},
},
/* 补全卡 / 决策卡共用这一格:问一句(两者都是对话卡族,不是执行工单) */
ask: { type: 'string', description: '补全卡:一句话说清缺口;决策卡:一句话说清这是个什么岔路' },
/*
* 决策卡的选项 —— **至少两个**。`consequence` 是「选了它接下来会发生什么」,
* 人靠它做判断;不写等于让人猜,那就跟没给选项差不多。
*/
options: {
type: 'array',
description: '决策卡:一条路一项,至少两项',
items: {
type: 'object',
required: ['value', 'label'],
properties: {
value: { type: 'string', description: '机器认的值(回传时就是它)' },
label: { type: 'string', description: '按钮上的话' },
detail: { type: 'string' },
consequence: { type: 'string', description: '选了它接下来会发生什么' },
recommended: { type: 'boolean' },
},
},
},
allowCustom: { type: 'boolean', description: '决策卡:允许人给选项之外的处置(附理由)' },
/*
* 两用一格:
* · 补全卡(ask)—— `{key,label,hint}`,人从空白填起;
* · 提交放行(submit)—— `{key,label,ai,ledger}`,验收报告上让人改的那几个值。
*
* `ai` / `ledger` **必须写进 schema**:结构化出参只吐 schema 里有的键,
* 光在提示词里交代不算 —— 实测模型照着 schema 吐,拟值与落回地址被整个丢掉,
* 验收报告于是只能看不能改。
*/
/* 数据录入卡:落哪张表、哪个主键 —— 人确认那一下基座照它写 */
ledger: { type: 'string', description: '数据录入卡:落表地址「域.对象」' },
entryKey: { type: 'object', description: '数据录入卡:主键值,如 {"customer_code":"C-XXX"}' },
also: {
type: 'array',
description: '数据录入卡:同一次确认还要落的另几张表,每段 {title, ledger, key, fields} —— 建客户就是公司档 + 联系人档',
items: { type: 'object' },
},
fields: {
type: 'array',
description: '补全卡:要对方填的格子;数据录入卡:要写的那一行;提交放行:验收报告上让人改的值',
items: {
type: 'object',
properties: {
key: { type: 'string' },
label: { type: 'string' },
hint: { type: 'string', description: '补全卡:格子里的灰字提示' },
value: {
type: 'string',
description:
'补全卡 / 数据录入卡:**预填值** —— 你从来件里读得出的先替人填上(公司名、联系人姓名等),' +
'人只需核对一眼、补上你填不出的那个。读不出来的别给:填错了人还得先擦掉再写。',
},
required: { type: 'boolean', description: '数据录入卡:必填(空着不让确认)' },
ai: { type: 'string', description: '提交放行:AI 拟值 —— 验收报告「已填好」的那个值' },
ledger: { type: 'string', description: '提交放行:改完落回哪个台账地址(域.对象)' },
},
},
},
basis: { type: 'array', description: '依据快照:键值对数组' },
eventType: { enum: ['data', 'time'] },
keyFilter: { type: 'object' },
deadline: { type: 'string' },
criterion: { type: 'string' },
satisfied: { type: 'boolean' },
rationale: { type: 'string' },
reason: { type: 'string' },
},
} as const;
/**
* 一拍的出参:一串行动 + 一句交代。
*
* 行动收**数组**是按实测账改的:一拍只许一个行动时,每个 write / finish / start
* 都单烧一次完整模型调用 —— A1 六步的活恰好 21 拍,其中 19 拍是该约束下的最短路径。
* 交代(say/tag/view)在拍级不在行动级:它是这一拍的附带,不与行动抢位置。
*/
const TURN_SCHEMA = {
type: 'object',
required: ['say', 'actions'],
properties: {
/*
* **一轮一句,不是一拍一句**(2026-08-01 用户定)。
*
* 这句话只在**要把控制权交出去的那一拍**才会进对话区(发卡 / 立监听 / 提交放行);
* 埋头办事的那些拍它照旧留进工作日志,人看不到。所以别写工序播报
* (「我这就把标写进分类表,收掉这一步」)—— 那种话攒五条也没告诉人一件事。
* 要写就写**这一轮办完了什么、结论是什么、为什么现在要找他**。
*/
say: {
type: 'string',
description:
'对负责人说的一句人话。**只在发卡/立监听/提交放行那一拍才会摆到他面前**:' +
'写这一轮的结论与为什么现在需要他,别播报「我这就去写哪张表、收哪一步」。',
},
tag: { enum: ['guidance', 'criterion', 'conclusion'] },
view: { type: 'string', description: '可选:换成哪张局面模版(活动清单里的名字)' },
actions: {
type: 'array',
minItems: 1,
maxItems: TURN_ACTION_CAP,
description: '这一拍按序执行的行动串;要看工具出参才能定的事,别写在工具后面',
items: ACTION_ITEM_SCHEMA,
},
},
} as const;
export type AgentRunnerDeps = {
readonly gateway: ModelGateway;
/** 活动声明的自定义模型名 */
readonly modelOf: (taskId: TaskId) => Promise<string>;
/** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
/**
* 留一条内部痕(进工作日志,不进对话区)。输出不成形就地重试时用它把
* **模型原文**存档 —— 卡面不再摆 JSON 原话(人看着像乱码),原文归日志,
* 想尸检的时候有处可查。可选:不给就只重试不留痕。
*/
readonly note?: (taskId: TaskId, title: string, raw: string) => Promise<void>;
};
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
export type WritableLedger = Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{ name: string; label: string; type?: string }>;
}>;
export function ledgerDictionary(writable: WritableLedger): string {
if (writable.length === 0) return '';
return [
'【可写台账(只准写这些地址与列名)】',
...writable.map(
(w) =>
`- ${w.domain}.${w.object} ${w.label}|主键:${w.keys.join('+') || '(无)'}` +
// 列型也摆出来:数值列写不进空串(空 = 不写这一列),它得知道
`|列:${w.fields
.map((f) => `${f.name}${f.label === f.name ? '' : `(${f.label})`}${f.type === undefined || f.type === 'text' ? '' : `:${f.type}`}`)
.join('、')}`,
),
].join('\n');
}
/** 本单自己写下的行 —— `mine:` 前缀那一份(与视图模版同一约定) */
function minePart(scope: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(scope).filter(([k]) => k.startsWith('mine:')));
}
/**
* 其余作用域:**摊平字段与起单入参在前,`域.对象` 整表殿后**。
*
* 顺序就是生死:这一段进提示词前会按字符截断,而整表里躺着**历史所有已放行单**的行,
* 只会随单量无限长 —— 排在前面,截断刀落下时被砍掉的是这一单自己的摊平字段(实测
* 入参膨胀到 13k token 的那一拍就是这么来的)。整表殿后,砍也先砍旧账。
*/
function restPart(scope: Record<string, unknown>, options?: { withoutAttachments?: boolean }): Record<string, unknown> {
const rest = Object.entries(scope).filter(
([k]) =>
!k.startsWith('mine:') &&
!k.startsWith('focus:') &&
!(options?.withoutAttachments === true && k === 'attachments'),
);
const flat = rest.filter(([k]) => !k.includes('.'));
const tables = rest.filter(([k]) => k.includes('.'));
return Object.fromEntries([...flat, ...tables]);
}
/**
* 附件单独一段 —— **它是这一单的料本身,不许被截断刀砍**。
*
* 表能拆的先替它拆好(`rows`);拆不出的保留原文,并如实说明为什么。
* 一件都没有就不摆这一段:空标题只会占位置。
*/
export function attachmentPart(scope: Record<string, unknown>): string {
const list = scope['attachments'];
if (!Array.isArray(list) || list.length === 0) return '';
const shaped = withSheetRows(scope)['attachments'];
return `【随件附件(基座已读过一遍;rows 是替你拆好的表,键就是表头)】\n${JSON.stringify(shaped)}`;
}
/**
* 附件里的**表**:制表符分列的原文 → **一行一个对象**,键就是表头。
*
* 为什么非做不可:模型拆制表符会数错格子。连着两个 `\t` 是中间那一格空着,
* 而它常把右边的值往前挪 —— 实测同一行栽过两次:BOM 上白纸黑字写着
* 「Антенна · 浙江嘉康 · (空) EAU」,拆出来成了「集成电路 · (空) · 30000」:
* 品类错了、制造商丢了、年用量凭空多了一个。**数格子是能算的事,不该问 LLM。**
*
* 只认「首行是表头、列数对得上」的规整表;对不上就原样留着文本,
* 让模型自己看 —— 宁可不帮忙,不要把一份读歪的表塞给它当事实。
*/
export function sheetRows(text: string): Array<Record<string, string>> | undefined {
const lines = text.split('\n').filter((l) => l.trim() !== '');
if (lines.length < 2) return undefined;
const header = (lines[0] ?? '').split('\t');
if (header.length < 2) return undefined;
const rows: Array<Record<string, string>> = [];
for (const line of lines.slice(1)) {
const cells = line.split('\t');
// 列数不齐就不认这张表 —— 半张读歪的表比没有更坏
if (cells.length !== header.length) return undefined;
rows.push(Object.fromEntries(header.map((h, i) => [h.trim() === '' ? `列${i + 1}` : h.trim(), (cells[i] ?? '').trim()])));
}
return rows.length === 0 ? undefined : rows;
}
/**
* 起单入参里的附件:凡是读得出规整表的,**在文本旁边再给一份拆好的行**。
* 文本照旧留着(口径要求、注释这类不在表里的东西还得从原文看)。
*/
export function withSheetRows(scope: Record<string, unknown>): Record<string, unknown> {
const list = scope['attachments'];
if (!Array.isArray(list)) return scope;
let changed = false;
const attachments = list.map((raw) => {
if (typeof raw !== 'object' || raw === null) return raw;
const item = raw as Record<string, unknown>;
if (typeof item['text'] !== 'string' || item['rows'] !== undefined) return raw;
const rows = sheetRows(item['text']);
if (rows === undefined) return raw;
changed = true;
/*
* 拆出来了就**把原文换下来**:两份并排会让这一件的体量翻倍,
* 而它进的那一段是按字符截的 —— 塞胖了,拆好的行连同别的字段一起被砍掉
* (实测:加了 rows 之后三轮里那一行照错不误,因为它压根没进提示词)。
* 附件的原文本来就只是这张表;正文里的口径要求另在 `body` 里,不受影响。
*/
const { text, ...rest } = item;
return { ...rest, sheet: `已拆成 ${rows.length} 行(原文 ${String(text).length} 字,拆好的在 rows 里)`, rows };
});
return changed ? { ...scope, attachments } : scope;
}
/**
* **这一步声明要读的那几张表** —— 剧本的 `reads`,由引擎打上 `focus:` 前缀。
*
* 它单独成段、**不参与那把截断刀**:整表段是按字符截的,而截断是盲的 ——
* 砍掉的可能恰是这一步唯一要用的那张(A1 第一步的 `customer.contact` 就这么丢过,
* 老客户当场被当成新客户)。声明了读什么,就一定看得见什么。
*/
function focusPart(scope: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(scope)
.filter(([k]) => k.startsWith('focus:'))
.map(([k, v]) => [k.slice('focus:'.length), v]),
);
}
/** 这一单能调什么 —— 声明 × 目录编译出来的清单 */
export type BusListing = Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
/**
* 工具清单摊成提示词一段:id · 效应 · 说明 · 入参键名。
*
* 只给键名不给完整 Schema:整份 JSON Schema 铺进去会把上下文吃掉一大半,
* 而模型真正需要知道的是「这件工具收哪几个参数」。填错了缝里会校验并如实回报。
*/
export function toolCatalog(tools: BusListing): string {
if (tools.length === 0) return '';
const keysOf = (input: Record<string, unknown>): string => {
const properties = input['properties'];
if (typeof properties !== 'object' || properties === null) return '(无入参)';
const required = new Set(Array.isArray(input['required']) ? (input['required'] as unknown[]).map(String) : []);
return Object.entries(properties as Record<string, unknown>)
.map(([key, raw]) => {
const spec = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const type = String(spec['type'] ?? 'string');
return `${key}:${type}${required.has(key) ? '*' : ''}`;
})
.join(', ');
};
return [
'【可调工具(只有这些;写类调了不会当场生效,登记为动作意向,放行才落地)】',
...tools.map((t) => `- ${t.id}(${t.effect === 'write' ? '写类' : '读类'}):${t.description}\n 入参:${keysOf(t.input)}`),
].join('\n');
}
export function createAgentRunner(deps: AgentRunnerDeps): {
decide(input: {
taskId: TaskId;
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
writable: WritableLedger;
tools: BusListing;
}): Promise<AgentTurn>;
} {
return {
async decide({ taskId, narrative, enabled, scope, writable, tools }) {
// 交集为空即断点 —— 引擎已经算好了,这里只把它讲给模型听
// 断点 = 无路可走:手上还开着活就不算(否则会把没干完的活丢下去等事件)
const breakpoint = enabled.entries.length === 0 && enabled.inProgress.length === 0;
const prompt = [
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
'tool(调工具)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动一项,可省)、',
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
/*
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
* 客户说本轮跳过的那颗料被写进了有效清单,判据正确地拦住,而模型改不动 ——
* 唯一能改的是那个数,改了别的数又不对,13 次拒回、23 拍、234k token 才熔断。
*/
'',
'**写错一行就撤掉它**:`drop`(address 同 write,keys 给那几行的主键,reason 一句话)——',
'撤的只是本单草稿层那一笔,正式版一个字都动不了。行数判据对不上时先看拒回里那份主键名单:',
'多的那一行就在名单里对着,撤掉它比改数字对 —— **数是台账的结论,别为了过判据去改它**。',
'主键要与写进去时一字不差(补个后缀就是另一行),撤不到基座会如实回绝。',
'',
'**一拍连做,别拆拍**:拍是按模型调用计费的 —— 「写记录、写结算、finish、start 下一项、接着写」',
'一口气排进同一串;每多拆一拍就多烧一遍完整上下文。三条排串纪律:',
'· tool 的出参你要下一拍才看得见 —— 要看结果才能定的事,别排在 tool 后面(排了也会被作废);',
'· ask / human / listen / submit 会让本拍挂起 —— 只能是串里最后一个;',
'· 串按序执行,谁被拒就停在谁那儿,前面的已生效。',
'',
'**出参要瘦** —— 你每多写一个字,人就多等一分:',
'· write 的 patch **只带有值的列**:没有值的列一个都别写(草稿是合并语义,不写 = 不动;',
' 写一串 "":"" 只是把回答拖长)。没变的行也别重写。',
'· say 一句话说清即可,不复述台账内容。JSON 紧凑输出,不要缩进排版。',
'',
/*
* **说话的节奏 = 一轮一句**(2026-08-01 用户定)。
* 从前每拍一句,人在对话区看到的是连着五条工序播报;那一段本该由
* 「AI 工作中」的办事条覆盖 —— 埋头干活时不必每动一下就报一次。
*/
'**说话按轮,不按拍**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次。',
'你的 say 只在**要把控制权交出去的那一拍**才会摆到人面前 ——',
'发卡问人(ask)、派工单(human)、摆岔路(decide)、立监听等外部(listen)、提交放行(submit)。',
'别的拍照旧留进工作日志,人看不到,所以:',
'· 埋头办事的拍,say 写给日志看就行,**不必也不该**播报「我这就去写哪张表、收哪一步」;',
'· 到了要找人的那一拍,say 写**这一轮办完了什么、结论是什么、为什么现在需要他** ——',
' 他看到的就这一句加一张卡,前面几拍的过程他不看也不该看。',
'',
'规矩:',
'① **你的成果就是台账里的行**。看懂材料、拆出条目、算出结论之后,用 write 把它们写进台账 ——',
' 不写就等于没做:验收判据读的是台账,出口载荷也从台账推。一次 rows 可以写多行,',
' 一份 BOM 一拍写完,不要一行一拍。写的是**草稿层**,放行前世界上什么都没变。',
' `address` 照「可写台账」里的地址原样抄(形如 `域.对象`,别拆开);',
' 只准写字典列出的地址与列名,编出来的列会被当场拒。**原文没有的值就别写这一列**',
' (空着 = 这一列没有值);数值列(:number/:integer)尤其不能写空串。',
' write 的 `itemId` 是**记账**:填正在干的那一项(它会亮进「进行中」);',
' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。',
' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。',
/*
* **「须你启动」不再是一堵墙**(2026-08-02 拆掉)。
*
* 从前标了人工激活的项要求先发显式 `start`,不发就拒;还为「话没盖住门」
* 修过一轮措辞(话窄一格,模型正好漏在那一格上)。措辞修全之后实测仍熔断:
* A1 一单连拒五拍,五次全是「S3_confirm 要先 start」—— 提示词与拒回都写着,
* 它就是学不稳。那道墙也保护不了任何东西:start 是模型自己的动作,
* 同一串连发即过。现在**动它就算启动**(引擎就地过 Active),
* 「须你启动」只剩它本来的意思:引擎不替你启动、也不给 doneWhen 替人收。
*/
' 标了「须你启动」的项,你**直接动它就算启动**(不必单发 start);',
' 它的分量在另一头:引擎不会替你启动它,也不会自动收它 —— 收它要你自己 finish。',
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
' (写类会被登记为动作意向,放行那一刻才真执行)。',
/*
* ③ 的措辞是一次实测教训(2026-08-02):零输入改造时这里曾写成
* 「执行工单不摆表单……**别再 write 一遍**」—— 一句全局禁令,把 A1 说明书里
* 「人交回确认卡后要写 confirmed_total 记账」顶翻了:模型收到人的答复后
* 不写不收,又觉得这卡没答成,重发同一张卡连拒五拍熔断(用户实测送来的单)。
* **全局提示词只说通用真理**:值怎么处理(哪些基座已替写、哪些要记账)
* 是各活动说明书的事,别在这儿下一刀切的禁令。
*/
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' basis 给出判断依据(键值对数组);执行工单不摆表单字段(填报在工作区的件上)。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的东西在轨迹里,',
' 看到了要照说明书处理(该记账的记账)** —— 别当没答过再发一张。',
/*
* **补全卡(ask)已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)—— 缺信息也是发执行工单:
* 指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。
* 旧输出里的 ask 会被基座原地转成执行工单,但别再用它。
*/
'④ **缺信息要人补 → human(执行工单)**:指导书写清缺哪几样、去哪儿补(工作区的件上),',
' 人补完保存、回卡提交 —— 值由基座写回草稿层,你别再 write 一遍;该记账的记账。',
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},',
' fields:[{key,label,value?,required?}], basis:[{k,v}]}。',
' **能填的先填上**(value),填不出的留空 —— 界面会标「待填写」;',
' 缺了这一格这张档就是残的,就标 required:true。',
'④" **机器判不了的判据 → judge**:验收判据里 L3/L4 那类(「吃不准降 B」「合乎业务常理」)',
' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。',
' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。',
'④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。',
' 执行工单问**事实与作业**(办完、补齐);决策卡问**处置**(往哪走,你选)。',
' 两条路都合法、选哪条是业务判断时,发这一张;别拿执行工单去问「你想怎么办」。',
' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。',
' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。',
' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、',
' 办完再回来,重得不成比例。human 留给真要人办一件事或裁断一次的场合。',
' 缺口在世界侧 → listen(必须给 deadline,ISO-8601)。',
'⑤ **一项干完了要自己宣告**:finish(itemId + rationale)。write / tool 只是干活,',
' 不会自动把那一项收掉 —— 因为一项常要好几个动作才交得出成果(先逐条写记录、',
' 再结算总数;先调工具取判据、再把判据写进台账)。都做完了再 finish。',
'⑥ **每一拍都要带一句 `say`** —— 你是在跟负责人**当面说话**,不是在播报进度。',
' 他正看着这一单:只写台账不说话,他的对话区从头到尾是空的,只看得见「多了一个任务」。',
' 怎么说:',
' · **先说这对这一单意味着什么**,再说你做了什么 —— 他关心的是单子,不是你的步骤;',
' · **一两句、口语**。像同事扭头跟你说一声,不是写工作日报;',
' · **别报内部词**:关键字段 / 次要字段 / 归一化阶段 / 计划项 / 条数 / 写草稿 ——',
' 这些是系统的说法。说料号、交期、价格、客户,说他明天要拿去跟人讲的那些话;',
' · **数字只在有意义时才报**。「三颗料都查到了历史价」有用;「写草稿 3 行」没用;',
' · **有风险、要他拿主意、或者你打算叫人,先说出来** —— 别等他自己从表里看出来;',
' · 别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同。',
' 反例:「已登记3条需求,缺关键字段0条,直接进入归一化阶段」—— 这是报表不是说话。',
' 正例:「三颗料都拆出来了,交期和用量都齐;NE555P 客户没给目标价,我先照历史价估。」',
' `say` 不占额外的拍,与这一拍的行动一起给;局面变了就用 `view` 换一张画面。',
'⑦ 成果已齐才 submit(提交放行,人验收)。submit 可带 `fields` —— 验收报告上',
' **让人当场改**的那几个值:{key,label,ai:"你拟的值",ledger:"域.对象"}。',
' `ai` 是「AI 已填好」的那个值,`ledger` 是他改完落回哪儿;缺一样修订就落不下去。',
'只输出 JSON,形如 {"say":"…","tag":"conclusion","view":"…","actions":[{"kind":"write",…},{"kind":"finish",…}]}。',
'',
`【案例说明书与活动记忆】\n${narrative}`,
'',
`【当下可为(Enabled 集)】\n${
enabled.entries.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`).join('\n') || '(空)'
}`,
enabled.inProgress.length === 0
? ''
: `【进行中(你已开工,还没交出成果)】\n${enabled.inProgress
.map((e) => `- ${e.id} ${e.name}(${e.type})`)
.join('\n')}\n这些项要么接着干(write / tool),要么干完了就 finish —— 别丢下它们去等事件。`,
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
enabled.judgements.length === 0
? ''
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
breakpoint && enabled.inProgress.length === 0
? '【注意】当下可为与进行中都空 —— 这是依赖断点,请调人或立监听。'
: '',
toolCatalog(tools),
ledgerDictionary(writable),
/*
* 任务数据分两半摆,**本单的摆在前面**。
*
* `mine:域.对象` 是这一单自己写下的行;`域.对象` 是正式版全表 ——
* 里面有别单放行过的记录。不分开摆,Agent 会拿别单的数据当自己的结论讲出来
* (实测:一单的交代里说的全是另外两单的分类结果)。
*/
`【本单已写下的行(mine:*)——「我的成果」只看这一份】\n${JSON.stringify(minePart(scope)).slice(0, 3000)}`,
// 这一步声明要读的表:摆在整表段**之前**,且不参与下面那把截断刀
Object.keys(focusPart(scope)).length === 0
? ''
: `【这一步要读的台账(剧本 reads 声明的,已为你取好)】\n${JSON.stringify(focusPart(scope))}`,
/*
* 附件**单独成段,且不参与那把截断刀**。
*
* 它是这一单的料本身 —— 被砍掉就等于让模型闭着眼拆条。
* 表能拆的先替它拆好(`rows`,键就是表头):数制表符是能算的事,
* 交给模型只会数错格子(实测:同一行的品类、制造商、年用量三样一起错位)。
* 这一段与整表段分开摆,正是因为整表段按字符截 —— 混在一起,拆好的行会被砍掉。
*/
attachmentPart(scope),
`【任务数据(域.对象 是全表,含别单已放行的记录 —— 别当自己的)】\n${JSON.stringify(restPart(scope, { withoutAttachments: true })).slice(0, 1800)}`,
]
.filter((line) => line !== '')
.join('\n');
const model = await deps.modelOf(taskId);
try {
/*
* **输出不成形先就地重试,别一抖就叫人**(2026-08-03 用户实测送来的单)。
*
* 关思考的模型偶发把这一拍的输出写坏(多个闭括号那种已由括号栈自修;
* 这次是另一形状)。从前一次没认出就直接发「需要你判断下一步」的兜底卡,
* 还把模型的 JSON 原话整段摆在卡面上 —— 人打开工单看到的是一坨代码
* (用户原话:「卡片里又是乱七八糟的东西」)。
* 现在:第一次没认出 → 原文存进工作日志(内部痕,留档好尸检),
* 附一句「只输出 JSON」就地再问一次;第二次仍不成形才交人,
* 而卡面只说人话(原文在日志里,不上卡)。
*/
let hint = '';
for (let attempt = 1; ; attempt += 1) {
const result = await deps.gateway.generate({
model,
prompt: hint === '' ? prompt : `${prompt}\n\n${hint}`,
outputSchema: TURN_SCHEMA,
// 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟
temperature: 0.2,
/*
* 关思考 —— 三档都实测过(A1 同一单):
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字;
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样;
* 'off':拍 2-4s(十倍差)。
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度:
* 这个架构里判断本就归判定表与说明书,模型只做编排。
*/
reasoning: 'off',
taskId,
label: 'loop-turn',
});
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
const turn = normalizeTurn(result.object, enabled);
if (!turn.unrecognized || attempt >= 2) return turn;
await deps.note?.(taskId, '这一拍的输出没成形(不是合法的行动串),就地重问一次', (result.text ?? '').slice(0, 2000));
hint = '【重要】你上一次的输出不是合法的行动 JSON,整拍作废了。只输出一个 JSON 对象,形如 {"say":"…","actions":[{"kind":"…",…}]},不要输出任何 JSON 之外的文字。';
}
} catch (error) {
/*
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
*
* 这**不是**异常路径,是断点的一种:机器当下决断不了,就该调人 ——
* 「人在环是一种被调用的工具」。抛出去只会让任务挂着空转:
* 推进器每一拍都重试、每一拍都记一行错,预算照烧,负责人却看不到任何东西。
* 世界一步都没碰,交回给人是安全且诚实的落点。
*/
const detail = error instanceof Error ? error.message : String(error);
return {
// 出错也要对人交代一句:不然他只看见工位上多了一张卡,不知道为什么
say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const },
actions: [{
kind: 'human',
itemId: enabled.entries[0]?.id ?? 'breakpoint',
workOrder: 'execution',
card: {
title: '模型这一轮未能决断,转人裁断',
reason: detail,
// 缺口是**依据**不是表单项:摆给人看,别让人逐条填
gaps: enabled.gaps.join('、') || '(无)',
enabled: enabled.entries.map((e) => `${e.id} ${e.name}`).join('、') || '(空)',
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
fields: ['decision'],
},
}],
};
}
},
};
}
/** 认得的行动型 —— 与 ACTION_ITEM_SCHEMA 的 enum 同一份名单 */
const KNOWN_KINDS = new Set([
'start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'human', 'listen', 'finish', 'submit',
'decide',
// 数据录入卡(第五类人类工具):人确认那一下由基座直接写库,不回到 Agent 手里
'entry',
]);
/**
* 一拍的产出 = 一串行动 + 一句交代。
*
* 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、
* 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定);
* 整串一个都不认识才标 `unrecognized` 退化为单张「转人裁断」卡 ——
* 不能让串里一个写坏的项在批中途给人甩一张卡。
* 调用方(decide)看到 `unrecognized` 先就地重问一次,原文进工作日志;卡面不摆 JSON。
*
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
*/
export function normalizeTurn(object: unknown, enabled: EnabledSet): AgentTurn & { unrecognized?: true } {
const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record<string, unknown>;
const text = String(raw['say'] ?? raw['text'] ?? '').trim();
const tag = raw['tag'];
const view = String(raw['view'] ?? '').trim();
const source = Array.isArray(raw['actions']) ? raw['actions'] : [raw];
const recognized = source
.filter((a): a is Record<string, unknown> => typeof a === 'object' && a !== null)
.filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? '')));
const actions =
recognized.length > 0
? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled))
: [normalizeAction(source[0] ?? {}, enabled)];
return {
actions,
...(recognized.length === 0 ? { unrecognized: true as const } : {}),
...(text === ''
? {}
: {
say: {
text,
...(tag === 'guidance' || tag === 'criterion' || tag === 'conclusion' ? { tag } : {}),
...(view === '' ? {} : { view }),
},
}),
};
}
/** 模型出参归一为 AgentAction;不合规一律退化为「立监听等人」而不是乱动世界 */
/**
* 卡面兜底:模型没填全就用局面补。
* 「调用必附上下文」是硬规矩 —— 与其发一张「请处理」,不如把当下可为与缺口摆上去,
* 收卡的人至少知道自己站在哪儿。
*/
/**
* 依据快照的兜底 —— 模型没给 `basis` 时用**当下局面**顶上。
*
* 与 `fillCard` 同一个道理:宁可信息略多,不发一张裸卡。
* 摆的是这一步是什么、当下可为哪些、还缺什么 —— 都是人判断时确实用得上的,
* 不是为了凑数把随便什么塞进去。
*/
function basisFromScene(enabled: EnabledSet, itemId: string): ReadonlyArray<{ k: string; v: string }> {
const item = [...enabled.entries, ...enabled.inProgress].find((e) => e.id === itemId);
return [
{ k: '这一步', v: item?.name ?? itemId },
{ k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' },
{ k: '目标缺口', v: enabled.gaps.join('、') || '(无)' },
];
}
function fillCard(
card: Record<string, unknown>,
enabled: EnabledSet,
itemId: string,
): Record<string, unknown> {
const item = enabled.entries.find((e) => e.id === itemId);
const title = String(card['title'] ?? '').trim();
const ask = String(card['ask'] ?? '').trim();
return {
...card,
title: title === '' ? `请处理:${item?.name ?? itemId}` : title,
ask: ask === '' ? '模型未给出具体问题,请按当下可为与缺口自行判断下一步。' : ask,
basis: Array.isArray(card['basis'])
? card['basis']
: [
{ k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' },
{ k: '目标缺口', v: enabled.gaps.join('、') || '(无)' },
],
};
}
/**
* 验收报告的**可修订字段**归一 —— 只收有 key 的;`ai` 与 `ledger` 原样带过去。
* 少了 `ai` 就没有「已填好」可改;少了 `ledger`,人改了也落不回台账。
* 这里不替它补:补出来的地址一定是猜的,而猜错会把值写到别的对象上。
*/
function normalizeRevisable(raw: unknown): Array<{ key: string; label: string; ai?: string; ledger?: string }> {
if (!Array.isArray(raw)) return [];
const out: Array<{ key: string; label: string; ai?: string; ledger?: string }> = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const f = item as Record<string, unknown>;
const key = String(f['key'] ?? '').trim();
if (key === '') continue;
const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']);
const ledger = String(f['ledger'] ?? '').trim();
out.push({
key,
label: String(f['label'] ?? key),
...(ai === undefined ? {} : { ai }),
...(ledger === '' ? {} : { ledger }),
});
}
return out;
}
/**
* 决策卡的选项归一 —— 只收有 `value` 的;`label` 缺省用 value 顶上。
* `value` 是回传时认的那个值:没有它,人点了也对不上是哪条路。
*/
function normalizeOptions(raw: unknown): ReadonlyArray<{
value: string;
label: string;
detail?: string;
consequence?: string;
recommended?: boolean;
}> {
if (!Array.isArray(raw)) return [];
return raw
.filter((o): o is Record<string, unknown> => typeof o === 'object' && o !== null && 'value' in o)
.map((o) => ({
value: String(o['value']),
label: String(o['label'] ?? o['value']),
...(o['detail'] === undefined ? {} : { detail: String(o['detail']) }),
...(o['consequence'] === undefined ? {} : { consequence: String(o['consequence']) }),
...(o['recommended'] === undefined ? {} : { recommended: o['recommended'] === true }),
}));
}
/**
* 补全卡的格子归一 —— 只收有 key 的;`label` 缺省用 key 顶上(宁可显示字段名,不显示空白)。
* 一个都收不到就是空数组,引擎那边会拒这一拍:「人不知道该填什么」比发一张空表单好。
*/
function normalizeFields(raw: unknown): Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> {
if (!Array.isArray(raw)) return [];
const out: Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> = [];
for (const item of raw) {
if (typeof item === 'string' && item !== '') {
out.push({ key: item, label: item });
continue;
}
if (typeof item !== 'object' || item === null) continue;
const f = item as Record<string, unknown>;
const key = String(f['key'] ?? '').trim();
if (key === '') continue;
const hint = String(f['hint'] ?? f['placeholder'] ?? '').trim();
/*
* **预填值要跟着过来** —— 这一行是它一路上最后一个、也是最隐蔽的丢失点。
*
* 这个函数**逐字段重建**对象(不是原样透传),所以它没挑的键就等于不存在。
* 实测查了一路:模型出参 schema 有 value、系统提示词的形状有 value、
* 活动剧本给了带 value 的骨架、卡片契约有 value、engines 也搬了 value ——
* 五处都对,而人拿到的格子还是空的,因为这里把它剥掉了。
* 逐字段重建的函数每加一个键都得在这儿补一笔,这是它的代价。
*/
const value = String(f['value'] ?? '').trim();
out.push({
key,
label: String(f['label'] ?? key),
...(hint === '' ? {} : { hint }),
...(value === '' ? {} : { value }),
// 数据录入卡用它:必填空着不让确认(别的卡型忽略这个键)
...(f['required'] === true ? { required: true } : {}),
});
}
return out;
}
/**
* 台账地址归一 —— 模型会用好几种写法说同一件事。
*
* 实测:字典里写的是 `quote_intake.record`(一个 token),而它会把整串塞进 `object`,
* 或者干脆拆成空的 domain + object。宽进一点不是纵容:地址对不对,装配层那道门会验;
* 这里只负责把「同一个意思的几种写法」收成一种,省掉一整拍无谓的驳回。
*/
function addressOf(raw: Record<string, unknown>): string {
const address = String(raw['address'] ?? '').trim();
if (address !== '') return address;
const domain = String(raw['domain'] ?? '').trim();
const object = String(raw['object'] ?? '').trim();
// 整串塞进了其中一格
if (domain.includes('.')) return domain;
if (object.includes('.')) return object;
return domain === '' || object === '' ? `${domain}${object}` : `${domain}.${object}`;
}
/**
* 写行归一 —— 只收 `{key, patch}` 都是对象的行。
* 形状不对的行**丢掉而不是猜**:猜错会往台账里落一行谁也说不清来路的数据;
* 全丢完 rows 就是空,引擎那边会拒这一拍并把理由喂回来,比落错数干净。
*/
function normalizeRows(raw: unknown): Array<{ key: Record<string, string | number>; patch: Record<string, unknown> }> {
if (!Array.isArray(raw)) return [];
const rows: Array<{ key: Record<string, string | number>; patch: Record<string, unknown> }> = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const row = item as Record<string, unknown>;
const key = row['key'];
const patch = row['patch'];
if (typeof key !== 'object' || key === null || Array.isArray(key)) continue;
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) continue;
const typedKey: Record<string, string | number> = {};
for (const [k, v] of Object.entries(key as Record<string, unknown>)) {
typedKey[k] = typeof v === 'number' ? v : String(v);
}
rows.push({ key: typedKey, patch: patch as Record<string, unknown> });
}
return rows;
}
/**
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
*/
export function normalizeAction(object: unknown, enabled: EnabledSet): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>;
/*
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
* 整个行动因此判成不合规,白发一张「请判断下一步」的卡给人。
* 同义词宽进不是纵容:行动型本身仍由 enum 框死,合不合规照验;
* 这里只是不让一个词的叫法差异吃掉一整拍。
*/
const kind = String(raw['kind'] ?? raw['action'] ?? '');
/*
* 项号兜底连**进行中**的项一起看(2026-08-23):从前只兜「当下可为」的第一项,
* 而依赖断点那一拍它恰恰是空的 —— 兜出来的是空串,兜底卡于是带着空项号发出去,
* 被引擎自己的门拦下(最后的活路自己被砌了墙)。引擎那一侧已改成「挂不上照发」,
* 这里再挑得准一点:在场的项优先挂上,记账才对得上。
*/
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? enabled.inProgress[0]?.id ?? '');
switch (kind) {
case 'start':
case 'plan':
return { kind: kind as 'start' | 'plan', itemId };
case 'disable':
return { kind: 'disable', itemId, reason: String(raw['reason'] ?? '此单不走这条') };
case 'judge':
return {
kind: 'judge',
criterion: String(raw['criterion'] ?? ''),
satisfied: raw['satisfied'] === true,
rationale: String(raw['rationale'] ?? ''),
};
case 'tool':
return {
kind: 'tool',
itemId,
tool: String(raw['tool'] ?? ''),
input: (raw['input'] ?? {}) as Record<string, unknown>,
};
case 'write':
return { kind: 'write', itemId, address: addressOf(raw), rows: normalizeRows(raw['rows']) };
/*
* 撤草稿 —— 主键那一份照收,别替它归一:`DAG…` 与 `DAG…-R0` 就是两行,
* 替它「猜哪个才是」等于替它决定撤掉哪一行数据。撤不到基座会如实回绝。
*/
case 'drop':
return {
kind: 'drop',
itemId,
address: addressOf(raw),
keys: Array.isArray(raw['keys'])
? raw['keys'].filter((k): k is Record<string, string | number> => typeof k === 'object' && k !== null)
: [],
reason: String(raw['reason'] ?? raw['rationale'] ?? '写错了,撤掉'),
};
case 'ask': {
/*
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)—— 「问人补信息」不再是
* 一种单独的卡:发一张零输入的执行工单,指导书写清缺什么、
* 人在工作区的件上补(保存即落草稿层)或办完确认。
*
* 这里做的是**归一而不是拒**:旧说明书、旧模型输出里的 `ask` 照收,
* 原地转成执行工单 —— 拒回只会让模型换着花样再发一遍。
* `fields` 不上卡(执行卡零输入),但字段名并进指导书,
* 人知道要补的是哪几样、补在件上。
*/
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
const asked = String(raw['ask'] ?? nested['ask'] ?? '').trim();
const wanted = normalizeFields(raw['fields'] ?? nested['fields'])
.map((f) => ((f.label ?? '') === '' ? f.key : (f.label ?? '')))
.filter((x) => x !== '');
const basis = Array.isArray(raw['basis'])
? raw['basis']
.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) }))
: [];
return {
kind: 'human',
itemId,
workOrder: 'execution',
card: {
title: asked === '' ? '请补齐这一单缺的信息' : asked.slice(0, 60),
ask: asked === '' ? '缺的信息见下面的依据:补在工作区的件上,补完回这张卡提交。' : asked,
...(wanted.length === 0
? {}
: { steps: [`要补的是:${wanted.join('、')} —— 在工作区的件上就地填,保存后回这张卡提交;确实拿不到的,备注里写明缘由`] }),
basis,
},
};
}
case 'entry': {
/*
* 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。
* `basis` 缺了照样用当下局面补:裸卡基座拒发,而少写一个字段不该吃掉整拍。
*/
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
const given = Array.isArray(raw['basis'])
? raw['basis']
.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) }))
: [];
const keyRaw = raw['entryKey'] ?? raw['key'] ?? nested['entryKey'] ?? nested['key'];
const key: Record<string, string> = {};
if (typeof keyRaw === 'object' && keyRaw !== null) {
for (const [k, v] of Object.entries(keyRaw as Record<string, unknown>)) key[k] = String(v);
}
/*
* **同一次确认还要落的另几张表**(2026-08-02):建一位新客户 = 公司档 + 联系人档。
* 形状与主段一样(title / ledger / key / fields),缺哪一样就整段丢掉 ——
* 半张段落进卡面只会让人对着一组填不下去的格子。
*/
const alsoRaw = raw['also'] ?? nested['also'];
const also = (Array.isArray(alsoRaw) ? alsoRaw : [])
.filter((g): g is Record<string, unknown> => typeof g === 'object' && g !== null)
.map((g) => {
const k: Record<string, string> = {};
const kr = g['key'] ?? g['entryKey'];
if (typeof kr === 'object' && kr !== null) {
for (const [a, b] of Object.entries(kr as Record<string, unknown>)) k[a] = String(b);
}
return {
title: String(g['title'] ?? ''),
ledger: String(g['ledger'] ?? ''),
key: k,
fields: normalizeFields(g['fields']),
};
})
.filter((g) => g.ledger !== '' && g.title !== '' && g.fields.length > 0);
return {
kind: 'entry',
itemId,
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
ledger: String(raw['ledger'] ?? nested['ledger'] ?? ''),
key,
fields: normalizeFields(raw['fields'] ?? nested['fields']),
...(also.length === 0 ? {} : { also }),
basis: given.length > 0 ? given : basisFromScene(enabled, itemId),
};
}
case 'decide': {
// 与补全卡同款的宽进:选项落在顶层或 `card` 里都认(位置差异不该吃掉一整拍)
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
const given = Array.isArray(raw['basis'])
? raw['basis']
.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) }))
: [];
/*
* **依据缺了就用当下局面补**,不要空手发出去。
*
* 「调用必附上下文,裸调用不得发出」是基座的硬规矩(AM 06):`basis` 为空即拒发。
* 而模型很容易只写 ask + options 就交卷 —— 实测:连拒 6 拍直接熔断,
* 一单就此停在那儿等人,而它想问的那句话本身完全合理。
* 拒得对,但让一个「少写一个字段」的失误吃掉整单是不划算的:
* 执行工单那一路早就这么办了(`fillCard`),决策卡照同一个规矩来。
*/
const basis = given.length > 0 ? given : basisFromScene(enabled, itemId);
return {
kind: 'decide',
itemId,
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
options: normalizeOptions(raw['options'] ?? nested['options']),
...(raw['allowCustom'] === undefined ? {} : { allowCustom: raw['allowCustom'] === true }),
basis,
};
}
case 'human':
return {
kind: 'human',
itemId,
// 卡面缺项用**当下局面**补全 —— 宁可信息略多,不发一张空卡
card: fillCard((raw['card'] ?? {}) as Record<string, unknown>, enabled, itemId),
workOrder: raw['workOrder'] === 'acceptance' ? 'acceptance' : 'execution',
};
case 'listen':
return {
kind: 'listen',
itemId,
eventType: raw['eventType'] === 'time' ? 'time' : 'data',
keyFilter: (raw['keyFilter'] ?? {}) as Record<string, string>,
// 时限必配 —— 模型没给就按三天兜底,绝不留幽灵等待
deadline: typeof raw['deadline'] === 'string' && /^P/.test(raw['deadline']) ? raw['deadline'] : 'P3D',
};
case 'finish':
return { kind: 'finish', itemId, rationale: String(raw['rationale'] ?? '这一项的成果已交出') };
case 'submit': {
// 验收报告的可修订字段:顶层 fields 或 card.fields 都认(见补全卡那一处的同款注释)
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
const revisable = normalizeRevisable(raw['fields'] ?? nested['fields']);
return {
kind: 'submit',
rationale: String(raw['rationale'] ?? '判据已收敛'),
...(revisable.length === 0 ? {} : { fields: revisable }),
};
}
default:
/*
* 看不懂就等人,不擅自触达世界。
*
* 卡面**只说人话,不摆模型的 JSON 原话**(2026-08-03 用户实测:原话上卡,
* 人打开工单看到的是一坨代码 ——「卡片里又是乱七八糟的东西」)。
* 原始证据仍然要留:decide 那一侧在重问之前已把原文存进工作日志(内部痕),
* 想分辨是模型跑偏、行动型少了一种、还是提示词没交代清楚,去日志里看。
*/
return {
kind: 'human',
itemId,
card: {
title: '需要你判断下一步',
reason: '模型这一拍没给出合规的行动,就地重问一次仍未成形;它的原话在这一单的工作日志里,不摆到卡上',
kindSeen: kind === '' ? '(未给 kind)' : kind,
ask: '请判断下一步:接着走哪一项,或直接办完当前这一步让环继续。',
fields: ['decision'],
},
workOrder: 'execution',
};
}
}