/** * 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; /** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */ readonly onUsage: (taskId: TaskId, tokens: number) => Promise; }; /** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */ 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): Record { return Object.fromEntries(Object.entries(scope).filter(([k]) => k.startsWith('mine:'))); } /** * 其余作用域:**摊平字段与起单入参在前,`域.对象` 整表殿后**。 * * 顺序就是生死:这一段进提示词前会按字符截断,而整表里躺着**历史所有已放行单**的行, * 只会随单量无限长 —— 排在前面,截断刀落下时被砍掉的是这一单自己的摊平字段(实测 * 入参膨胀到 13k token 的那一拍就是这么来的)。整表殿后,砍也先砍旧账。 */ function restPart(scope: Record, options?: { withoutAttachments?: boolean }): Record { 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 { 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> | 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> = []; 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): Record { 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; 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): Record { 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; effect: 'read' | 'write' }>; /** * 工具清单摊成提示词一段:id · 效应 · 说明 · 入参键名。 * * 只给键名不给完整 Schema:整份 JSON Schema 铺进去会把上下文吃掉一大半, * 而模型真正需要知道的是「这件工具收哪几个参数」。填错了缝里会校验并如实回报。 */ export function toolCatalog(tools: BusListing): string { if (tools.length === 0) return ''; const keysOf = (input: Record): 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) .map(([key, raw]) => { const spec = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; 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; writable: WritableLedger; tools: BusListing; }): Promise; } { 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。', /* * **话说的范围要盖住门拦的范围**(2026-08-02 实测逼出来的)。 * * 从前这一句写的是「标了「须你启动」的项要先 start **再调工具**」—— * 只说了调工具。而拦截那一侧(`readyToWork`)管的是 tool / ask / human / * decide / entry / finish 一整片。模型那一拍要发的是 `human`(派工单), * 照字面读这条规矩管不着它,于是漏了 start,撞上 * 「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。 * * **门拦住的事和话说的事必须是同一件** —— 话窄一格,模型就正好漏在那一格上。 * 例外只有 write:它的 itemId 只是记账,挂不上照写不拦(见 turn-runner 的注释)。 */ ' 标了「须你启动」的项,**动它之前先 start** —— 调工具、发卡(ask / human / decide / entry)、', ' 宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。', ' 只有 write 例外:它的 itemId 只是记账,挂不上也照写。', '② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——', ' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,', ' 却发了一张「请查这三个 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; 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 => 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, enabled: EnabledSet, itemId: string, ): Record { 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; 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 => 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; 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 { 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; patch: Record }> { if (!Array.isArray(raw)) return []; const rows: Array<{ key: Record; patch: Record }> = []; for (const item of raw) { if (typeof item !== 'object' || item === null) continue; const row = item as Record; 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 = {}; for (const [k, v] of Object.entries(key as Record)) { typedKey[k] = typeof v === 'number' ? v : String(v); } rows.push({ key: typedKey, patch: patch as Record }); } return rows; } /** * 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。 * 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。 */ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction { const raw = (object ?? {}) as Record; /* * `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, }; 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 => 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; 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; 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 = {}; if (typeof keyRaw === 'object' && keyRaw !== null) { for (const [k, v] of Object.entries(keyRaw as Record)) 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 => typeof g === 'object' && g !== null) .map((g) => { const k: Record = {}; const kr = g['key'] ?? g['entryKey']; if (typeof kr === 'object' && kr !== null) { for (const [a, b] of Object.entries(kr as Record)) 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; 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, 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, // 时限必配 —— 模型没给就按三天兜底,绝不留幽灵等待 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; 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', }; } }