A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
+124 -76
View File
@@ -54,6 +54,14 @@ const ACTION_ITEM_SCHEMA = {
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: '收口标准:办到什么程度算完,一句话' },
/*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
*
@@ -216,6 +224,12 @@ export type AgentRunnerDeps = {
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>;
};
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
@@ -395,7 +409,7 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
const prompt = [
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、',
'tool(调工具)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动一项,可省)、',
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
/*
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
@@ -443,42 +457,42 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。',
' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。',
/*
* **话说的范围要盖住门拦的范围**(2026-08-02 实测逼出来的)。
* **「须你启动」不再是一堵墙**(2026-08-02 拆掉)。
*
* 从前这一句写的是「标了「须你启动」的项要先 start **再调工具**」——
* 只说了调工具。而拦截那一侧(`readyToWork`)管的是 tool / ask / human /
* decide / entry / finish 一整片。模型那一拍要发的是 `human`(派工单),
* 照字面读这条规矩管不着它,于是漏了 start,撞上
* 「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。
*
* **门拦住的事和话说的事必须是同一件** —— 话窄一格,模型就正好漏在那一格上。
* 例外只有 write:它的 itemId 只是记账,挂不上照写不拦(见 turn-runner 的注释)。
* 从前标了人工激活的项要求先发显式 `start`,不发就拒;还为「话没盖住门」
* 修过一轮措辞(话窄一格,模型正好漏在那一格上)。措辞修全之后实测仍熔断:
* A1 一单连拒五拍,五次全是「S3_confirm 要先 start」—— 提示词与拒回都写着,
* 它就是学不稳。那道墙也保护不了任何东西:start 是模型自己的动作,
* 同一串连发即过。现在**动它就算启动**(引擎就地过 Active),
* 「须你启动」只剩它本来的意思:引擎不替你启动、也不给 doneWhen 替人收。
*/
' 标了「须你启动」的项,**动它之前先 start** —— 调工具、发卡(ask / human / decide / entry)、',
' 宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。',
' 只有 write 例外:它的 itemId 只是记账,挂不上也照写。',
' 标了「须你启动」的项,你**直接动它就算启动**(不必单发 start);',
' 它的分量在另一头:引擎不会替你启动它,也不会自动收它 —— 收它要你自己 finish。',
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
' (写类会被登记为动作意向,放行那一刻才真执行)。',
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,',
' 看到了要用 write 落进台账** —— 系统不会替你写。',
'④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。',
' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),',
' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。',
/*
* 形状里**必须带 `value`** —— 这一条实测栽过:
* 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架;
* 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 ——
* 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。
* 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。
* ③ 的措辞是一次实测教训(2026-08-02):零输入改造时这里曾写成
* 「执行工单不摆表单……**别再 write 一遍**」—— 一句全局禁令,把 A1 说明书里
* 「人交回确认卡后要写 confirmed_total 记账」顶翻了:模型收到人的答复后
* 不写不收,又觉得这卡没答成,重发同一张卡连拒五拍熔断(用户实测送来的单)。
* **全局提示词只说通用真理**:值怎么处理(哪些基座已替写、哪些要记账)
* 是各活动说明书的事,别在这儿下一刀切的禁令。
*/
' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。',
' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。',
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' basis 给出判断依据(键值对数组);执行工单不摆表单字段(填报在工作区的件上)。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的东西在轨迹里,',
' 看到了要照说明书处理(该记账的记账)** —— 别当没答过再发一张。',
/*
* **补全卡(ask)已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)—— 缺信息也是发执行工单:
* 指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。
* 旧输出里的 ask 会被基座原地转成执行工单,但别再用它。
*/
'④ **缺信息要人补 → human(执行工单)**:指导书写清缺哪几样、去哪儿补(工作区的件上),',
' 人补完保存、回卡提交 —— 值由基座写回草稿层,你别再 write 一遍;该记账的记账。',
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;',
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},',
@@ -489,8 +503,8 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。',
' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。',
'④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。',
' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。',
' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。',
' 执行工单问**事实与作业**(办完、补齐);决策卡问**处置**(往哪走,你选)。',
' 两条路都合法、选哪条是业务判断时,发这一张;别拿执行工单去问「你想怎么办」。',
' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。',
' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。',
' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、',
@@ -564,27 +578,44 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
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);
/*
* **输出不成形先就地重试,别一抖就叫人**(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) {
/*
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
@@ -631,13 +662,14 @@ const KNOWN_KINDS = new Set([
*
* 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、
* 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定);
* 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)——
* 整串一个都不认识才标 `unrecognized` 退化为单张「转人裁断」卡 ——
* 不能让串里一个写坏的项在批中途给人甩一张卡。
* 调用方(decide)看到 `unrecognized` 先就地重问一次,原文进工作日志;卡面不摆 JSON。
*
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
*/
export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn {
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'];
@@ -649,11 +681,12 @@ export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: st
.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)];
? 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 === ''
? {}
: {
@@ -842,7 +875,7 @@ function normalizeRows(raw: unknown): Array<{ key: Record<string, string | numbe
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
*/
export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction {
export function normalizeAction(object: unknown, enabled: EnabledSet): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>;
/*
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
@@ -892,26 +925,41 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
};
case 'ask': {
/*
* 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 ——
* 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。
* 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。
* **补全卡已全局退役**(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: 'ask',
kind: 'human',
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) })),
}
: {}),
workOrder: 'execution',
card: {
title: asked === '' ? '请补齐这一单缺的信息' : asked.slice(0, 60),
ask: asked === '' ? '缺的信息见下面的依据:补在工作区的件上,补完回这张卡提交。' : asked,
...(wanted.length === 0
? {}
: { steps: [`要补的是:${wanted.join('、')} —— 在工作区的件上就地填,保存后回这张卡提交;确实拿不到的,备注里写明缘由`] }),
basis,
},
};
}
case 'entry': {
/*
* 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。
@@ -1021,19 +1069,19 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
/*
* 看不懂就等人,不擅自触达世界。
*
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。
* 卡面**只说人话,不摆模型的 JSON 原话**(2026-08-03 用户实测:原话上卡,
* 人打开工单看到的是一坨代码 ——「卡片里又是乱七八糟的东西」)。
* 原始证据仍然要留:decide 那一侧在重问之前已把原文存进工作日志(内部痕),
* 想分辨是模型跑偏、行动型少了一种、还是提示词没交代清楚,去日志里看。
*/
return {
kind: 'human',
itemId,
card: {
title: '需要你判断下一步',
reason: '模型未给出合规的行动',
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
reason: '模型这一拍没给出合规的行动,就地重问一次仍未成形;它的原话在这一单的工作日志里,不摆到卡上',
kindSeen: kind === '' ? '(未给 kind)' : kind,
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
ask: '请判断下一步:接着走哪一项,或直接办完当前这一步让环继续。',
fields: ['decision'],
},
workOrder: 'execution',