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-TMP-REPO/apps/api/src/app/agent-runner.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

1029 lines
54 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' },
/*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
*
* 执行工单本来就收 `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>;
};
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
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(调工具)/ ask(补全卡)/ 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,',
' 标了「须你启动」的项要先 start 再调工具。',
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
' (写类会被登记为动作意向,放行那一刻才真执行)。',
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,',
' 看到了要用 write 落进台账** —— 系统不会替你写。',
'④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。',
' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),',
' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。',
/*
* 形状里**必须带 `value`** —— 这一条实测栽过:
* 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架;
* 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 ——
* 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。
* 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。
*/
' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。',
' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。',
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;',
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
' 形状:{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 {
const result = await deps.gateway.generate({
model,
prompt,
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);
return normalizeTurn(result.object, enabled, result.text);
} 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 定);
* 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)——
* 不能让串里一个写坏的项在批中途给人甩一张卡。
*
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
*/
export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn {
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, rawText))
: [normalizeAction(source[0] ?? {}, enabled, rawText)];
return {
actions,
...(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, rawText?: string): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>;
/*
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
* 整个行动因此判成不合规,白发一张「请判断下一步」的卡给人。
* 同义词宽进不是纵容:行动型本身仍由 enum 框死,合不合规照验;
* 这里只是不让一个词的叫法差异吃掉一整拍。
*/
const kind = String(raw['kind'] ?? raw['action'] ?? '');
const itemId = String(raw['itemId'] ?? enabled.entries[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': {
/*
* 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 ——
* 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。
* 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。
*/
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
return {
kind: 'ask',
itemId,
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
fields: normalizeFields(raw['fields'] ?? nested['fields']),
...(Array.isArray(raw['basis'])
? {
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) })),
}
: {}),
};
}
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:
/*
* 看不懂就等人,不擅自触达世界。
*
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。
*/
return {
kind: 'human',
itemId,
card: {
title: '需要你判断下一步',
reason: '模型未给出合规的行动',
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
kindSeen: kind === '' ? '(未给 kind)' : kind,
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
fields: ['decision'],
},
workOrder: 'execution',
};
}
}