人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 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
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -23,6 +23,11 @@
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
近变(2026-07-30):`list()` **不再列出全部版本都已卸载的活动**,`uninstall` 同时置
`enabled=false`。卸载只把版本状态置 `uninstalled`(版本行留着,在途任务按启动版跑完),
但活动库照旧摆着它就是撒谎 —— 实测:被真活动包顶替的槽位卸载后仍在架上,
与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
不变量(OS 11.2 / 11.3,AM 11.3):
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
@@ -0,0 +1,271 @@
/**
* 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。
*
* 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了
* 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced`
* / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假,
* 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门,
* 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。
*/
import { describe, expect, it } from 'vitest';
import type { ActivityManifest } from '@contracts';
/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */
type PredicateInput = Parameters<typeof acceptanceLevelFindings>[0];
type LedgerTable = ActivityManifest['declarations']['ledger'][number];
type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number];
import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts';
/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */
function manifest(over: {
predicate?: string | undefined;
level?: 'L1' | 'L2' | 'L3';
fields?: string[];
caseItems?: string[];
inputProps?: string[];
}): PredicateInput {
const fields = over.fields ?? ['line_total'];
const caseItems = over.caseItems ?? [];
const inputProps = over.inputProps ?? [];
return {
contract: {
input: {
data: {
type: 'object',
properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])),
},
},
acceptanceCriteria: [
{
key: 'k',
label: '测试判据',
level: over.level ?? 'L2',
...(over.predicate === undefined ? {} : { predicate: over.predicate }),
},
],
},
declarations: {
ledger: [
{
domain: 'd',
object: 'record',
kind: 'record' as const,
caseKey: 'rfq_ref',
label: '表',
fields: fields.map((name) => ({
name,
type: 'integer' as const,
primaryKey: false,
masked: false,
})),
},
],
},
assets: {
businessModel: {
cmmnXml: caseItems.map((id) => `<caseFileItem id="${id}" name="x"/>`).join(''),
},
},
};
}
const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code);
describe('判据谓词的名字必须绑得上', () => {
it('台账有这一列 —— 放行', () => {
expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]);
});
it('台账没有这个名字 —— 拒装', () => {
const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' }));
expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound');
// 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜
expect(found[0]?.message).toContain('candidatesConfirmed');
});
it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => {
expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]);
});
it('起单入参也算绑得上', () => {
expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]);
});
it('两个名字里错一个,也要报出那一个', () => {
const found = acceptanceLevelFindings(
manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }),
);
expect(found).toHaveLength(1);
expect(found[0]?.message).toContain('type_c_total');
});
it('保留字与字面量不当作台账名', () => {
expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]);
});
it('字符串字面量里的词不当作台账名', () => {
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
});
it('L3 不机检 —— 没谓词也不报', () => {
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
});
it('L2 没谓词照旧报等级谎报,且不误报绑定', () => {
const found = codes(manifest({ level: 'L2', predicate: undefined }));
expect(found).toEqual(['acceptance-level-misreported']);
});
});
/**
* 必交付物必须给产出地址 —— 不给的话放行门永远拒。
*
* 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找,
* 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。
* A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。
*/
import { deliverableAddressFindings } from '../internal/domain/static-audit.ts';
type DeliverableInput = Parameters<typeof deliverableAddressFindings>[0];
const LEDGER: LedgerTable[] = [
{ domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] },
];
function withDeliverables(items: Deliverable[]): DeliverableInput {
return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } };
}
describe('必交付物的产出地址', () => {
it('地址指向真实台账表 —— 放行', () => {
const found = deliverableAddressFindings(
withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]),
);
expect(found).toEqual([]);
});
it('没给地址 —— 拒装', () => {
const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }]));
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
expect(found[0]?.message).toContain('对标表');
});
it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => {
const found = deliverableAddressFindings(
withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]),
);
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
expect(found[0]?.message).toContain('quote_alternative.benchmark');
});
it('多件里错一件,只报那一件', () => {
const found = deliverableAddressFindings(
withDeliverables([
{ key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' },
{ key: 'b', label: '初筛清单' },
]),
);
expect(found).toHaveLength(1);
expect(found[0]?.message).toContain('初筛清单');
});
});
/**
* 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。
*
* A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。
* 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
* 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。
* **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。
*/
import { ledgerKindFindings } from '../internal/domain/static-audit.ts';
type LedgerInput = Parameters<typeof ledgerKindFindings>[0];
type Kind = 'record' | 'master' | 'derived';
function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput {
const cols = over.cols ?? ['rfq_ref', 'pn'];
const pk = over.pk ?? ['rfq_ref', 'pn'];
return {
declarations: {
ledger: [
{
domain: 'd',
object: 'o',
label: '表',
kind: over.kind ?? 'record',
...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }),
fields: cols.map((name) => ({
name,
type: 'text' as const,
primaryKey: pk.includes(name),
masked: false,
})),
},
],
},
};
}
const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code);
describe('台账性质', () => {
it('单据:指明单标识且它在主键里 —— 放行', () => {
expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]);
});
/* 这一条就是 A4 那次数据丢失的机检形态 */
it('单据没指明单标识 —— 拒装', () => {
const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] }));
expect(found.map((f) => f.code)).toContain('ledger-kind-invalid');
expect(found[0]?.message).toContain('caseKey');
});
it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => {
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] }));
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
expect(found[0]?.message).toContain('不是主键的一部分');
});
it('单标识不在自己字段里 —— 拒装', () => {
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' }));
expect(found[0]?.message).toContain('不在它自己的字段里');
});
it('主数据:按业务实体分行,不要单标识 —— 放行', () => {
expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]);
});
it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => {
const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' }));
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
expect(found[0]?.message).toContain('归错了类');
});
it('派生:按周期分行,不要单标识 —— 放行', () => {
expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]);
});
it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => {
const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] }));
expect(found[0]?.message).toContain('主键列');
});
});
/**
* 行数读数 `域_对象_rows` —— 基座数出来的那一边。
*
* 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报,
* 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
* 派单数与三类条数同源同错,`2 = 2` 顺利过关)。
* 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。
*/
describe('行数读数认得出,写错的照旧拒装', () => {
it('声明过的表 → `域_对象_rows` 绑得上', () => {
expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]);
});
it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => {
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
});
});
@@ -32,8 +32,11 @@ export type {
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
export { createRegistryService } from './internal/usecase/registry-service.ts';
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
export {
acceptanceLevelFindings,
deliverableAddressFindings,
ledgerKindFindings,
auditPassed,
danglingListeners,
loopFindings,
@@ -28,6 +28,26 @@ export type ExternalChecks = {
readonly sdkCompatible: boolean;
};
/**
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
*/
export type AcceptanceAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
};
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
readonly assets: {
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
};
};
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
export type DeliverableAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
};
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
@@ -62,8 +82,9 @@ export function danglingListeners(manifest: ActivityManifest): StaticAuditFindin
}
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const declared = boundNames(manifest);
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
const at = `contract.acceptanceCriteria[${index}]`;
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
@@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit
at,
});
}
/*
* **谓词里的名字必须在台账上**(2026-07-31 立)。
*
* A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 ——
* S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**:
* 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。
* A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。
*
* 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸,
* 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。
*/
for (const name of predicateNames(criterion.predicate)) {
if (declared.has(name)) continue;
out.push({
code: 'acceptance-predicate-unbound',
severity: 'error',
message:
`判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` +
'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' +
'把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。',
at,
});
}
}
return out;
}
/**
* 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。
*
* 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 ——
* 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键,
* 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。
* 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。
*/
export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`));
for (const [index, item] of manifest.contract.requiredDeliverables.entries()) {
const at = `contract.requiredDeliverables[${index}]`;
if (item.ledger === undefined || item.ledger.trim() === '') {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` +
'预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' +
'这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。',
at,
});
continue;
}
if (!tables.has(item.ledger)) {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` +
`声明过的是:${[...tables].join('、') || '(一张都没有)'}`,
at,
});
}
}
return out;
}
/**
* 台账对象的性质要**核得住**(2026-07-31 立)。
*
* 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份,
* 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。
* 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record`
* 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。**
*
* 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条:
* · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话);
* · `master` 不该有单标识(有了说明它其实是单据,归错类了);
* · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。
*/
export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, table] of manifest.declarations.ledger.entries()) {
const at = `declarations.ledger[${index}]`;
const where = `${table.domain}.${table.object}`;
const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name);
const names = new Set(table.fields.map((f) => f.name));
if (pk.length === 0) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`,
at,
});
}
if (table.kind === 'record') {
if (table.caseKey === undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` +
'主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' +
'指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。',
at,
});
} else if (!names.has(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`,
at,
});
} else if (!pk.includes(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` +
'单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。',
at,
});
}
}
if (table.kind !== 'record' && table.caseKey !== undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` +
'跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。',
at,
});
}
}
return out;
}
/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。
* 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字;
* 带点的取根名(`a.b` 的绑定看 `a`)。
*/
function predicateNames(predicate: string | undefined): string[] {
if (predicate === undefined || predicate.trim() === '') return [];
const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? [];
const out = new Set<string>();
for (const raw of found) {
const root = raw.split('.')[0] ?? raw;
if (SFEEL_WORDS.has(root.toLowerCase())) continue;
out.add(root);
}
return [...out];
}
/**
* 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。
* 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。
*/
function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
const out = new Set<string>();
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) out.add(field.name);
out.add(table.object);
/*
* **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用:
* 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」——
* 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。
* 它只对**本活动声明过的表**成立,所以在这儿按声明生成:
* 对象名手滑写错,照旧当场拒装。
*/
out.add(`${table.domain}_${table.object}_rows`);
/*
* 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**:
* · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数);
* · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。
* 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账
* **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果,
* 那个兜底功能就是假的。
*/
if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`);
if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`);
for (const field of table.fields) {
for (const value of field.tally ?? []) {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
}
}
}
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
}
const data = manifest.contract.input.data;
if (typeof data === 'object' && data !== null) {
const props = (data as { properties?: Record<string, unknown> }).properties;
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
}
return out;
}
@@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
at: 'assets.businessModel.narrative',
});
}
/*
* 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。
*
* 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」:
* · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书;
* · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。
* 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧),
* 只要认得出「有没有这个 id」就够。
*/
const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
for (const step of model.steps ?? []) {
if (!declared.has(step.item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `剧本引的计划项在 CMMN 里不存在:${step.item}`,
at: 'assets.businessModel.steps',
});
}
}
for (const phase of model.phases ?? []) {
for (const item of phase.items) {
if (!declared.has(item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`,
at: 'assets.businessModel.phases',
});
}
}
}
return out;
}
@@ -106,8 +363,18 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
}
for (const tool of external.missingTools) {
// 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行)
out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` });
/*
* 工具缺口是**警告,不是拒装**(2026-07-30 降级)。
*
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」——
* 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路:
* 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。
* 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」——
* 与声明式工具正面冲突,也让契约先行成了空话。
*
* 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。
*/
out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` });
}
for (const model of external.unboundModels) {
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
@@ -130,6 +397,8 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
}
out.push(...danglingListeners(manifest));
out.push(...deliverableAddressFindings(manifest));
out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest));
@@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
}
const cards: ActivityCard[] = [];
for (const [activityId, versions] of byActivity) {
/*
* 全部版本都已卸载的活动**不在架上**。
*
* 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)——
* 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里,
* 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
* 「卸了还摆着」就是界面撒谎。
*/
if (versions.every((v) => v.state === 'uninstalled')) continue;
const manifest = await deps.store.manifest(activityId);
if (manifest === undefined) continue;
cards.push({
@@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
for (const pkg of await deps.store.listPackages(activityId)) {
await deps.store.setState(activityId, pkg.version, 'uninstalled');
}
// 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯
await deps.store.setEnabled(activityId, false);
},
async loadEnvelope(activityId) {
@@ -28,19 +28,20 @@ describe('planConsumption —— 三级消费', () => {
triggerHits: ['tr1'],
ownedTaskId: 't1',
hasOwner: true,
fromChannel: true,
});
expect(plan.taskTargets).toEqual(['t1']);
expect(plan.triggerTargets).toEqual(['tr1']);
expect(plan.messageFallback).toBe(false);
});
it('皆未命中且无主 → 消息兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false });
it('**入站件**皆未命中且无主 → 消息兜底(系统自己发的不算,见下面那组)', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true });
expect(plan.messageFallback).toBe(true);
});
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true });
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true, fromChannel: true });
expect(plan.messageFallback).toBe(false);
expect(plan.recordOnlyTask).toBe('t7');
expect(plan.taskTargets).toEqual([]);
@@ -54,6 +55,7 @@ describe('planConsumption —— 三级消费', () => {
triggerHits: [],
ownedTaskId: undefined,
hasOwner: true,
fromChannel: true,
});
expect(plan.messageFallback).toBe(false);
expect(plan.recordOnlyTask).toBeUndefined();
@@ -65,12 +67,19 @@ describe('planConsumption —— 三级消费', () => {
triggerHits: [],
ownedTaskId: 't7',
hasOwner: true,
fromChannel: true,
});
expect(plan.recordOnlyTask).toBeUndefined();
});
it('无主但触发命中 → 不兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false });
const plan = planConsumption({
listenerHits: [],
triggerHits: ['tr1'],
ownedTaskId: undefined,
hasOwner: false,
fromChannel: true,
});
expect(plan.messageFallback).toBe(false);
});
});
@@ -96,3 +105,36 @@ describe('deadlineMillis —— 监听时限必配', () => {
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
});
});
/**
* **消息兜底只兜从外面进来的件**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」)。
*
* 「无主入站待认领」是件具体的事:一封信进来了,没匹配上任何单也没触发任何流,
* 得有个人去认领它。而系统自己发的事件不是这种东西 —— 台账写一行会发 `data` 事件,
* 没人监听它是**常态**,不是待办。
*
* 不分这一刀的实测下场:装一次活动包(主数据 systemWrite)就往三个人的收件箱里
* 塞了十几条「无主入站待认领:(无主题) … 原件:无」—— 没有主题、没有原件、
* 没人能对它做任何事。那不是待认领的件,是一条内部广播。
*/
describe('消息兜底:只兜入站件', () => {
const plan = (over: Partial<Parameters<typeof planConsumption>[0]> = {}) =>
planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true, ...over });
it('一封信进来、谁都没接住 → 该有人认领', () => {
expect(plan().messageFallback).toBe(true);
});
it('**台账写一行发的事件谁都没接住 → 什么也不做**(它不是件)', () => {
expect(plan({ fromChannel: false }).messageFallback).toBe(false);
});
it('接住了就不兜底 —— 监听命中或触发命中,任一即可', () => {
expect(plan({ listenerHits: [{ taskId: 't1' }] }).messageFallback).toBe(false);
expect(plan({ triggerHits: ['tr1'] }).messageFallback).toBe(false);
});
it('有主的件不兜底 —— 那是「主不是任务」,不是没人认领', () => {
expect(plan({ hasOwner: true }).messageFallback).toBe(false);
});
});
@@ -66,6 +66,22 @@ export function planConsumption(input: {
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
*/
readonly hasOwner: boolean;
/**
* 这条事件**是从外面进来的件**吗 —— 只有它才谈得上「无人认领」。
*
* 「无主入站待认领」是一件具体的事:一封信进来了、没匹配上任何单也没触发任何流,
* **得有个人去认领它**。而系统自己发的事件不是这种东西:
* 台账写一行会发 `data` 事件、引擎起一单会发工作流事件 ——
* 没人监听它们是**常态**,不是待办。
*
* 不分这一刀的下场实测过(用户 2026-08-02:「消息怎么总莫名其妙的出内容」):
* 装一次活动包(主数据 `systemWrite`)就往三个人的收件箱里塞了十几条
* 「无主入站待认领:(无主题) —— 事件 6d17debb… 未命中任何任务监听与触发器。原件:无」。
* 没有主题、没有原件、没人能对它做任何事 —— 那不是待认领的件,是一条内部广播。
*
* 判据取**来源**不取类型:同样是 `data`,从通道进来的是件,台账写出来的是事实。
*/
readonly fromChannel: boolean;
}): ConsumptionPlan {
const taskTargets = fanOutTargets(input.listenerHits);
const triggerTargets = [...new Set(input.triggerHits)];
@@ -73,8 +89,8 @@ export function planConsumption(input: {
return {
taskTargets,
triggerTargets,
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3)
messageFallback: nothingHit && !input.hasOwner,
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主的入站件**(OS 6.3)
messageFallback: nothingHit && !input.hasOwner && input.fromChannel,
recordOnlyTask:
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
? input.ownedTaskId
@@ -51,6 +51,8 @@ export function createEventService(deps: EventServiceDeps): EventService {
triggerHits,
ownedTaskId,
hasOwner: envelope.correlation.target !== undefined,
// 只有**从通道进来的件**谈得上「无人认领」—— 台账写一行发的 data 事件不是件
fromChannel: envelope.source.kind === 'channel',
});
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
@@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => {
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
});
});
/**
* **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
*
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉,
* **而盖掉之后没人看得出来**。
*/
describe('录入卡分段:段号.列名', () => {
/** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */
const split = (values: Record<string, string>, at: number): Record<string, string> => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
const mark = `${at}.`;
if (k.startsWith(mark)) out[k.slice(mark.length)] = v;
else if (at === 0 && !/^\d+\./.test(k)) out[k] = v;
}
return out;
};
const values = {
'0.customer_code': 'CU-9001',
'0.name': '上海精密电子有限公司',
'1.customer_code': 'CU-9001',
'1.contact_code': 'CT-01',
'1.name': '陈刚',
'1.email': 'chen@sh-elec.com',
};
it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => {
expect(split(values, 0)['name']).toBe('上海精密电子有限公司');
expect(split(values, 1)['name']).toBe('陈刚');
});
it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => {
expect(split(values, 0)['contact_code']).toBeUndefined();
expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']);
});
it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' });
});
it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
});
});
@@ -0,0 +1,134 @@
/**
* 调人预算 —— 拦的是「又去问人」,不是「办完了要交差」。
*
* 这条是拿一次熔断换来的:A4 的预算是 3 次,两张决策卡加一张补全卡用完;
* 三条料的价全算好了、门禁也过了,提交放行却被「调人预算触顶 3/3」拒回 ——
* 再提再拒,连拒五拍熔断。活动可以完全合规地把自己饿死在终点线上。
*
* 放行不是 Agent 选择的打扰:铁律③终局唯放行,这一单**必须**有人签字才收得了口。
* 所以验收单不计预算;补全卡、决策卡、执行工单照算。
*/
import { describe, expect, it } from 'vitest';
import type { ChatCard, DeliverableId, StaffId, TaskId, WorkOrder } from '@contracts';
import { cardIdSchema } from '@contracts';
import type { CardStore, HumanEventPublisher, MemoryRecorder, MessageOutbox } from '../port.ts';
import type { IssueContext } from '../contract.ts';
import { createHumanToolService } from '../internal/usecase/human-tool-service.ts';
import { HumanCallBudgetExceededError } from '../contract.ts';
const TASK = 'task-1' as TaskId;
const OWNER = 'staff-1' as StaffId;
/** 记下「发出去了什么」的假台账 —— 已用次数由测试给定,其余按端口如实实现 */
function cardStore(used: number, sent: string[]): CardStore {
return {
saveWorkOrder: async (_taskId, order) => {
sent.push(`order:${order.mode}`);
},
getWorkOrder: async () => undefined,
listWorkOrders: async () => [],
markWorkOrderDone: async () => undefined,
patchWorkOrder: async () => undefined,
saveChatCard: async (_taskId, card) => {
sent.push(`card:${card.k}`);
},
getChatCard: async () => undefined,
listChatCards: async () => [],
updateChatCard: async () => undefined,
closeChatCard: async () => undefined,
humanCallCount: async () => used,
openCardsOf: async () => [],
};
}
const outbox: MessageOutbox = { sendElicitation: async () => ({ id: 'm-1' }) };
const memory: MemoryRecorder = { append: async () => undefined };
const events: HumanEventPublisher = { publishHumanEvent: async () => undefined };
function service(used: number) {
const sent: string[] = [];
let seq = 0;
const svc = createHumanToolService({
cards: cardStore(used, sent),
outbox,
memory,
events,
clock: { now: () => new Date('2026-07-31T00:00:00.000Z') },
ids: {
next: () => {
seq += 1;
return `id-${seq}`;
},
},
});
return { svc, sent };
}
const context: IssueContext = { taskId: TASK, ownerId: OWNER, humanCallCap: 3 };
const executionDraft = {
title: '请补齐字段',
basis: [{ k: '缺口', v: 'PN' }],
ask: '请向客户追问',
fields: [{ key: 'pn', label: 'PN', kind: 'text' as const, required: true }],
} satisfies Parameters<ReturnType<typeof service>['svc']['issueExecution']>[1];
const acceptanceDraft = {
title: '收口验收',
basis: [{ k: '条目数', v: '3' }],
deliverable: 'd-1' as DeliverableId,
criteria: [{ label: '草案完整', level: 'L1' as const, pass: true }],
fields: [],
} satisfies Parameters<ReturnType<typeof service>['svc']['issueAcceptance']>[1];
const decisionCard: ChatCard = {
id: cardIdSchema.parse('c-1'),
at: '2026-07-31T00:00:00.000Z',
k: 'decision',
ask: '选一个处置',
basis: [{ k: '偏离', v: '-30%' }],
options: [
{ value: 'a', label: '照原价报' },
{ value: 'b', label: '按底价报' },
],
allowCustom: false,
};
describe('调人预算', () => {
it('没到顶 —— 执行单发得出去', async () => {
const { svc, sent } = service(2);
await svc.issueExecution(context, executionDraft);
expect(sent).toEqual(['order:execution']);
});
it('到顶了 —— 执行单拒发', async () => {
const { svc } = service(3);
await expect(svc.issueExecution(context, executionDraft)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
});
it('到顶了 —— 决策卡拒发', async () => {
const { svc } = service(3);
await expect(svc.issueChatCard(context, decisionCard)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
});
it('到顶了 —— **验收放行照发**:终局唯放行,不该跟提问抢额度', async () => {
const { svc, sent } = service(3);
await svc.issueAcceptance(context, acceptanceDraft);
expect(sent).toEqual(['order:acceptance']);
});
it('超顶很多也照发验收单 —— 已经办完的活不该因为问过几次人就交不了差', async () => {
const { svc, sent } = service(99);
await svc.issueAcceptance(context, acceptanceDraft);
expect(sent).toEqual(['order:acceptance']);
});
it('验收单免的是预算,不是三规矩 —— 裸卡照旧拒发', async () => {
const { svc } = service(0);
const bare = { ...acceptanceDraft, basis: [] } satisfies Omit<
Extract<WorkOrder, { mode: 'acceptance' }>,
'id' | 'at' | 'mode'
>;
await expect(svc.issueAcceptance(context, bare)).rejects.toThrow();
});
});
+26 -1
View File
@@ -89,6 +89,8 @@ export type IssuedChatCard = {
export type ReplyOutcome = {
readonly order: WorkOrder;
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
readonly taskId: TaskId;
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
readonly producesCompletion: boolean;
/** 驳回:环内回转,任务继续 */
@@ -110,10 +112,33 @@ export type HumanToolService = {
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
/**
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
*/
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>;
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
/**
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
*
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
*/
attachPortChoice(
workOrderId: string,
choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> },
): Promise<void>;
/**
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
*/
attachRoutedPort(
workOrderId: string,
routed: { group: string; name: string; label?: string | undefined; because: string },
): Promise<void>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
@@ -54,6 +54,20 @@ export function createCardStore(db: Db): CardStore {
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
},
/*
* 局部改卡面:整卡存在 `payload` 里,所以读回来合并再写回。
* 只对**未回传**的卡开放(调用方保证)—— 已答的卡是留痕,只增不改。
*/
async patchWorkOrder(id, patch) {
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
const row = rows[0];
if (row === undefined) return;
await db
.update(workOrderTable)
.set({ payload: { ...(row.payload as Record<string, unknown>), ...patch } })
.where(eq(workOrderTable.id, id));
},
async saveChatCard(taskId, card, recipientId) {
await db.insert(chatCardTable).values({
id: card.id,
@@ -164,6 +164,29 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
}
}
/*
* 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住:
* 必填空着不让确认(缺一格这张档就是残的,而它是主数据);
* 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。
*/
if (card.k === 'entry' && reply.k === 'entry') {
if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }];
/*
* **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 ——
* 两张表都有 `name` 这一列,平铺就会互相盖掉。
* 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。
*/
const groups = [{ fields: card.fields }, ...(card.also ?? [])];
const blank = groups.flatMap((g, at) =>
g.fields
.filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '')
.map((f) => f.label),
);
if (blank.length > 0) {
out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` });
}
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
@@ -7,6 +7,7 @@ import type {
ChatCardReply,
ExecutionWorkOrder,
StaffId,
TaskId,
WorkOrder,
WorkOrderReply,
} from '@contracts';
@@ -38,9 +39,35 @@ export type HumanToolServiceDeps = {
readonly ids: IdGenerator;
};
/**
* 录入卡上那一格叫什么 —— 键是 `段号.列名`(一张卡可以落好几张表)。
* 按段号找回它属于哪一段;认不出的原样留着(宁可笼统,不可编)。
*/
function entryLabel(card: Extract<ChatCard, { k: 'entry' }>, key: string): string {
const at = /^(\d+)\.(.+)$/.exec(key);
const groups = [{ title: '', fields: card.fields }, ...(card.also ?? [])];
if (at === null) return card.fields.find((f) => f.key === key)?.label ?? key;
const group = groups[Number(at[1])];
const name = at[2] ?? key;
const label = group?.fields.find((f) => f.key === name)?.label ?? name;
return group === undefined || group.title === '' ? label : `${group.title}·${label}`;
}
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
async function assertBudget(context: IssueContext): Promise<void> {
/**
* 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1)。
*
* **验收放行那一张不计预算**(2026-07-31 加)。
*
* 预算管的是「打扰」——Agent 自己选择去问人的那些次数:补全卡、决策卡、执行工单。
* 而放行不是它选的:铁律③终局唯放行,这一单**必须**由人签字才能收口。
* 把它和提问算在同一个额度里,后果是活动可以合规地把自己饿死 ——
* 实测 A4:预算 3 次,两张决策卡加一张补全卡用完,活全干完了,
* 提交放行被「调人预算触顶 3/3」拒回,再提再拒,连拒五拍熔断。
* 该拦的是「又去问人」,不是「办完了要交差」。
*/
async function assertBudget(context: IssueContext, mode?: WorkOrder['mode']): Promise<void> {
if (mode === 'acceptance') return;
const used = await deps.cards.humanCallCount(context.taskId);
if (used >= context.humanCallCap) {
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
@@ -61,7 +88,12 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
taskId: context.taskId,
kind: 'card-sent',
title,
detail: { kind, id, arrival, recipient },
/*
* `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 ——
* 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡,
* 四字动作那一格就只能写「发出卡片」这种等于没说的词。
*/
detail: { kind, id, arrival, recipient, ...payload },
});
if (arrival === 'task-chat') return { arrival };
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
@@ -79,7 +111,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
const violations = checkWorkOrder(order);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
await assertBudget(context, order.mode);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
@@ -171,8 +203,8 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
await deps.memory.append({
taskId: found.taskId,
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
});
// 回传经统一入口以人事件抛回任务
await deps.events.publishHumanEvent({
@@ -186,15 +218,28 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
return {
order,
taskId: found.taskId,
producesCompletion: producesCompletion(reply),
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
/*
* 出口:**算出来的优先**。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*/
selectedPorts:
reply.mode !== 'acceptance'
? {}
: (order as AcceptanceWorkOrder).routedPort !== undefined
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
: reply.selectedPorts,
};
},
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }> {
const found = await deps.cards.getChatCard(reply.cardId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
@@ -205,14 +250,45 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
const updated = applyChatReply(found.card, reply, by, at);
await deps.cards.updateChatCard(updated);
/*
* 标题写**内容**,不写「entry 卡回传」这种行话 —— 日志里动作已有自己一格
* (四字章:员工确认 / 员工补全 …),标题再复述一遍卡的类型就是白占地方。
* 录入卡尤其要说清落到哪张表:人回看时问的是「我那一下写了什么」。
*/
/*
* **日志里不摆台账地址与列名**(2026-08-02 用户指出:
* 「柴浩:写入 customer.profile / customer_code=chchch;name=青岛海洋电子有限公司」
* —— 这为啥没换成纯描述)。
*
* `customer.profile` 与 `customer_code` 是系统内的名字;人回看时问的是
* **「我那一下办了什么」**,答案该是「建了客户档案 · 青岛海洋电子有限公司」。
* 卡上本来就有人话:`ask` 是问他的那句,`fields[].label` 是每一格的中文名 ——
* 用它们,不用地址与列名。
*/
const title = found.card.ask.split('\n')[0] ?? found.card.ask;
await deps.memory.append({
taskId: found.taskId,
kind: 'card-reply',
title: `${found.card.k} 卡回传`,
detail: { by, ...reply },
title,
detail: {
by,
...reply,
...(reply.k === 'entry' && found.card.k === 'entry'
? {
/*
* 列名换成卡上那个中文 label;认不出的原样留着(宁可笼统,不可编)。
* 值的键是 `段号.列名`(一张卡可以落好几张表)—— 按段号找回它属于哪一段,
* 不然「联系人姓名」会显示成公司档那个同名的「公司名」。
*/
summary: Object.entries(reply.values)
.map(([k, v]) => `${found.card.k === 'entry' ? entryLabel(found.card, k) : k}:${v}`)
.join(' · '),
}
: {}),
},
});
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
return updated;
return { card: updated, taskId: found.taskId };
},
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
@@ -239,6 +315,26 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
},
async attachRoutedPort(workOrderId, routed) {
await deps.cards.patchWorkOrder(workOrderId, {
routedPort: {
group: routed.group,
name: routed.name,
...(routed.label === undefined ? {} : { label: routed.label }),
because: routed.because,
},
});
},
async attachPortChoice(workOrderId, choice) {
await deps.cards.patchWorkOrder(workOrderId, {
portChoice: {
group: choice.group,
ports: choice.ports.map((p) => ({ name: p.name, ...(p.label === undefined ? {} : { label: p.label }) })),
},
});
},
async openWorkOrders(taskId) {
return deps.cards.listWorkOrders(taskId, true);
},
@@ -270,6 +366,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
};
}
if (card.k === 'entry' && reply.k === 'entry') {
return { ...card, done: { at, by, values: reply.values } };
}
if (card.k === 'rating' && reply.k === 'rating') {
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
}
+2
View File
@@ -11,6 +11,8 @@ export type CardStore = {
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
@@ -18,12 +18,29 @@
`MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org)
被依赖: release, loop-engine, flow-engine, task, app(SSE 路由)
近变(2026-07-30):新增 `onPush(listener)` —— **系统级转接**,装配层接走全部推送转给前端 SSE。
从前只有 `subscribe(staffId, …)`,而它一个订阅者都没有:AI 说的每一句、
推的每一帧画面都广播给了空气,前端只好靠人点刷新。不复用 `subscribe` 是因为
转接是系统的事,为它编一个假员工 id 是撒谎;不按人筛也不漏 —— 推送不带业务载荷,
谁该看见由前端自己那几把查询决定。
对话流投影另加 `narration` 一类(AI 对人说的话)。
不变量(OS 4.1 / 09,AM 07):
- **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交;
视图区不得产生回环输入,浏览性交互由前端容器本地处理
- **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统;
转交随引擎事件自动发生,活动不感知交接
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
- **填数只做取值与转义**:`data-slot="路径"` 换文本、`data-rows="路径"` 逐行复制;
无表达式、无分支、无循环 —— 模版一旦能算,「无脚本执行权」就形同虚设。
两个表现档,都把判断留在样式表、不留在填数里:
· `data-blank` 让空值**真的留空**(不填「—」),好让模版自己的 CSS
去标必填/选填缺口(`td:empty::after` / `tr:has(td.req:empty)`);
· `data-class-of="路径"` 把该值消毒成 `v-<值>` 追加进 class(只放行
ASCII 字母数字与横杠、封顶 24 字;一个合规字符都没有就不给 class)——
「A 绿 / B 蓝 / C 橙」这类按值分档的表现,靠它投影,不靠模版算。
**两个档互不依赖**:`data-class-of` 不以 `data-slot` 为前提,
纯色块(格里不写字,只按值上色)照样上得了色
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
@@ -16,7 +16,7 @@ const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
it('引擎自转的条目一律不进对话区', () => {
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'control'];
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
});
@@ -54,9 +54,174 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
});
it('放行裁决与终态投成阶段结论', () => {
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
/*
* **顺利收尾一条都不出**(2026-08-02 用户当场指出:「终态:released 删除」)。
*
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake」「终态:released」——
* 一件事说三遍。2026-08-01 先砍成一条(留终态);同日验收卡办结后换成了「落地结果」
* (落了几行、进了哪几张表、转去哪儿了),那一条也就**没话可说了** ——
* 在一件已经交代清楚的事后面补一句「结束了」,是废话;何况它摆的还是英文状态名。
*/
it('**顺利收尾:对话区一条都不出** —— 落地结果那一屏已经说全了', () => {
const out = projectChat([
entry('release-verdict', { id: 'r', title: '裁决:pass' }),
entry('release-verdict', { id: 'r2', title: '落地收敛,发射输出口:intake' }),
entry('terminal', { id: 't', title: '终态:released' }),
]);
expect(out).toHaveLength(0);
});
/*
* **没顺利收尾的仍要出声** —— 那时落地结果那一屏根本不会出现,
* 而人需要知道这一单停在哪儿了。措辞取共用字典,不摆 `blocked` 这种词。
*/
it('卡住 / 退回 / 预算耗尽:出一条,且说人话', () => {
for (const [state, said] of [
['blocked', '这一单受阻待人'],
['exhausted', '这一单预算耗尽'],
['stalled', '这一单停滞熔断'],
] as const) {
const out = projectChat([entry('terminal', { id: 't', title: `终态:${state}` })]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe(said);
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('conclusion');
}
});
it('认不出的状态原样留着 —— 宁可笼统,不可编', () => {
const out = projectChat([entry('terminal', { id: 't', title: '终态:什么新状态' })]);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('终态:什么新状态');
});
});
/**
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
*
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
* 卡是「你要办的那件事」。各占一个气泡,每轮就是两条:一条话、
* 一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
*/
describe('同一轮的交代与卡并成一条', () => {
const say = (id: string, text: string) => entry('narration', { id, title: text });
const card = (id: string, title: string, wo: string) =>
entry('card-sent', { id, title, detail: { kind: 'work-order', id: wo } });
it('刚说过话就发卡 → 一个气泡,话是正文、卡挂在上面', () => {
const out = projectChat([say('n', '九条料都拆好了,缺两格,请核对确认'), card('c', '受理需求确认', 'wo-1')]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('九条料都拆好了,缺两格,请核对确认');
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-1');
});
it('并进去之后**标记跟着卡走** —— 验收卡要 criterion,裁决表单才渲得出来', () => {
const out = projectChat([say('n', '判据我都裁过了,交你签'), card('c', '收口验收', 'wo-2')]);
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('criterion');
});
it('**没说话就自成一条** —— 卡不能不出现', () => {
const out = projectChat([card('c', '受理需求确认', 'wo-3')]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-3');
});
it('一句话只领一张卡 —— 同拍两张卡的第二张自成一条,不覆盖第一张', () => {
const out = projectChat([say('n', '两件事要你办'), card('a', '卡一', 'wo-a'), card('b', '卡二', 'wo-b')]);
expect(out.map((m) => (m.k === 'ai' ? m.ref : undefined))).toEqual(['wo-a', 'wo-b']);
});
it('隔着一条系统事件就不并 —— 那是上一轮的话,不是这张卡的结论', () => {
const out = projectChat([
say('n', '我去问客户'),
entry('observation', { id: 'o', title: '客户回信了' }),
card('c', '受理需求确认', 'wo-4'),
]);
expect(out).toHaveLength(3);
});
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
expect(out).toHaveLength(2);
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
});
/*
* **结论口吻的交代照样并卡**。
*
* 挡住并卡的判断一度写成 `tag !== 'conclusion'` —— 而交代本身常常就是结论口吻
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
* 实测:第一轮并对了、收口那一轮没并 —— 同一屏两种样子。
* 不许并卡的只有**收尾那一句**(terminal),按来路认,不按语气签认。
*/
it('模型把交代标成 conclusion,卡照样并进去', () => {
const out = projectChat([
entry('narration', { id: 'n', title: '判据我都裁过了,交你签', detail: { tag: 'conclusion' } }),
card('c', '收口验收', 'wo-6'),
]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-6');
});
});
/**
* **AI 会不会说话** —— 这一类从前没有,后果是对话区从头到尾空白。
*
* 实测:A1 跑十几拍、写了七行台账、调了两次工具,负责人的对话区一个字也没有 ——
* 他只看得见「多了一个任务」,不知道机器干到哪儿、凭什么这么判。
* 「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
*/
describe('narration —— AI 对人说的话进对话区', () => {
const entry = (kind: string, title: string, detail?: Record<string, unknown>) => ({
id: '11111111-1111-4111-8111-111111111111',
at: '2026-07-30T01:00:00.000Z',
kind,
title,
...(detail === undefined ? {} : { detail }),
});
it('交代进展投成 AI 发言', () => {
const out = projectChat([entry('narration', '三条已归一,缺 1 个交期')]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ k: 'ai', text: '三条已归一,缺 1 个交期', tag: 'guidance' });
});
it('语气签照活动标的来;标不出按「指导」计', () => {
expect(projectChat([entry('narration', 'x', { tag: 'conclusion' })])[0]).toMatchObject({ tag: 'conclusion' });
expect(projectChat([entry('narration', 'x', { tag: '我编的签' })])[0]).toMatchObject({ tag: 'guidance' });
});
it('机器踪迹照旧不进对话区 —— 它不是日志的副本', () => {
const out = projectChat([
entry('action', '写草稿:quote_intake.record 3 行'),
entry('engine-transition', 'manualStart:S_intake'),
entry('result', 'crm.history-price-read 返回'),
entry('thought', '装配技能:intake.read-rfq@2.0.0'),
]);
expect(out).toHaveLength(0);
});
});
describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', () => {
it('执行工单回传投成人事件回执 —— 人办完一件事,对话流上要有一句人话的痕', () => {
// 认的是 `detail.mode`,不是标题前缀:措辞是给人看的、随时会改,改了这条不该失灵
const out = projectChat([
entry('card-reply', { id: 'r1', title: '补全三条料的品牌', detail: { mode: 'execution' } }),
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ k: 'system', type: 'human' });
expect((out[0] as { text: string }).text).toBe('「补全三条料的品牌」已办结,AI 接着往下推。');
});
it('对话卡回传不投 —— 卡自己会翻成已答态,再来一条回执是重影', () => {
expect(projectChat([entry('card-reply', { id: 'r2', title: '补齐品牌', detail: { k: 'elicitation' } })])).toHaveLength(
0,
);
});
it('引擎内部观察(internal 标)进日志不进对话', () => {
expect(projectChat([entry('observation', { id: 'o9', title: 'S1 已在进行中 —— 无需再启动', detail: { internal: true } })])).toHaveLength(0);
});
it('裸「事件到达(human):卡号」不投 —— 对人只是一串 UUID,回执已用人话说过', () => {
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
});
});
@@ -108,3 +108,173 @@ describe('整键优先 —— 台账地址本身带点号', () => {
expect(out).toContain('<td>LM358DR</td>');
});
});
/**
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
*
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
*/
describe('data-blank:空值真的留空', () => {
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
const html = '<td class="req" data-blank data-slot="eau"></td>';
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
expect(renderView(html, {})).toBe('<td class="req"></td>');
});
it('有值照填 —— 0 与 false 都是值,不算空', () => {
const html = '<td class="req" data-blank data-slot="v"></td>';
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
});
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
});
it('逐行展开时每行各判各的空', () => {
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
rs: [{ eau: 30_000 }, { eau: '' }],
});
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
});
});
/**
* `data-class-of` —— **按值上色**,模版仍然不许算。
*
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
*/
describe('data-class-of:按值上色', () => {
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
});
it('没有 class 属性就新建一个', () => {
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
});
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
});
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
expect(out).toBe('<b class="x v-onclickalert1b">&quot; onclick=alert(1) &lt;b&gt;</b>');
expect(out).not.toContain('onclick=alert(1)"');
});
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
});
it('逐行展开时每行按自己的值上色', () => {
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
});
/*
* **上色不以填字为条件** —— 这一组是补的账。
*
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
*/
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
});
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
});
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
});
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
});
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
hit: 'r1',
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
});
});
/**
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
*
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
*/
describe('data-edit / data-col:可改格子的输入孪生件', () => {
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
it('容器标了 data-edit,格子就多一个带值的 input', () => {
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
});
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
expect(out).not.toContain('<input');
});
it('行号钉在每一行上 —— 写回时按它对行', () => {
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
});
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
const out = renderView(ROWS, { rs: [{ pn: '' }] });
expect(out).toContain('value=""');
});
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
expect(out).toContain('value="&quot; onfocus=alert(1) x=&quot;"');
expect(out).not.toMatch(/value="" onfocus/);
});
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
});
});
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect((once.match(/<input/g) ?? []).length).toBe(1);
expect(renderView(once, {})).toBe(once);
});
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
});
});
+20 -2
View File
@@ -40,9 +40,16 @@ export type InteractionService = {
/** 视图流控:活动按局面推结构化块 */
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
/**
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
*
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
*/
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
@@ -50,6 +57,17 @@ export type InteractionService = {
/** SSE:单连接、按信封目标失效缓存 */
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
/**
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
*
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
*
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
*/
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
/** 供上层广播失效信号 */
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
};
@@ -21,6 +21,8 @@ export const taskViewTable = sys.table('task_view', {
taskId: text('task_id').primaryKey(),
blocks: jsonb('blocks'),
html: text('html'),
/** 这一帧用的模版名 —— 数据变了要按同一张重渲(件是投影,不是快照) */
template: text('template'),
phase: text('phase').notNull().default('activating'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -28,11 +28,13 @@ export function createInteractionStore(db: Db): InteractionStore {
.orderBy(asc(chatMessageTable.at));
return rows.map((row) => chatMessageSchema.parse(row.payload));
},
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) {
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }) {
const values = {
taskId,
blocks: view.blocks === undefined ? null : [...view.blocks],
html: view.html ?? null,
// 模版名一起存:件是投影 —— 数据变了要按同一张重渲(见迁移 0028 的注释)
template: view.template ?? null,
updatedAt: new Date(),
};
await db
@@ -40,7 +42,7 @@ export function createInteractionStore(db: Db): InteractionStore {
.values(values)
.onConflictDoUpdate({
target: taskViewTable.taskId,
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
set: { blocks: values.blocks, html: values.html, template: values.template, updatedAt: values.updatedAt },
});
},
async getView(taskId: TaskId) {
@@ -49,6 +51,7 @@ export function createInteractionStore(db: Db): InteractionStore {
return {
...(row.blocks === null ? {} : { blocks: row.blocks as ViewBlock[] }),
...(row.html === null ? {} : { html: row.html }),
...(row.template === null ? {} : { template: row.template }),
};
},
async setPhase(taskId: TaskId, phase: InteractionPhase) {
@@ -7,14 +7,26 @@
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
*
* 筛四类:
* narration → **AI 发言**:一轮办完时对人说的那一句。
* 这一类从前没有,后果是对话区从头到尾空白 —— A1 跑十几拍写了七行台账、
* 调了两次工具,而人在对话区一个字看不到,只看得见「多了一个任务」。
* 机器踪迹(engine-transition / action / result)照旧不进:
* 对话区不是日志的副本,它是**AI 对人说的话**。
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
* release-verdict→ AI 发言 · 阶段结论
* terminal → AI 发言 · 阶段结论
* card-sent → **并进上一条 AI 发言**,合成「结论 + 功能卡」一个气泡;
* 没有可并的就自成一条(卡不能不出现)
* terminal → AI 发言 · 阶段结论(收尾只此一条)
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
* release-verdict 不投:裁决是人自己刚按下的,发了哪几个口是编排层的事实 —— 都在工作日志里。
*
* ── 密度(2026-08-01 用户定)─────────────────────────────────────────
* **一轮一个气泡**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次,
* 可以是仅结论,也可以是结论 + 功能卡两部分。埋头办事的拍不出气泡 ——
* 那一段由「AI 工作中」的办事条覆盖(哪些拍算收口见 `turn-runner` 的 `handsOff`)。
* 这一层做的是后半段:把同一轮的交代与卡并成一条,并把收尾那三条引擎回声收成一条。
*/
import type { ChatMessage, EventType } from '@contracts';
import { cardIdSchema, workOrderIdSchema } from '@contracts';
import type { ChatMessage, EventType, TerminalState } from '@contracts';
import { cardIdSchema, TERMINAL_STATE_LABEL, workOrderIdSchema } from '@contracts';
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
export type NarrativeEntry = {
@@ -40,31 +52,130 @@ function workOrderRef(detail: Record<string, unknown> | undefined): string | und
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
}
/** 发言的语气签:活动自己标,标不出按「进展」计 —— 三种签在前端各有其样式 */
const TAGS: readonly string[] = ['guidance', 'criterion', 'conclusion'];
function tagOf(detail: Record<string, unknown> | undefined): 'guidance' | 'criterion' | 'conclusion' {
const tag = detail?.['tag'];
return typeof tag === 'string' && TAGS.includes(tag) ? (tag as 'guidance' | 'criterion' | 'conclusion') : 'guidance';
}
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
const out: ChatMessage[] = [];
/*
* 上一条是不是**收尾那句**(terminal)—— 只有它不许认领后面的卡。
*
* 不能拿 `tag === 'conclusion'` 当这个判断:交代本身常常就是结论口吻
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
* 实测栽过一次:收口那一轮的交代标了 conclusion,于是并卡被这条挡下,
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
*/
let lastIsTerminal = false;
for (const e of entries) {
const id = cardIdSchema.parse(e.id);
if (e.kind === 'observation') {
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
if (e.kind === 'narration') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
lastIsTerminal = false;
continue;
}
/*
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
* 不是「这条属于哪一类」。
*/
if (e.kind === 'trigger' || e.kind === 'observation') {
// 引擎内部观察(已在进行中 / 批截断…)进日志不进对话 —— 对话区不是机器踪迹的副本
if (e.detail?.['internal'] === true) continue;
/*
* 裸「事件到达(human):卡号」也不进:对人它只是一串 UUID,而这件事
* 已由**回执**(card-reply 投影)用人话说过一遍 —— 同一件事不说两遍。
* 认的是 `detail.kind`(结构化标记),不是标题前缀:措辞改一次,
* 按前缀认的过滤就静默失灵,而失灵的样子是「对话区多出一串 UUID」。
*/
if (e.detail?.['kind'] === 'human') continue;
out.push({
k: 'system',
id,
at: e.at,
type: eventTypeOf(e.detail),
text: e.title,
// 起单那句开场白带「查看邮件」—— 其余系统事件没有去处,不加这个键
...(e.detail?.['openComms'] === true ? { openComms: true } : {}),
});
continue;
}
/*
* 认的是 `detail.mode`,**不是标题前缀**(2026-07-31 改)——
* 从前判的是 `title.startsWith('执行工单回传:')`,而标题措辞是给人看的、随时会改;
* 改一次这条投影就静默失灵:人办结了工单,对话区一句话也不出,像没发生过。
* 结构化标记改不动,措辞怎么改都不影响。
*/
if (e.kind === 'card-reply' && e.detail?.['mode'] === 'execution') {
/*
* 页面交互的**回执**(PRD:三类唤醒事件之一)——人在视图区办结了一件工单,
* 对话流上要有一句人话的痕:「工单已办结,AI 接着往下推」。
* 没有它,人办完一件事回到对话区,看到的还是办之前那句话 —— 像没发生过。
*/
out.push({
k: 'system',
id,
at: e.at,
type: 'human',
text: `「${e.title}」已办结,AI 接着往下推。`,
});
continue;
}
if (e.kind === 'card-sent') {
const ref = workOrderRef(e.detail);
// 跨人卡进对方的消息要素,不在本任务对话流上露面
if (ref === undefined) continue;
out.push({
k: 'ai',
id,
at: e.at,
text: e.title,
// 验收件请人裁决 = 判据;执行件派工 = 指导
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
ref: workOrderIdSchema.parse(ref),
});
// 验收件请人裁决 = 判据;执行件派工 = 指导
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
/*
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
*
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
* 卡是「你要办的那件事」。从前各占一个气泡,于是每轮都是两条:
* 一条话、一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
*
* 这一拍刚说过话(紧邻的上一条就是它,turn-runner 保证交代先于行动),
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
*/
const last = out[out.length - 1];
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
continue;
}
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
continue;
}
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
/*
* **收尾只出一条**(2026-08-01)。
*
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake、to-alternative」
* 「终态:released」—— 一件事说三遍,而且前两条是引擎内部话:
* 裁决是**人自己刚按下的**,不必读回给他听;发了哪几个口是编排层的事实,
* 它在工作日志里(那一屏本来就是给排障看的),不该占对话区一条。
* 留下的是终态那一条:这一单结没结、怎么结的 —— 那才是他要知道的。
*/
if (e.kind === 'terminal') {
/*
* **顺利收尾的那一条不再出声**(2026-08-02 用户当场指出:「终态:released 删除」)。
*
* 两个毛病:①摆的是 `终态:released` —— 系统内的英文状态名,界面不摆这种词;
* ②**它已经没有话可说了** —— 同日起,人签完字之后验收卡那一屏换成了「落地结果」
* (落了几行、进了哪几张表、转去哪儿了),该说的它全说了,而且说得具体得多。
* 再补一条「结束了」,是在一件已经交代清楚的事后面加一句废话。
*
* **没顺利收尾的仍要出声**:卡住了、被退回、预算耗尽 —— 那时人需要知道
* 「这一单停在哪儿了」,而落地结果那一屏根本不会出现。措辞取共用的
* `TERMINAL_STATE_LABEL`(与任务卡、日志同一本字典),不在这儿另抄一份。
*/
const state = /(?:终态[::]\s*)?([a-z]+)\s*$/.exec(e.title)?.[1];
const label = state === undefined ? undefined : TERMINAL_STATE_LABEL[state as TerminalState];
if (state !== 'released') {
out.push({ k: 'ai', id, at: e.at, text: label === undefined ? e.title : `这一单${label}`, tag: 'conclusion' });
lastIsTerminal = true;
}
}
}
return out;
@@ -48,6 +48,27 @@ export function displayValue(value: unknown): string {
}
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
/** `data-blank`:这一格空着的时候**真的留空**,不填「—」 */
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
/**
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
*
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
*/
export function classToken(value: unknown): string {
if (value === undefined || value === null) return '';
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
return text.slice(0, 24);
}
/**
* 把作用域填进模版的槽位。
@@ -55,13 +76,72 @@ const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
*
* ── 例外:`data-blank` ────────────────────────────────────────────────
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
*/
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd();
const text = escapeHtml(displayValue(resolveSlot(scope, path)));
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
const raw = `${before}${after}`;
const blank = /\bdata-blank\b/.test(raw);
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
const value = resolveSlot(scope, path);
const empty = value === undefined || value === null || value === '';
const text = blank && empty ? '' : escapeHtml(displayValue(value));
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
});
/*
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
*
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
*/
const withValues = filled.replace(
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
(_all, before: string, path: string, mid: string, after: string) => {
const value = resolveSlot(scope, path);
const empty = value === undefined || value === null || value === '';
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
},
);
/*
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
*
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
*/
return withValues.replace(
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
(_all, tag: string, attrs: string) => {
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
},
);
}
/**
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
*/
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
const classOf = CLASS_OF.exec(attrs);
if (classOf === null) return attrs;
const rest = attrs.replace(CLASS_OF, '');
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
if (token === '') return rest;
return /\bclass\s*=\s*"[^"]*"/.test(rest)
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
: `${rest} class="v-${token}"`;
}
/**
@@ -72,19 +152,57 @@ const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim();
const raw = `${before}${after}`;
const attrs = raw.replace(/\s+/g, ' ').trim();
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
const value = resolveSlot(scope, path);
const rows = Array.isArray(value) ? value : [];
/*
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
*/
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
const body = rows
.map((row) =>
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
)
.map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
})
.join('');
return `${open}${body}</${tag}>`;
});
}
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
/**
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
* 两者由样式表二选一显示(根节点带不带 `editing`)。
*
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
*
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
* 表头改个说法不该让写回错位。
*/
function twinInputs(inner: string, rowIndex: number): string {
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
if (slot === undefined) return all;
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
.split(/\s+/)
.filter((c) => c === 'mono' || c === 'num')
.join(' ');
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
});
}
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
return renderTemplate(renderRows(html, scope), scope);
@@ -19,7 +19,8 @@ export type InteractionServiceDeps = {
readonly ids: IdGenerator;
};
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
/** 订阅者:`staffId` 缺省 = 系统级转接(装配层接走全部推送,转给前端 SSE) */
type Subscriber = { staffId: StaffId | undefined; onPush: (envelope: PushEnvelope) => void };
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
@@ -70,10 +71,11 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
},
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
async pushViewHtml(taskId, html) {
async pushViewHtml(taskId, html, template) {
const violations = checkTemplate(html);
if (violations.length > 0) throw new TemplateRejectedError(violations);
await deps.store.putView(taskId, { html });
// 模版名一起存:件是投影,数据变了要能按同一张模版重渲(见 contract 的注释)
await deps.store.putView(taskId, { html, ...(template === undefined ? {} : { template }) });
broadcast({ kind: 'view-html', taskId, html });
},
@@ -98,6 +100,13 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
},
/** 系统级转接 —— 装配层接走全部推送,转给前端 SSE(见 contract 的注释) */
onPush(listener) {
const bridge: Subscriber = { staffId: undefined, onPush: listener };
subscribers.add(bridge);
return { close: () => void subscribers.delete(bridge) } satisfies Subscription;
},
invalidate(target, id) {
broadcast({ kind: 'invalidate', target, id });
},
+2 -2
View File
@@ -11,8 +11,8 @@ export type IdGenerator = { next(): string };
export type InteractionStore = {
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }): Promise<void>;
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
};
+11 -1
View File
@@ -25,8 +25,17 @@
不变量:
- **写三分是三个方法**:`systemWrite` / `draftWrite` / `releaseWrite` —— 无第四条写入路径,
调用方拿不到带 flag 的「绕门」形态(§12.5#9)
- `draftDrop` **不是第四种写**:它只撤本单草稿层那一笔 —— 正式版一个字都动不了,
也不抛事件,放行门的位置没变。写错了得有退路:实测过一次死胡同 ——
客户说本轮跳过的那颗料被写进有效清单,判据正确地拦住而模型改不动
(唯一能改的是那个数,改了别的数又不对),13 次拒回、23 拍、234k token 才熔断。
**门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。**
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
- 合并只增不破坏:`planSchemaChanges` 永不产出删列 / 缩窄;主键一经建立不得由声明变更
- 同名异型取能兼容双方的最宽类型(integer→number→text;date→timestamp→text);
`json` 与标量语义互斥 —— 拦下,交数据管理裁决
@@ -34,7 +43,8 @@
- `ledger` schema 的唯一写者是本模块 adapter;SQL 拼接只在 `ledger-store.ts`,
标识符过 `IDENT` 正则 + `quoteIdent`
影响半径: index/contract/port 未变 → 对外零影响
影响半径: contract 加 `ReleaseWriteResult.simulatedRows`(加性)· port 加 `LedgerStore.masterRefs`
与 `SimulationProbe`(app 注入)→ 下游只有 release 需同步读新字段
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
@@ -20,14 +20,25 @@ let published: EnvelopeDraft[] = [];
const DOMAIN = 'it_quote';
const OBJECT = 'intake';
/** 试跑闸门那一组用的主数据对象(与单据对象同库同任务,才测得出「只挡主数据」) */
const MASTER_DOMAIN = 'it_customer';
const MASTER_OBJECT = 'profile';
const T1 = 't-it-1' as TaskId;
const T2 = 't-it-2' as TaskId;
const T3 = 't-it-3' as TaskId;
const T4 = 't-it-4' as TaskId;
const T5 = 't-it-5' as TaskId;
const T6 = 't-it-6' as TaskId;
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
const SIMULATED = new Set<string>();
const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({
domain: DOMAIN,
object: OBJECT,
// 台账层不关心性质,给个最常见的值即可(分类由安装静态审核)
kind: 'record',
caseKey: 'rfq_ref',
fields,
});
@@ -49,13 +60,21 @@ beforeAll(async () => {
},
clock: { now: () => new Date('2026-07-27T12:00:00Z') },
ids: { next: () => `it-${(counter += 1)}` },
// 这一组用例测的是真库读写,不是试跑闸门 —— 一律当生产单
simulation: { isSimulated: async (taskId) => SIMULATED.has(taskId) },
});
}, 120_000);
afterAll(async () => {
if (pool !== undefined) {
await pool.query(`drop table if exists ledger."${DOMAIN}__${OBJECT}"`).catch(() => undefined);
await pool.query(`delete from ledger.__draft where task_id in ($1, $2)`, [T1, T2]).catch(() => undefined);
await pool.query(`drop table if exists ledger."${MASTER_DOMAIN}__${MASTER_OBJECT}"`).catch(() => undefined);
await pool
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
.catch(() => undefined);
await pool
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6]])
.catch(() => undefined);
await pool.end();
}
await database?.stop();
@@ -172,7 +191,12 @@ describe('ledger · 真实 PG', () => {
expect(official.rows[0]).toMatchObject({ grade: 'C' });
// 草稿已清空:再放行是空操作
expect(await ledger.releaseWrite(T1)).toEqual({ mergedRows: 0, emittedEvents: 0, merged: [] });
expect(await ledger.releaseWrite(T1)).toEqual({
mergedRows: 0,
emittedEvents: 0,
simulatedRows: 0,
merged: [],
});
}, 60_000);
itDb()('数据冲突检查:他单已改同一字段 → 放行写打回,不做部分合并', async () => {
@@ -253,3 +277,97 @@ describe('ledger · 真实 PG', () => {
expect(official.rows[0]).toMatchObject({ grade: 'B', note: '二版' });
}, 60_000);
});
/**
* 试跑单不落主数据 —— 挡的是「落到世界」,不是挡这一单自己看得见。
*
* 判据四条,缺一条这道闸都算白装:
* ① 主数据没进正式表(否则「新客户」样例第二次跑就成了老客户)
* ② 同一单里的**单据照落**(试跑仍是真跑,只挡主数据这一类)
* ③ 被挡下的行仍进 `merged`(出口载荷取的是它,拦了就断了下游)
* ④ 行数如实分开报(放行痕上不许把没进表的算进「合并 N 行」)
*/
describe('ledger · 试跑闸门', () => {
itDb()('试跑单:主数据挡在正式表外,单据照落,出口载荷不缺行', async () => {
await ledger.applyDeclarations([
{
domain: MASTER_DOMAIN,
object: MASTER_OBJECT,
kind: 'master',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false },
{ name: 'name', type: 'text', primaryKey: false, masked: false },
],
},
]);
SIMULATED.add(T5);
// 同一单里两笔:一笔主数据、一笔单据
await ledger.draftWrite({
taskId: T5,
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
key: { customer_code: 'C-SIM' },
patch: { customer_code: 'C-SIM', name: '试跑建的客户' },
});
await ledger.draftWrite({
taskId: T5,
address: { domain: DOMAIN, object: OBJECT },
key: { pn: 'SIM-PN' },
patch: { grade: 'A' },
});
const result = await ledger.releaseWrite(T5);
// ④ 行数分开报
expect(result.simulatedRows).toBe(1);
expect(result.mergedRows).toBe(1);
// ① 主数据一行没进
const master = await ledger.query({
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
where: { customer_code: 'C-SIM' },
orderBy: [],
limit: 10,
offset: 0,
});
expect(master.rows).toEqual([]);
// ② 单据照落
const record = await ledger.query({
address: { domain: DOMAIN, object: OBJECT },
where: { pn: 'SIM-PN' },
orderBy: [],
limit: 10,
offset: 0,
});
expect(record.rows[0]).toMatchObject({ grade: 'A' });
// ③ 出口载荷两行都在
expect(result.merged.map((m) => `${m.domain}.${m.object}`).sort()).toEqual(
[`${DOMAIN}.${OBJECT}`, `${MASTER_DOMAIN}.${MASTER_OBJECT}`].sort(),
);
expect(result.merged.find((m) => m.domain === MASTER_DOMAIN)?.values).toMatchObject({ name: '试跑建的客户' });
}, 60_000);
itDb()('生产单:同一张主数据表照写不误 —— 闸门只对试跑生效', async () => {
await ledger.draftWrite({
taskId: T6,
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
key: { customer_code: 'C-REAL' },
patch: { customer_code: 'C-REAL', name: '真单建的客户' },
});
const result = await ledger.releaseWrite(T6);
expect(result.simulatedRows).toBe(0);
expect(result.mergedRows).toBe(1);
const master = await ledger.query({
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
where: { customer_code: 'C-REAL' },
orderBy: [],
limit: 10,
offset: 0,
});
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
}, 60_000);
});
@@ -26,6 +26,8 @@ const f = (
const decl = (fields: ReturnType<typeof f>[]): ObjectDeclaration => ({
domain: 'quote',
object: 'intake',
kind: 'record',
caseKey: 'rfq_ref',
fields,
});
@@ -125,7 +127,7 @@ describe('mergeDeclarations —— 最大化合并', () => {
it('拒绝混入别的对象', () => {
expect(() =>
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', fields: [f('a', 'text')] }]),
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', kind: 'record' as const, caseKey: 'rfq_ref', fields: [f('a', 'text')] }]),
).toThrow(/只接受同一对象/);
});
});
+18
View File
@@ -69,6 +69,12 @@ export type DraftWrite = {
export type ReleaseWriteResult = {
readonly mergedRows: number;
readonly emittedEvents: number;
/**
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
* 非试跑单恒为 0。
*/
readonly simulatedRows: number;
/**
* 合并进正式版的那些行(键 + 值)。
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
@@ -96,6 +102,18 @@ export type LedgerService = {
systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
*
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
*
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
*/
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
/** 正式版查询 —— 跨任务只见过门的事实 */
@@ -100,12 +100,39 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
return materialized;
},
/*
* 记下对象性质 —— **每次应用声明都写**,不只建表那一次。
* 挂在 create-table 分支上是错的(踩过):表早就建好了,那条分支不走,
* meta 永远是空的,治理台上全部表都成了「无人声明」。
*/
async setObjectMeta(ref, kind, caseKey) {
await runner.query(
`insert into ledger.__object_meta (domain, object, kind, case_key) values ($1,$2,$3,$4)
on conflict (domain, object) do update set kind = excluded.kind, case_key = excluded.case_key`,
[ref.domain, ref.object, kind, caseKey ?? null],
);
},
async listObjects() {
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
const res = await runner.query<{ table_name: string; label: string | null }>(
const res = await runner.query<{
table_name: string;
label: string | null;
kind: string | null;
case_key: string | null;
}>(
/*
* 性质与人读名**同一次查询取回**:治理台要按性质分区,而性质写在活动清单里 ——
* 若让路由每次去把清单捞回来重建,那是 12 个活动 × 全版本清单 ≈ 1.5MB 的 JSON
* 解析(实测热连接 73ms、并发八次 350ms,慢请求还占住浏览器连接连累别的页)。
* 物化的东西记在物化侧:表建出来那一刻性质就定了。
*/
`select t.table_name,
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label,
m.kind, m.case_key
from information_schema.tables t
left join ledger.__object_meta m
on m.domain || '__' || m.object = t.table_name
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
[LEDGER_SCHEMA],
);
@@ -117,11 +144,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
domain: r.table_name.slice(0, idx),
object: r.table_name.slice(idx + 2),
...(r.label === null ? {} : { label: r.label }),
...(r.kind === null ? {} : { kind: r.kind }),
...(r.case_key === null ? {} : { caseKey: r.case_key }),
},
];
});
},
/** 主数据清单 —— 放行写拿它挡试跑单;性质在 `__object_meta`,一次 where 取回 */
async masterRefs() {
const res = await runner.query<{ domain: string; object: string }>(
`select domain, object from ledger.__object_meta where kind = 'master'`,
);
return res.rows.map((r) => ({ domain: r.domain, object: r.object }));
},
async applyChanges(ref, changes) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
/*
@@ -141,6 +178,7 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
: '';
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
/*
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
@@ -341,6 +379,21 @@ export function createDraftStore(runner: Queryable): DraftStore {
],
);
},
/**
* 撤掉本单草稿层的**一行** —— 写错了得有退路。
*
* 只删 `task_id` 那一份:正式版一个字都动不了。草稿层本来就是这一单的过程事实,
* 过程里写错一行是常事;没有退路的后果实测过 ——
* 客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动**:
* 唯一能改的是那个数(改了 skipped_total 就不对),于是 13 次拒回、23 拍、234k token。
*/
async drop(taskId, ref, key) {
await runner.query(
`delete from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 and key_hash = $4`,
[taskId, ref.domain, ref.object, keyOf(key)],
);
},
async clear(taskId) {
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
},
@@ -8,7 +8,7 @@
* 同名字段类型不一致时,以**能兼容双方的最宽类型**落地(放宽即增,不破坏),静态审提示、不阻塞;
* **语义互斥**不可兼容的极端情形才拦下,交数据管理裁决。
*/
import type { FieldDeclaration, LedgerFieldType, ObjectDeclaration } from '@contracts';
import type { FieldDeclaration, LedgerFieldType, LedgerObjectKind, ObjectDeclaration } from '@contracts';
/* ── 类型格:widen(a, b) = 能同时容纳 a 与 b 的最窄类型 ────────────────── */
@@ -74,6 +74,9 @@ export type MergedObject = {
readonly domain: string;
readonly object: string;
readonly label: string | undefined;
/** 对象性质与单标识 —— 随表物化,治理台按它分区(见 0026_object_meta) */
readonly kind: LedgerObjectKind;
readonly caseKey: string | undefined;
readonly fields: readonly MergedField[];
readonly primaryKey: readonly string[];
};
@@ -172,6 +175,13 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
domain: first.domain,
object: first.object,
label: declarations.find((d) => d.label !== undefined)?.label,
/*
* 性质取**首份非默认的声明**:平台声明主数据在前(`master`),
* 活动若也声明同一张表,它只补自己要写的那几列、kind 用的是缺省 `record` ——
* 若拿最后一份覆盖,主数据会被活动的缺省值改回单据。
*/
kind: declarations.find((d) => d.kind !== 'record')?.kind ?? 'record',
caseKey: declarations.find((d) => d.caseKey !== undefined)?.caseKey,
fields,
primaryKey,
},
@@ -17,7 +17,15 @@ import type {
SystemWrite,
} from '../../contract.ts';
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
import type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from '../../port.ts';
import type {
Clock,
DraftStore,
EventPublisher,
IdGenerator,
LedgerStore,
ObjectRef,
SimulationProbe,
} from '../../port.ts';
import { mergeDeclarations, planIsNonDestructive, planSchemaChanges } from '../domain/schema-merge.ts';
import { detectConflicts, keyOf, overlay, pickKey } from '../domain/task-view.ts';
@@ -27,6 +35,8 @@ export type LedgerServiceDeps = {
readonly events: EventPublisher;
readonly clock: Clock;
readonly ids: IdGenerator;
/** 试跑判定 —— 放行写据此把主数据挡在正式表外(见 port.ts `SimulationProbe`) */
readonly simulation: SimulationProbe;
};
function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<string, ObjectDeclaration[]> {
@@ -97,6 +107,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
continue;
}
if (plan.changes.length > 0) await deps.store.applyChanges(ref, plan.changes);
/*
* 性质每次都记 —— 它随声明走,不随建表走。
* 声明合并已经定了口径(平台的 master 优先于活动的缺省 record),这里照写。
*/
await deps.store.setObjectMeta(ref, merge.merged.kind, merge.merged.caseKey);
results.push({
merged: merge.merged,
notices: [...merge.notices, ...plan.notices],
@@ -148,13 +163,51 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
});
},
/**
* 撤掉本单草稿层的一行 —— **只撤自己写过的**。
*
* 回 `false` 表示草稿层里根本没有这一行:那多半是主键写法对不上
* (`DAG…` 与 `DAG…-R0` 是两行),如实说出来比静默成功有用 ——
* 静默成功会让模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
async draftDrop(drop) {
const mine = await deps.drafts.listForObject(drop.taskId, drop.address);
const schema = await requireSchema(drop.address);
const keys = schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(drop.key);
const wanted = pickKey({ ...drop.key }, keys);
const hit = mine.some((d) => keys.every((k) => String((d.key as Record<string, unknown>)[k] ?? '') === String(wanted[k] ?? '')));
if (!hit) return false;
await deps.drafts.drop(drop.taskId, drop.address, wanted);
return true;
},
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, merged: [] };
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
/*
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
*
* 试跑照旧是真跑:过装载两审、过预算、过放行门,单据照写。挡的只有主数据 ——
* 它按业务实体分行,试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
* 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。
* 单据不挡:它按单标识分行,一次试跑写自己那一行,清运行数据就干净了。
*
* 被挡下的行仍然进 `merged`(出口载荷取的就是它)—— 拦的是**落到世界**,
* 不是拦这一单自己看得见:活动缓存里它一直在,这一单该怎么跑还怎么跑。
*/
const simulated = await deps.simulation.isSimulated(taskId);
const masters = new Set<string>();
if (simulated) {
for (const ref of await deps.store.masterRefs()) masters.add(`${ref.domain}.${ref.object}`);
}
const heldBack = (draft: { domain: string; object: string }): boolean =>
simulated && masters.has(`${draft.domain}.${draft.object}`);
let simulatedRows = 0;
const pending: EnvelopeDraft[] = [];
/*
@@ -171,6 +224,23 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (heldBack(draft)) {
simulatedRows += 1;
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
/*
* 值取「表里现有的 + 这一单改的」——没真写进去,读不回 PG 归型后的那一份,
* 所以这里的值是草稿的原样(人的回传天生是字符串)。如实记在这:
* 主数据不进出口载荷,下游拿不到它,归不归型都影响不到谁。
*/
const current = (await store.selectByKeys(ref, [draft.key]))[0];
landed.push({
domain: draft.domain,
object: draft.object,
key: draft.key,
values: { ...(current ?? {}), ...draft.patch },
});
continue;
}
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(
@@ -203,7 +273,12 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
});
for (const envelope of pending) await deps.events.publish(envelope);
return { mergedRows: drafts.length, emittedEvents: pending.length, merged: landed };
return {
mergedRows: drafts.length - simulatedRows,
emittedEvents: pending.length,
simulatedRows,
merged: landed,
};
},
async query(query: LedgerQuery): Promise<LedgerPage> {
+31 -1
View File
@@ -1,7 +1,7 @@
/**
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts';
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
import type { DraftEntry } from './internal/domain/task-view.ts';
@@ -11,6 +11,19 @@ export type IdGenerator = { next(): string };
/** 数据事件的发射口 —— app 注入 `event` 模块的出口(§5.2) */
export type EventPublisher = { publish(envelope: EnvelopeDraft): Promise<void> };
/**
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
*
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
*
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
*/
export type SimulationProbe = { isSimulated(taskId: string): Promise<boolean> };
export type ObjectRef = { readonly domain: string; readonly object: string };
/**
@@ -21,7 +34,16 @@ export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
/**
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
*
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
*/
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
/** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
@@ -39,5 +61,13 @@ export type DraftStore = {
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
/**
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
*
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
*/
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
clear(taskId: string): Promise<void>;
};
+8
View File
@@ -22,6 +22,14 @@
被依赖: toolbox, memory, loop-engine, flow-engine, skill-engine, task, activity-registry,
report(叙述性小结)、app(治理台 · 系统管理路由)
近变(2026-07-30):`ModelCall.reasoning?: 'off' | 'low'` —— **压推理**。
混合推理模型的隐藏思考按秒计:实测 A1 一拍可见出参 1538 字、推理正文 9808 字,
48.6s 里八成五在解码没人读的字;关掉后同一单每拍 1.5-4.9s(十倍差)。
厂商姿势收在 provider-profile 的 `reasoningControl`(DeepSeek:off →
`thinking:{type:'disabled'}`,low → `reasoning_effort:'low'` —— 探针核过;
low 在该厂身上实测无效,推理长度不降);没声明姿势的厂家如实忽略。
回放指纹已带上 reasoning(关思考录的带不被开思考的重放命中)。
不变量:
- **一切模型调用经 `ModelGateway`** —— 绕过它,回放审就对它失明(§12.5#10)
- `replay` 模式**绝不回退实调**:缺录即 `ReplayMissError`
@@ -26,3 +26,19 @@ describe('Provider 画像', () => {
expect(resolveBaseUrl('openai-compatible', undefined)).toBe('');
});
});
describe('压推理的厂商姿势', () => {
/*
* 混合推理模型的隐藏思考按秒计:实测一拍可见出参 1538 字、推理 9808 字,
* 48.6s 里八成五在解码没人读的思考。姿势收在画像里 —— 新厂家只改画像,不加 if。
*/
it('DeepSeek:off 关思考,low 降档 —— 参数名探针核过', () => {
const p = profileOf('deepseek');
expect(p.reasoningControl?.off).toEqual({ thinking: { type: 'disabled' } });
expect(p.reasoningControl?.low).toEqual({ reasoning_effort: 'low' });
});
it('没声明姿势的厂家:调用方的意愿如实忽略,不假装生效', () => {
expect(profileOf('openai').reasoningControl).toBeUndefined();
});
});
+6
View File
@@ -45,6 +45,12 @@ export type ModelCall = {
readonly prompt: string;
readonly temperature?: number;
readonly maxOutputTokens?: number;
/**
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
*/
readonly reasoning?: 'off' | 'low';
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
readonly outputSchema?: Record<string, unknown>;
/** 录制/回放的定位:任务 × 步序号(§7.3) */
@@ -33,6 +33,17 @@ export function createProviderInvoker(): ProviderInvoker {
.filter((line): line is string => line !== undefined && line !== '')
.join('\n\n');
/*
* providerOptions 是**合并体**:JSON 模式与压推理都往请求体里加参,得进同一个对象 ——
* 两处各自 spread 会互相覆盖(踩不得)。
*/
// AI SDK 的 providerOptions 值型是 JSONObject —— 从 generateText 的形参上取,不额外引包
type ProviderBody = NonNullable<Parameters<typeof generateText>[0]['providerOptions']>[string];
const bodyExtra: ProviderBody = {};
if (useJsonMode) bodyExtra['response_format'] = { type: 'json_object' };
const knob = call.reasoning === undefined ? undefined : profile.reasoningControl?.[call.reasoning];
if (knob !== undefined) Object.assign(bodyExtra, knob as ProviderBody);
const started = Date.now();
const result = await generateText({
model: provider(binding.model),
@@ -40,7 +51,7 @@ export function createProviderInvoker(): ProviderInvoker {
prompt: call.prompt,
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
...(useJsonMode ? { providerOptions: { [binding.provider]: { response_format: { type: 'json_object' } } } } : {}),
...(Object.keys(bodyExtra).length === 0 ? {} : { providerOptions: { [binding.provider]: bodyExtra } }),
});
/*
@@ -84,15 +95,51 @@ export function parseJsonLoose(text: string): unknown {
try {
return JSON.parse(candidate);
} catch {
const start = candidate.search(/[[{]/);
const end = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'));
if (start >= 0 && end > start) {
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
return undefined;
}
}
return undefined;
return firstBalanced(candidate);
}
}
/**
* 从文本里取**第一个配平的** JSON 对象 / 数组。
*
* 从前这里取的是「第一个 `{` 到**最后一个** `}`」。模型一次吐两个对象时
* (它把这一拍和下一拍一起写了出来,中间空一行),那一刀切出来的是
* `{…}\n\n{…}` —— 非法 JSON,整段作废,于是基座判「模型未给出合规的行动」,
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
*
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 ——
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。
*/
function firstBalanced(text: string): unknown {
const start = text.search(/[[{]/);
if (start < 0) return undefined;
const open = text[start];
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) {
try {
return JSON.parse(text.slice(start, i + 1));
} catch {
return undefined;
}
}
}
}
return undefined;
}
@@ -19,6 +19,15 @@ export type ProviderProfile = {
readonly structuredOutput: 'json-object' | 'prompt-only';
/** 推理模型:回包里带 reasoning,答案在正文,推理另置(不进出参、只作留痕) */
readonly emitsReasoning: boolean;
/**
* 「压推理」的厂商姿势 —— 调用方说 `reasoning: 'off' | 'low'`,画像译成请求体参数。
*
* 为什么值得一个画像字段:混合推理模型的隐藏思考按 token 计费、按解码计秒 ——
* 实测 A1 一拍可见出参 1538 字、推理正文 9808 字,48.6 秒里八成五在解码没人读的思考;
* 探针实测 DeepSeek `thinking:{type:'disabled'}` 后同一问题 89→9 出参 token、时延减半。
* 没有姿势的厂家(undefined)只能由着它想 —— 调用方的意愿如实忽略,不假装生效。
*/
readonly reasoningControl?: Readonly<Record<'off' | 'low', Record<string, unknown>>>;
readonly label: string;
};
@@ -43,6 +52,11 @@ export const PROVIDER_PROFILES: Readonly<Record<LlmProvider, ProviderProfile>> =
defaultBaseUrl: 'https://api.deepseek.com',
structuredOutput: 'json-object',
emitsReasoning: true,
// 探针核过(2026-07-30):disabled 归零推理;effort 只认 low/medium/high/xhigh/max
reasoningControl: {
off: { thinking: { type: 'disabled' } },
low: { reasoning_effort: 'low' },
},
label: 'DeepSeek',
},
anthropic: {
@@ -24,7 +24,18 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
},
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
/*
* 找「原来那条」要**按 id 也按名字**。
*
* 落库是 `onConflictDoUpdate(target: name)` —— 名字才是那张表的身份。
* 从前只按 id 查:播种走的是无 id 的按名 upsert,于是 `existing` 恒为 undefined、
* `apiKeyCipher` 解析成 undefined、落库写成 null ——
* **一次不带密钥的播种就把治理台里存好的密钥擦掉了**,而界面上只会显示「未绑定」。
* 「只写不读:apiKey 留空即保持原密文」(ADR-009 #4)这句话,得在两条路上都成立。
*/
const existing =
(input.id === undefined ? undefined : await deps.instances.findById(input.id)) ??
(await deps.instances.findByName(input.name));
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
const apiKeyCipher =
input.apiKey !== undefined && input.apiKey !== ''
+60 -2
View File
@@ -11,11 +11,11 @@
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
`AutomaticOnWorldTouchingError`
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
@@ -36,6 +36,64 @@
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 `TURN_ACTION_CAP` = 6):
拍是按模型调用计费的 —— 一拍只许一个行动时,每个 write / finish / start 都单烧一遍
完整上下文,实测 A1 六步的活恰好 21 拍(其中 19 拍是该约束下的最短路径)。
收成一串后同一单 8 拍收口、token 减半。执行纪律引擎强制:按序执行,谁被拒停在谁那儿
(**前缀已生效不回滚**,理由喂回下一拍);`tool` 之后截断(出参没看见,后面的行动是
闭着眼计划的);挂起型(ask/human/listen/submit)落地即收拍;超封顶丢弃并留痕。
- **写台账草稿是过程写的唯一形态**(2026-07-30 新增 `write` 行动,铁律②)。
从前这条路**根本不存在**:`TaskDataGateway.draftWrite` 有端口无调用方,
Agent 干出来的结论一处也放不下 —— 后果不是少个功能,而是必交付物恒零行、
验收判据求值取不到值、出口载荷推不出字段:跑得再热闹,放行那刻什么也交不出。
- 一拍可写**多行**:一份 BOM 一次写完,不一行一拍各烧一次模型;
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
又常驻「当下可为」与「目标缺口」,让 Agent 每拍都看见自己按不动的东西。
现在引擎在发布前自己收(达成留痕);**无前提的里程碑不自达成** —— 那是建模漏了前提。
- **事件监听器不是交付物,是等待岗**(2026-07-30):不进 `gaps`,也不算 autoComplete 的必做项
(从前只排除 `timerEventListener`,一个 `userEventListener` 就让案子永远判不齐)。
- **一拍 = 一个行动 + 一句交代**(2026-07-30 `AgentTurn`)。Agent 从前**不会说话** ——
会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。
实测:A1 跑十几拍、写七行台账、调两次工具,负责人的对话区从头到尾空白,
他只看得见「多了一个任务」。「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
- 交代落 `narration`(它进对话区),与 `thought`(给自己留的痕)分开;
- **不是一型行动,是每一拍的附带**:先做成 `kind:'say'` 试过 —— 它占一整拍又不推进局面,
预算下的 Agent 理所当然跳过它,对话区照旧空白。让说话与干活抢拍,
等于把「必须说」设计成了「有空才说」;
- **先交代再动手**:人看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果;
- `say.view` 可顺手换局面模版 —— 局面变了,人既要听到为什么,也要看到新画面。
- **卡死了交回给人**(2026-07-30 `stalledTurns` + `STALL_LIMIT=5`)。预算会烧完,
但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 —— 那不是在办,是卡死了。
连续 5 拍行动被拒就不再问模型(再问也是同一个答案),发执行卡把**拒绝理由原话**摆给人:
是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同,人得看得见证据。
- **补全卡是对话卡,不是执行工单**(`ask` 行动 / `CardGateway.ask`)。
「按缺口清单追问补全」要的是:缺口清单摆在对话区、带一张表单,人就地填齐。
从前 Agent **发不出对话卡**(只能选执行 / 验收两种工单),SOP 上画着补全卡的那一步
只能拿执行工单顶 —— 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、
办完再回来,重得不成比例。一个格子都没有即拒发(空表单等于没问)。
- **决策卡也是对话卡**(2026-07-31 `decide` 行动 / `CardGateway.decide`)。
与补全卡的分工:补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。
从前决策卡**只有 UI 没有 Agent 侧** —— 卡面在对话区画得出来,行动型里却发不出它,
于是所有岔路口都被写成补全卡:人拿到一张要自己敲字的表单,而不是两个可点的选项。
少于两个选项即拒发(一个选项的「选择题」是通知,不是决策)。
- **视图推送回报推没推上**(`ViewGateway.push` → `{pushed, templates}`)。
从前引擎每拍推一个**硬编码**的 `case-overview`,而活动声明的模版未必叫这个名字
(A1 的四张一个都不叫)—— 模版查不到就静默 return,**每一帧都被悄悄丢掉**,
工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」看起来像「没产出」。
现在:要的那张没有就退到活动声明的第一张,换不上则如实记痕。
- **人答了什么要看得见**:`MemoryRecorder.window` 带回 `detail`,回传值(`detail.values`)
随轨迹进叙事面。不给的话「执行工单回传」这一行读起来像事情办完了,而人填了哪几个值
Agent 一个字看不见 —— 既写不进台账,也只能再问一遍。
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
@@ -180,19 +180,38 @@ describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
describe('navigate —— 导航即交集,断点即人环', () => {
it('有交集即择优推进', () => {
const nav = navigate(
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺报价'], activeStages: [] },
new Set(['a']),
);
expect(nav.kind).toBe('proceed');
});
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
it('交集为空**且手上没开着的活**才是断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], inProgress: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
});
/*
* 断点的定义是「无路可走」。手上还有开着的活就不算 —— 从前只看 Enabled 集,
* 局面推进开以后手上全是 Active,每拍都判成断点,而断点的处置是「调人或立监听」:
* 环于是把没干完的活丢下,去等一个不会来的事件。
*/
it('Enabled 为空但有进行中的项 → 接着干,不是断点', () => {
const nav = navigate(
{
entries: [],
inProgress: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }],
judgements: [],
gaps: ['缺分类表'],
activeStages: [],
},
new Set(['nothing']),
);
expect(nav).toEqual({ kind: 'proceed', candidates: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }] });
});
it('无缺口即收敛', () => {
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
expect(navigate({ entries: [], inProgress: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
});
});
@@ -0,0 +1,176 @@
/**
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
*
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
*/
import { describe, expect, it } from 'vitest';
import { readingsOf } from '../internal/domain/lifecycle.ts';
describe('收步判据的读数', () => {
it('把判据读的那几个值摆出来', () => {
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
missing_key_total: 1,
missing_minor_total: 0,
});
expect(out).toContain('missing_key_total=1');
expect(out).toContain('missing_minor_total=0');
});
it('只摆判据读的,不摆整个任务视图', () => {
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
expect(out).toContain('line_total=3');
expect(out).not.toContain('subject');
expect(out).not.toContain('pn');
});
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
});
it('保留字不当台账名去查', () => {
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
expect(out).toContain('need_sourcing=false');
expect(out).not.toContain('true=');
expect(out).not.toContain('and=');
});
it('字符串字面量里的词不当台账名', () => {
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
});
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
expect(readingsOf('1 = 1', {})).toBe('');
});
});
/**
* 判据名归一要削掉**引擎自己贴的等级标记**。
*
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
*/
import { criterionKey } from '../internal/domain/lifecycle.ts';
describe('判据名归一', () => {
it('模型抄进等级标记也要认得出是同一条', () => {
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
);
});
it('五个等级都削', () => {
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
}
});
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
});
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
});
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
});
});
/**
* 同一句交代不进两遍对话区。
*
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
* 人在对话区看到同一个问题被问三遍,像卡带。
*
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
*/
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
const now = say.trim().replace(/\s+/g, '');
const last = [...trail].reverse().find((t) => t.kind === 'narration');
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
}
describe('对话区不结巴', () => {
const n = (title: string) => ({ kind: 'narration', title });
it('跟上一句一字不差 —— 压下', () => {
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
});
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
});
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
});
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
});
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
});
it('一条叙事都还没有 —— 放行', () => {
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
});
});
/**
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事。
*/
describe('行数判据拒回:连主键名单一起摆', () => {
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
const out = readingsOf('quote_intake_record_rows = line_total', {
quote_intake_record_rows: 10,
line_total: 9,
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
});
expect(out).toContain('quote_intake_record_rows=10');
expect(out).toContain('line_total=9');
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
});
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
quote_intake_type_table_rows: 2,
quote_intake_record_rows: 3,
quote_intake_type_table_keys: 'A、B',
quote_intake_record_keys: 'A、B、C',
});
expect(out).toContain('是这几行:A、B;');
expect(out).toContain('是这几行:A、B、C');
});
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
quote_intake_alt_request_rows: 0,
type_c_total: 0,
});
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
});
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
'(现在:missing_key_total=1)',
);
});
});
@@ -0,0 +1,67 @@
/**
* 阶段进度 —— 进度条的刻度。判据是「这一阶段的计划项**全收完**才算过」。
*/
import { describe, expect, it } from 'vitest';
import type { CasePhase } from '@contracts';
import { CLOSING_PHASE, phaseProgress } from '../internal/domain/phase.ts';
import type { ItemRuntime, ItemState } from '../internal/domain/lifecycle.ts';
/** A1 的三段:定位客户 / 归一受理 / 判定去向;放行那一格由进度条自己加 */
const PHASES: CasePhase[] = [
{ name: '定位客户', items: ['S1_customer'] },
{ name: '归一受理', items: ['S2_normalize'] },
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
];
const items = (states: Record<string, ItemState>): ItemRuntime[] =>
Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
const ALL = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route', 'T_release'];
const at = (done: readonly string[]): ItemRuntime[] =>
items(Object.fromEntries(ALL.map((id) => [id, done.includes(id) ? 'Completed' : 'Available'])));
describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
it('一格都没收:停在第一阶段', () => {
expect(phaseProgress(PHASES, at([]), false)).toEqual({ index: 1, total: 4, name: '定位客户' });
});
it('逐段推进:收完一段才进下一段', () => {
expect(phaseProgress(PHASES, at(['S1_customer']), false)).toMatchObject({ index: 2, name: '归一受理' });
expect(phaseProgress(PHASES, at(['S1_customer', 'S2_normalize']), false)).toMatchObject({
index: 3,
name: '判定去向',
});
});
it('一段里的项没收齐就不算过 —— 三项收两项仍停在这一段', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify'];
expect(phaseProgress(PHASES, at(done), false)).toMatchObject({ index: 3, name: '判定去向' });
});
it('三段都过、还没收口:进最后一格(等放行),但不是终态', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route'];
expect(phaseProgress(PHASES, at(done), false)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('终态直接给最后一格 —— 不去数计划项', () => {
// 放行完成那一瞬 T_release 还没收:去数项的话会显示「还差一格」,而单已经收口了
expect(phaseProgress(PHASES, at([]), true)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('被跳过的裁量项(Terminated)算过 —— 它不会再收,等它等到天亮', () => {
const mixed = items({
S1_customer: 'Completed',
S2_normalize: 'Completed',
S4_history: 'Completed',
S5_classify: 'Terminated',
S6_route: 'Completed',
T_release: 'Available',
});
expect(phaseProgress(PHASES, mixed, false)).toMatchObject({ index: 4, name: CLOSING_PHASE });
});
it('没声明阶段就不给刻度 —— 界面照此不画进度条,不编一个出来', () => {
expect(phaseProgress(undefined, at([]), false)).toBeUndefined();
expect(phaseProgress([], at([]), false)).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
+223 -2
View File
@@ -17,6 +17,7 @@ import type {
TerminalAuthority,
} from './internal/domain/lifecycle.ts';
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
import type { PhaseProgress } from './internal/domain/phase.ts';
export type {
CaseModel,
@@ -31,6 +32,7 @@ export type {
ItemState,
LoopModel,
Navigation,
PhaseProgress,
PlanItem,
PlanItemType,
TerminalAuthority,
@@ -57,8 +59,154 @@ export class AutomaticOnWorldTouchingError extends Error {
export type AgentAction =
/** 调工具:系统调用与 LLM 工具 */
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
/**
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
*
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
*/
| {
readonly kind: 'write';
readonly itemId: string;
/**
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
*
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
*/
readonly address: string;
readonly rows: ReadonlyArray<{
readonly key: Record<string, string | number>;
readonly patch: Record<string, unknown>;
}>;
}
/**
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
*
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
*
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
* 那扇门就只是个死胡同。**
*/
| {
readonly kind: 'drop';
readonly itemId: string;
/** 台账地址「域.对象」—— 与 write 同一种写法 */
readonly address: string;
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
readonly keys: ReadonlyArray<Record<string, string | number>>;
readonly reason: string;
}
/** 调人:四卡之一 —— 调人 = 立监听听人 */
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
/**
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
*
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
* 收卡的人得进待处理、办完再回来,重得不成比例。
*
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
*/
| {
readonly kind: 'ask';
readonly itemId: string;
/** 问什么 —— 一句话说清缺口 */
readonly ask: string;
/**
* 要对方填的格子。`hint` 是格子里的灰字提示;
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
*/
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly hint?: string;
readonly value?: string;
}>;
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
}
/**
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
*
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
* 那一笔该由人亲手落下,不该经你转述一遍。
*
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
*/
| {
readonly kind: 'entry';
readonly itemId: string;
readonly ask: string;
/** 落哪张表:`域.对象` */
readonly ledger: string;
/** 主键值 —— 确认时按它定位那一行 */
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
/**
* **同一次确认还要落的另几张表**(2026-08-02)。
*
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
*/
readonly also?: ReadonlyArray<{
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
readonly title: string;
readonly ledger: string;
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
}>;
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
}
/**
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
*
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
*
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
*/
| {
readonly kind: 'decide';
readonly itemId: string;
/** 问什么 —— 一句话说清这是个什么岔路 */
readonly ask: string;
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
readonly options: ReadonlyArray<{
readonly value: string;
readonly label: string;
readonly detail?: string;
readonly consequence?: string;
readonly recommended?: boolean;
}>;
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
/** 允许人给出选项之外的处置(附理由) */
readonly allowCustom?: boolean;
}
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
/** 启动一个 Enabled 项(manualStart,留痕) */
@@ -69,8 +217,35 @@ export type AgentAction =
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
/** 评估语义性前提(评估留痕,可回放可归因) */
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
/** 判定成果已齐,提交放行 */
| { readonly kind: 'submit'; readonly rationale: string }
/**
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
*
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
*
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
*/
| { readonly kind: 'finish'; readonly itemId: string; readonly rationale: string }
/**
* 判定成果已齐,提交放行。
*
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
*/
| {
readonly kind: 'submit';
readonly rationale: string;
readonly fields?: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly ai?: string;
readonly ledger?: string;
}>;
}
/**
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
@@ -78,6 +253,44 @@ export type AgentAction =
*/
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
/**
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
*
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
*
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
*/
export type Narration = {
readonly text: string;
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
readonly view?: string;
};
/**
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
*
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
*
* 执行纪律(引擎强制,不靠模型自觉):
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
*/
export const TURN_ACTION_CAP = 6;
export type AgentTurn = {
readonly actions: readonly AgentAction[];
readonly say?: Narration;
};
export type TurnOutcome = {
readonly runtime: CaseRuntime;
readonly enabled: EnabledSet;
@@ -94,6 +307,14 @@ export type LoopEngineService = {
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
/**
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
*
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
*/
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
publish(taskId: TaskId): Promise<EnabledSet>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
@@ -3,6 +3,8 @@
*/
export type {
AgentAction,
AgentTurn,
Narration,
CaseModel,
CaseRuntime,
CaseState,
@@ -26,6 +28,7 @@ export {
CmmnParseError,
ForbiddenTerminalError,
IllegalTransitionError,
TURN_ACTION_CAP,
} from './contract.ts';
export type {
@@ -47,9 +50,12 @@ export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
export {
assertItemTransition,
achieveMilestones,
autoCompleteReady,
canTransitionCase,
canTransitionItem,
criterionKey,
readingsOf,
CASE_STATES,
evaluateCriterion,
initialRuntime,
@@ -139,6 +139,17 @@ export type Redlight = { readonly criterion: string; readonly statement: string
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/**
* **进行中的项** —— 已开工、还没交出成果的那些。
*
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
*
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
*/
readonly inProgress: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
@@ -196,6 +207,16 @@ export function publishEnabled(
return {
entries,
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
inProgress: model.items
.filter((item) => byId.get(item.id)?.state === 'Active')
.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
})),
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
/*
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
*/
if (intersection.length === 0) {
return enabled.inProgress.length > 0
? { kind: 'proceed', candidates: enabled.inProgress }
: { kind: 'breakpoint', gaps: enabled.gaps };
}
return { kind: 'proceed', candidates: intersection };
}
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
/*
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
*/
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
/**
* 前提达成即达成的里程碑 —— **不需要谁去按**。
*
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
* 让 Agent 每拍都看到一堆自己按不动的东西。
*
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
*/
export function achieveMilestones(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): { runtime: CaseRuntime; achieved: string[] } {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
let next = runtime;
const achieved: string[] = [];
for (const item of model.items) {
if (item.type !== 'milestone') continue;
if (byId.get(item.id)?.state !== 'Available') continue;
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
if (item.entryCriteria.length === 0) continue;
const met = item.entryCriteria.every(
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
);
if (!met) continue;
next = occur(next, item.id);
achieved.push(item.id);
}
return { runtime: next, achieved };
}
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
),
};
}
/**
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
*
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
*
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
*/
export function criterionKey(raw: string): string {
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
/*
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
*
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
*
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
*/
return tight.replace(/\(l[1-5]\)$/, '');
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
*
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
*/
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
const names = new Set<string>();
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
}
if (names.size === 0) return '';
const readings = [...names].map((name) => {
const value = scope[name];
if (value === undefined || value === null) return `${name}=(没写过)`;
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
});
/*
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
*
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
*/
const rowKeys = [...names]
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
.map((n) => {
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
if (typeof listed !== 'string' || listed === '') return '';
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
})
.filter((s) => s !== '');
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
}
@@ -0,0 +1,50 @@
/**
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
*
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
* 两层分开,是因为它们回答的是两个问题:
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
*/
import type { CasePhase } from '@contracts';
import type { ItemRuntime } from './lifecycle.ts';
export type PhaseProgress = {
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
readonly index: number;
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
readonly total: number;
/** 这一格叫什么 */
readonly name: string;
};
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
export const CLOSING_PHASE = '放行收口';
/**
* 算这一单在第几阶段。
*
* 判据是「这一阶段的计划项**全部收完**」——
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
*
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
*/
export function phaseProgress(
phases: readonly CasePhase[] | undefined,
items: readonly ItemRuntime[],
terminal: boolean,
): PhaseProgress | undefined {
if (phases === undefined || phases.length === 0) return undefined;
const total = phases.length + 1;
if (terminal) return { index: total, total, name: CLOSING_PHASE };
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
for (const [at, phase] of phases.entries()) {
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
}
return { index: total, total, name: CLOSING_PHASE };
}
File diff suppressed because it is too large Load Diff
+150 -7
View File
@@ -4,7 +4,7 @@
import type { LoopModel, StaffId, TaskId } from '@contracts';
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
import type { AgentAction } from './contract.ts';
import type { AgentTurn } from './contract.ts';
export type Clock = { now(): Date };
@@ -32,13 +32,47 @@ export type Sfeel = {
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
export type TaskDataGateway = {
view(taskId: TaskId): Promise<Record<string, unknown>>;
/**
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
*
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
*/
writable(taskId: TaskId): Promise<
Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{ name: string; label: string; type?: string }>;
}>
>;
/**
* 写草稿,并回报**这一笔到底有没有改变什么**。
*
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
*/
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<void>;
}): Promise<{ changed: boolean }>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路。
*
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
draftDrop(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
}): Promise<{ dropped: boolean }>;
};
/**
@@ -52,7 +86,23 @@ export type AgentPort = {
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
}): Promise<AgentAction>;
/**
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
*
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
*/
tools: Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
writable: Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{ name: string; label: string; type?: string }>;
}>;
}): Promise<AgentTurn>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
@@ -61,9 +111,21 @@ export type ToolGateway = {
ok: boolean;
output?: Record<string, unknown>;
error?: { message: string };
/**
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
*
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
*/
awaitingHuman?: { readonly workOrderId: string };
}>;
isWriteEffect(tool: string): Promise<boolean>;
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
busOf(activityId: string): Promise<
Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>
>;
};
/**
@@ -77,6 +139,24 @@ export type PendingAcceptance = {
};
export type CardGateway = {
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
entry(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
also?: ReadonlyArray<{
title: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}): Promise<{ id: string }>;
issue(input: {
taskId: TaskId;
ownerId: StaffId;
@@ -84,6 +164,42 @@ export type CardGateway = {
workOrder: 'execution' | 'acceptance';
card: Record<string, unknown>;
}): Promise<{ id: string }>;
/**
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
*
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
*/
ask(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
fields: ReadonlyArray<{ key: string; label: string; hint?: string }>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}): Promise<{ id: string }>;
/**
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
*
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
*/
decide(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
options: ReadonlyArray<{
value: string;
label: string;
detail?: string;
consequence?: string;
recommended?: boolean;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
allowCustom?: boolean;
}): Promise<{ id: string }>;
};
export type ListenerRegistrar = {
@@ -102,12 +218,18 @@ export type ListenerRegistrar = {
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
*/
export type ViewGateway = {
/**
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
*/
push(input: {
taskId: TaskId;
activityId: string;
template: string;
scope: Record<string, unknown>;
}): Promise<void>;
}): Promise<{ pushed: boolean; templates: string[] }>;
};
/**
@@ -124,16 +246,37 @@ export type SkillGateway = {
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
window(taskId: TaskId): Promise<Array<{ kind: string; title: string }>>;
/**
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
*
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
*/
window(
taskId: TaskId,
size?: number,
): Promise<Array<{ kind: string; title: string; detail?: Record<string, unknown> | undefined }>>;
};
export type TaskContext = {
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
/**
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
*
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
*/
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
};
@@ -86,15 +86,20 @@ describe('structuredLog —— 零 LLM,可重算', () => {
const entries = [
entry('thought', { seq: 1 }),
entry('action', { seq: 2 }),
entry('card-sent', { seq: 3 }),
entry('card-reply', { seq: 4 }),
entry('release-verdict', { seq: 5 }),
// 机器产出侧留一条**该进**的(交代进展)—— 动作那一类整类退出后,
// 没有它这一侧就空了,「归侧分栏」那条用例也就断言不到东西
entry('narration', { seq: 3 }),
entry('card-sent', { seq: 4 }),
entry('card-reply', { seq: 5 }),
entry('release-verdict', { seq: 6 }),
];
it('只取骨架条目,思考不入日志', () => {
it('只取骨架条目:思考与动作都不入日志', () => {
const lines = structuredLog(entries);
expect(lines).toHaveLength(4);
expect(lines.some((l) => l.title.startsWith('思考'))).toBe(false);
// 动作(调工具 / 写草稿 / 技能结晶)是机制,不是产出 —— 整类不进
expect(lines.some((l) => l.title.startsWith('action'))).toBe(false);
});
it('两次生成完全一致 —— 口径与源恒一致', () => {
@@ -201,11 +206,73 @@ describe('归侧:治理干预', () => {
entry('control', { seq: 1, title: '控制事件:halt' }),
entry('engine-transition', { seq: 2, title: '步 1 完成' }),
]);
expect(lines.map((l) => l.title)).toEqual(['治理:控制事件:halt', '迁移:步 1 完成']);
/*
* 前缀**全部退休**(2026-07-31 第二轮):「治理:」「迁移:」这些是拿前缀
* 兼职「这是什么动作」。动作现在有自己一格(四字章),内容只留内容。
* 两者仍分得出,而且分得更清楚 ——
* 动作:治理干预 vs 状态推进;主语:人(按下的那位)vs 系统。
*/
expect(lines.map((l) => l.title)).toEqual(['控制事件:halt', '步 1 完成']);
expect(lines.map((l) => l.action)).toEqual(['治理干预', '状态推进']);
expect(lines.map((l) => l.actor)).toEqual(['', '系统']); // '' = 人,名字由上层按 actorId 换
expect(lines.map((l) => l.sideLabel)).toEqual(['人裁决', '机器产出']);
});
});
/**
* 日志的呈现口径(2026-07-31 用户定):**类型是四字短语,内容有主语**。
*
* 「四字」不是排版洁癖:它逼着每一格只说一件事。从前这一格摆归侧
* (机器产出 / 系统等待),那答的是「算谁的工时」——同一个「系统等待」下面
* 既有派工单也有发录入卡,人扫一眼说不出这一步到底干了什么。
*/
describe('日志:四字动作 + 主语', () => {
it('动作与对话区同词,人点确认那一下叫「员工确认」', () => {
const lines = structuredLog([
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
entry('narration', { seq: 2, title: '这封邮件来自青岛海洋电子', detail: { tag: 'conclusion' } }),
entry('card-sent', { seq: 3, title: '这是位新客户,请核对建档信息', detail: { k: 'entry' } }),
entry('card-reply', { seq: 4, title: '写入 customer.profile', detail: { k: 'entry', by: 's-1' } }),
entry('card-sent', { seq: 5, title: '补齐三条料的品牌', detail: { k: 'elicitation' } }),
entry('card-sent', { seq: 6, title: '请去询三家', detail: { mode: 'execution' } }),
entry('human-input', { seq: 7, title: '看过了邮件', detail: { act: 'read-comm', by: 's-1' } }),
]);
expect(lines.map((l) => l.action)).toEqual([
'触发事件',
'阶段结论',
'数据录入',
'员工确认', // ← 用户点名的那一个
'请你补全',
'派出工单',
'员工阅件',
]);
// 每一条都有四个字 —— 少一个字多一个字,那一列就参差不齐
for (const line of lines) expect(line.action).toHaveLength(4);
});
it('主语三种:任务助手 / 系统 / 具体某人(域里只给 id,名字上层换)', () => {
const lines = structuredLog([
entry('trigger', { seq: 1 }),
entry('narration', { seq: 2 }),
entry('card-reply', { seq: 3, detail: { k: 'entry', by: 's-7' } }),
]);
expect(lines.map((l) => l.actor)).toEqual(['系统', '任务助手', '']);
expect(lines.map((l) => l.actorId)).toEqual([undefined, undefined, 's-7']);
});
it('内容不带主语、不带前缀 —— 主语由上层拼,前缀早该退休', () => {
const lines = structuredLog([
entry('card-sent', { seq: 1, title: '这是位新客户', detail: { k: 'entry' } }),
entry('ledger-write', { seq: 2, title: '放行写合并 3 行' }),
entry('terminal', { seq: 3, title: 'released' }),
]);
// 系统名换人话:「放行写合并」是引擎自己的说法,「released」更是机器状态
expect(lines.map((l) => l.title)).toEqual(['这是位新客户', '成果落账,共 3 行', '这一单已放行完成']);
expect(lines.map((l) => l.action)).toEqual(['数据录入', '写入台账', '任务收尾']);
});
});
describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
it('终态、人兜底、驳回各归纳一条,且都带证据链', () => {
const drafts = deterministicDistill([
@@ -229,3 +296,247 @@ describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
expect(deterministicDistill([])).toEqual([]);
});
});
/**
* 工作日志是**给人看的** —— 引擎自己的机械动作不该占版面(2026-07-31 用户定)。
*
* 从前每条留痕都往日志里摆:「装配技能 intake.read-rfq@2.0.0」「装载案例模型(6 个计划项)」
* 每单一模一样,说不出这一单的任何事;工具调用那一条更是废话 ——
* 人要的是**产出结果**,而出参另有一条 result 记着。
*/
describe('工作日志只留人看得懂的', () => {
it('装配技能 / 装载案例模型 —— 机械动作不进日志', () => {
const lines = structuredLog([
entry('thought', { seq: 1, title: '装配技能:intake.read-rfq@2.0.0' }),
entry('engine-transition', { seq: 2, title: '装载案例模型:客户RFQ受理(6 个计划项)' }),
entry('engine-transition', { seq: 3, title: '完成:定位客户信息' }),
]);
// 「完成:」这类前缀由动作那一格接管,内容只留内容
expect(lines.map((l) => l.title)).toEqual(['定位客户信息']);
expect(lines.map((l) => l.action)).toEqual(['完成一项']);
});
/*
* **工具那一整类都不进**(2026-08-02 用户定,翻了上一版口径)。
*
* 上一版留着「出参」那一条,说「人要的是结果不是过程」。可出参本身也是过程:
* 「crm.history-price-read 返回」「行动被拒:…」—— 前者是一句机器话,
* 后者是 AI 与引擎较劲的踪迹,人既看不懂也帮不上忙。
* **成果在台账里、在成果报告里**;工作日志要的是「每一步办完了什么」。
* 排障的人照旧看得到 —— 那些痕一条没少,只是不进这一屏。
*/
it('工具的调用与出参**都不进** —— 工作日志给人看的是每一步的成果', () => {
const lines = structuredLog([
entry('action', { seq: 1, title: 'crm.history-price-read', detail: { tool: 'crm.history-price-read' } }),
entry('result', { seq: 2, title: 'crm.history-price-read 返回' }),
entry('result', { seq: 3, title: '行动被拒:台账地址没声明过' }),
entry('engine-transition', { seq: 4, title: '完成:查历史价' }),
]);
expect(lines.map((l) => l.title)).toEqual(['查历史价']);
});
/*
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
* 引擎留痕时顺手写了内部标识:`manualStart:S3_confirm`、`终态:released`、
* `落地收敛,发射输出口:intake、to-alternative` —— 那是给排障看的机器话,
* 摆在人的日志上等于没写。
*/
it('系统名换成人话:开始办 / 已放行完成 / 成果落账', () => {
const lines = structuredLog([
entry('engine-transition', { seq: 1, title: 'manualStart:T_release' }),
entry('release-verdict', { seq: 2, title: '裁决:pass' }),
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-alternative' }),
entry('engine-transition', { seq: 4, title: '去向已算出:intake-route → to-alternative(…_type_tag_c > 0)' }),
entry('engine-transition', { seq: 5, title: '落地收敛:合并 23 行,发射输出口 intake、to-alternative' }),
entry('terminal', { seq: 6, title: '终态:released' }),
]);
/*
* 一条英文都不许漏到人的日志上 —— 计划项号、口名、判据表达式都是给排障看的。
* 那两条「落地收敛」不在这份名单里:**它们是同一件事的复述**,
* 说得出实情的那一条(放行写合并,带「几行没进正式表」)已经把话说全了。
*/
expect(lines.map((l) => l.title)).toEqual([
'开始办这一步',
'验收通过',
'去向已算出,随放行转入下游',
'这一单已放行完成',
]);
expect(lines.every((l) => !/[A-Za-z_]{4,}/.test(l.title))).toBe(true);
});
/*
* 这一条**翻过来了**(2026-07-31 第二轮,用户定)。
* 原来的口径是「写台账那一条照进 —— 它本身就是产出」;
* 可产出是**台账里那几行**,人在工作成果与台账里看得见,
* 「写入草稿:quote_intake.record 3 行(3 行有变化)」说的是引擎写了一笔,
* 「几行有变化」更是引擎自己的账 —— 对人说不出任何事。
*/
it('写台账那一条也**不进** —— 产出是台账里那几行,不是「我写了一笔」', () => {
const lines = structuredLog([
entry('action', {
seq: 1,
title: '写草稿:quote_intake.record 3 行',
detail: { ledger: 'quote_intake.record', rows: 3 },
}),
// 「行动被拒」记在 result —— 同样不进(2026-08-02):那是 AI 与引擎较劲的踪迹
entry('result', { seq: 2, title: '行动被拒:台账地址没声明过' }),
]);
expect(lines).toEqual([]);
});
/* 系统事件本来一条都不在日志里 —— 而那恰是人最想回看的「这单怎么起来的」 */
it('系统事件进日志;引擎给自己看的内部观察不进', () => {
const lines = structuredLog([
entry('observation', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责' }),
entry('observation', { seq: 2, title: 'S2 已在进行中 —— 无需再启动', detail: { internal: true } }),
]);
expect(lines.map((l) => l.title)).toEqual(['一封邮件触发了这条流程,系统指定你负责']);
});
});
/**
* 触发事件自成一侧 —— 它既不是机器产出也不是员工动作。
*
* 从前起单那句记成 `observation`,归侧推成「机器产出」——
* 而机器什么也没产出:一封信到了、系统把这一环指给了你,那是**外面发生的事**。
* 混进机器那一侧还会把自主率算歪:分母里多了一条谁也没干的事。
*/
describe('触发事件', () => {
it('归侧是 trigger,不是 machine', () => {
expect(deriveSide('trigger')).toBe('trigger');
expect(deriveSide('observation')).toBe('machine');
});
it('进结构化日志,且不加前缀 —— 它本来就是一句人话', () => {
const lines = structuredLog([
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。' }),
]);
expect(lines).toHaveLength(1);
expect(lines[0]?.title).toBe('一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。');
expect(lines[0]?.sideLabel).toBe('触发事件');
});
/* 自主率的分子分母只认机器与员工 —— 触发既不该进分子,也不该进分母 */
it('不进自主率的分子分母', () => {
const split = splitBySide(
structuredLog([
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
entry('narration', { seq: 2, title: 'AI 干了一件事' }),
]),
);
expect(split.machine).toHaveLength(1);
expect(split.human).toHaveLength(0);
expect(split.trigger).toHaveLength(1);
});
});
/**
* **日志要看得出人和 AI 分别干了啥**(2026-08-02 用户定)。
*
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
* 「完成一项」「开始办」是**AI 报的**(它 finish / start,引擎照办);
* 「起单」「自动收步」是引擎自己做主的。全署系统,人就看不出这一单里
* AI 到底干了什么、自己干了什么 —— 而那正是他翻这一屏想知道的事。
*/
describe('日志分得清谁干的', () => {
it('AI 报的完成 / 开始办记在「任务助手」名下', () => {
const lines = structuredLog([
entry('engine-transition', { seq: 1, title: '完成:定位客户信息' }),
entry('engine-transition', { seq: 2, title: '开始办:受理需求交人审定', detail: { by: 'agent' } }),
]);
expect(lines.map((l) => l.actor)).toEqual(['任务助手', '任务助手']);
});
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
const lines = structuredLog([
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }),
]);
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
});
it('**那一步的名字要留着** —— 只说「开始办这一步」等于没说办的是哪一步', () => {
const lines = structuredLog([entry('engine-transition', { seq: 1, title: '开始办:受理需求交人审定' })]);
expect(lines[0]?.title).toBe('开始办:受理需求交人审定');
});
});
/**
* **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
*
* 归侧 `side` 有五种,那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
* 而人扫这一屏只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
*/
describe('日志三档:谁干的', () => {
it('负责人办的归 owner —— 回卡、就地修订、放行裁决', () => {
const lines = structuredLog([
entry('card-reply', { seq: 1, title: '补齐了两颗料的制造商', detail: { by: 's-1' } }),
entry('human-input', { seq: 2, title: '就地修订成果:9 行', detail: { by: 's-1' } }),
entry('release-verdict', { seq: 3, title: '裁决:pass', detail: { by: 's-1' } }),
]);
expect(lines.map((l) => l.who)).toEqual(['owner', 'owner', 'owner']);
});
it('AI 办的归 agent —— 完成一项、开始办、交代进展、派卡', () => {
const lines = structuredLog([
entry('engine-transition', { seq: 1, title: '完成:查历史价' }),
entry('narration', { seq: 2, title: '九条料都拆好了', detail: { tag: 'conclusion' } }),
entry('card-sent', { seq: 3, title: '受理需求审定', detail: { mode: 'execution' } }),
]);
expect(lines.map((l) => l.who)).toEqual(['agent', 'agent', 'agent']);
});
it('流程自己走到的归 system —— 起单与触发不是谁办的', () => {
const lines = structuredLog([
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
entry('trigger', { seq: 2, title: '一封邮件触发了这条流程' }),
entry('terminal', { seq: 3, title: '终态:released' }),
]);
expect(lines.map((l) => l.who)).toEqual(['system', 'system', 'system']);
});
it('**只有这三档** —— 多一档就是屏上多一种颜色,而那一种说不出新东西', () => {
const lines = structuredLog([
entry('trigger', { seq: 1, title: '起' }),
entry('engine-transition', { seq: 2, title: '完成:一步' }),
entry('human-input', { seq: 3, title: '改了一格', detail: { by: 's-1' } }),
]);
expect([...new Set(lines.map((l) => l.who))].sort()).toEqual(['agent', 'owner', 'system']);
});
});
describe('放行那一刻的日志尾巴', () => {
/** 留痕那一侧三条都在(排障分得清谁做的);日志这一侧只说一遍 */
const tail = () =>
structuredLog([
entry('release-verdict', { seq: 1, title: '裁决:pass', detail: { by: 's-1' } }),
entry('ledger-write', { seq: 2, title: '放行写合并 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)' }),
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-sourcing' }),
entry('engine-transition', { seq: 4, title: '落地收敛:合并 10 行,发射输出口 intake、to-sourcing' }),
entry('terminal', { seq: 5, title: '终态:released' }),
]);
it('**「落账了」只说一遍** —— 四行说同一件事,人读的是日志不是留痕', () => {
const said = tail().map((l) => l.title);
expect(said.filter((t) => t.includes('落账'))).toHaveLength(1);
expect(said).toEqual(['验收通过', '成果落账,共 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)', '这一单已放行完成']);
});
it('留的是**说得出实情的那一条** —— 几行没进正式表,只有它讲得出来', () => {
expect(tail()[1]?.title).toContain('未落正式表');
});
it('放行合并归系统,不归 AI —— 记在它名下会让人以为是它自作主张写了正式表', () => {
expect(tail()[1]?.who).toBe('system');
});
it('**人工修订归人** —— 同一类留痕,带了他的 id 就是他改的', () => {
const [line] = structuredLog([
entry('ledger-write', { seq: 1, title: '人工修订:目标价 → 0.31(落 quote_intake.record)', detail: { by: 's-9' } }),
]);
expect(line?.who).toBe('owner');
expect(line?.actorId).toBe('s-9');
expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)');
});
});
@@ -15,19 +15,62 @@ export type LogLine = {
readonly at: string;
readonly side: AttributionSide;
readonly sideLabel: string;
/**
* **这一条是谁干的** —— 日志只认三档(2026-08-02 用户定):
* `owner` 负责人(绿)· `agent` 智能体(蓝)· `system` 系统(灰)。
*
* 归侧 `side` 有五种(machine / human / human-verdict / world-wait / trigger),
* 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
* 而人扫这一屏时只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
*/
readonly who: 'owner' | 'agent' | 'system';
/**
* **类型章:四字短语**,与对话区同词同粒度(2026-07-31 用户定)。
*
* 从前这一格摆的是归侧(机器产出 / 系统等待 …)—— 那是**度量的口径**,
* 答的是「这一步该算谁的工时」;人翻日志想知道的是「这一步是什么动作」。
* 两者不是一回事:同一个「系统等待」下面既有派工单也有发录入卡。
* 归侧仍在(`side` / `sideLabel`),度量与配色照旧靠它,只是不再占着这一格。
*/
readonly action: string;
/** 主语:任务助手 / 系统;人做的那些留空,由上层照 `actorId` 换成名字 */
readonly actor: string;
/** 人做的那一条是谁做的 —— 域里不认识人名(纯函数零 IO),只交出 id */
readonly actorId: string | undefined;
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
readonly title: string;
readonly summary: string | undefined;
readonly eventRef: string | undefined;
readonly position: string | undefined;
};
/** 骨架:只有这些种类进结构化日志(OS 8.1 列举) */
/**
* 骨架:哪些种类进结构化日志(OS 8.1 列举)。
*
* **工作日志是给人看的**(2026-07-31 用户定)——「装配技能 xxx@2.0.0」
* 「装载案例模型(6 个计划项)」这类是引擎自己的机械动作,人看了不知道该拿它怎么办;
* 工具调用的过程同理:**人要的是产出结果,不是调用细节**。
* 所以骨架之外还有一道 `machinery()` 过滤(见下),两道一起才干净。
*
* `observation` 是**新进来的**:系统事件(一封信触发了这条流、邮件确认、
* 人把来件标了已读)本来一条都不在日志里 —— 而那恰恰是人最想回看的
* 「这单怎么起来的、谁在什么时候看过」。
*
* `action` **整类退出**(2026-07-31 第二轮,用户定)。它只有三个来源,全是机制:
* 调工具、写草稿、技能结晶。「写入草稿:quote_intake.record 3 行(3 行有变化)」
* 对人说不出任何事 —— 写了什么在工作成果与台账里看得见,「几行有变化」是引擎的账。
* 从前只滤掉带 `tool` 的那一半,留着写草稿的另一半,理由是「它本身就是产出」;
* 可产出是**台账里那几行**,不是「我写了一笔」这个动作。
* 「为什么不行」不在这一类里:行动被拒、提交被拒、换画面失败都记在 `result`,照进。
*/
const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
'trigger',
'observation',
'engine-transition',
'action',
'result',
'card-sent',
'card-reply',
'narration',
'ledger-write',
'action-intent',
'release-verdict',
@@ -36,32 +79,268 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
'control',
]);
/**
* 内容取原文 —— **不加前缀**。
*
* 从前每种各挂一个前缀(`发出:` `回传:` `写入:` `终态:` …),那是在用前缀
* 兼职「这是什么动作」。现在动作有了自己的一格(`action`),前缀就成了重复:
* 「[数据录入] 发出:这是位新客户…」——「发出」两个字白占地方。
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
*/
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
observation: (e) => `观察:${e.title}`,
thought: (e) => `思考:${e.title}`,
action: (e) => `调用:${e.title}`,
result: (e) => `返回:${e.title}`,
'engine-transition': (e) => `迁移:${e.title}`,
evaluation: (e) => `求值:${e.title}`,
'card-sent': (e) => `发出:${e.title}`,
'card-reply': (e) => `回传:${e.title}`,
'ledger-write': (e) => `写入:${e.title}`,
'action-intent': (e) => `动作意向:${e.title}`,
'release-verdict': (e) => `放行裁决:${e.title}`,
terminal: (e) => `终态:${e.title}`,
'human-input': (e) => `留言:${e.title}`,
control: (e) => `治理:${e.title}`,
observation: (e) => e.title,
thought: (e) => e.title,
action: (e) => actionEntryOf(e).body,
result: (e) => e.title,
'engine-transition': (e) => plain(transitionOf(e.title).body),
evaluation: (e) => e.title,
'card-sent': (e) => e.title,
'card-reply': (e) => e.title,
'ledger-write': (e) => plain(e.title),
'action-intent': (e) => e.title,
'release-verdict': (e) => plain(e.title),
terminal: (e) => plain(e.title),
'human-input': (e) => e.title,
narration: (e) => e.title,
control: (e) => e.title,
trigger: (e) => e.title,
};
/**
* 四字动作 —— **对话区说什么词,这里就是什么词**。
*
* 对齐的出处是 `chat-bubbles.tsx` 的三张表:AI 的类型章(`TAG_UI`)、
* 系统气泡的章(`SYS_TAG`)、各卡卡头的类型名。同一件事在两屏用两个说法,
* 人得在脑子里做一次翻译 —— 而这本来是同一件事。
*/
const CARD_ACTION: Readonly<Record<string, string>> = {
elicitation: '请你补全',
decision: '需要你定',
entry: '数据录入',
rating: '任务评价',
execution: '派出工单',
acceptance: '请你验收',
};
/** 人回传:一律「员工 X X」句式 —— 点确认那一下就是**员工确认**(用户定) */
const REPLY_ACTION: Readonly<Record<string, string>> = {
entry: '员工确认',
elicitation: '员工补全',
decision: '员工决策',
rating: '员工评价',
execution: '员工办结',
acceptance: '放行裁决',
};
/** 叙述的三种签,与对话区 `TAG_UI` / `SAY_UI` 同词 */
const NARRATION_ACTION: Readonly<Record<string, string>> = {
conclusion: '阶段结论',
guidance: '交代进展',
criterion: '请你验收',
};
/**
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
*
* 工作日志是给人看的。而引擎留痕时顺手把内部标识写了进去:
* `manualStart:S3_confirm`、`终态:released`、`发射输出口:intake、to-alternative`——
* 那是给排障的人看的机器话,摆在人的日志上等于没写。
* 这里逐条换成人话;认不出的原样留着(宁可笼统,不可编)。
*/
const PLAIN: ReadonlyArray<{ readonly re: RegExp; readonly to: string }> = [
/*
* **计划项号对人是天书**:`manualStart:S3_confirm`、`开始办:T_release` ——
* 那一格里唯一的信息是「引擎开始办某一步了」,而哪一步下一行的「完成一项」会说清。
* 与其摆一个 id,不如只说这件事本身。
*/
// 老留痕里还有 `manualStart:S3_confirm` 这种(新的已经写名字了)—— 认不出名字就只说这件事
{ re: /^manualStart[::]\s*[A-Za-z0-9_]+$/, to: '开始办这一步' },
{ re: /^manualStart[::]\s*/, to: '开始办:' },
// 去向那一条:口名与判据表达式都是给排障看的;人要知道的是「转到哪儿」
{ re: /^去向已算出[::].*→\s*([a-z-]+).*$/, to: '去向已算出,随放行转入下游' },
// 终态有两种写法:带「终态:」前缀的,与裸状态名 —— 两种都得认,漏一种就露一个英文
{ re: /^(终态[::]\s*)?released$/, to: '这一单已放行完成' },
{ re: /^(终态[::]\s*)?blocked$/, to: '这一单卡住了,没能落地' },
{ re: /^(终态[::]\s*)?rejected$/, to: '这一单被退回了' },
{ re: /^(终态[::]\s*)?exhausted$/, to: '这一单预算用尽,停下了' },
{ re: /^裁决[::]\s*pass$/, to: '验收通过' },
{ re: /^裁决[::]\s*redo$/, to: '验收退回重做' },
{ re: /^放行写合并\s*/, to: '成果落账,共 ' },
{ re: /^人工修订[::]\s*/, to: '你改的那一笔已落账:' },
];
/** 把一行里的系统名换成人话 —— 认不出就原样留着 */
function plain(title: string): string {
for (const p of PLAIN) if (p.re.test(title)) return title.replace(p.re, p.to);
return title;
}
/**
* 引擎流转的措辞由引擎自己拼(`起单:xxx` / `完成:xxx` …)。
* 认出前缀是为了两件事:动作那一格说得具体,以及**把前缀从内容里摘掉** ——
* 章上已经写着「任务起单」,内容再来一遍「起单:」就是同一句话说两遍。
* 认不出的一律「状态推进」,内容原样留着:宁可笼统,不可编。
*/
const TRANSITION: ReadonlyArray<{ readonly prefix: string; readonly action: string }> = [
{ prefix: '起单:', action: '任务起单' },
{ prefix: '完成:', action: '完成一项' },
{ prefix: '自动收步:', action: '自动收步' },
{ prefix: '立监听:', action: '立起监听' },
{ prefix: '驳回重入:', action: '驳回重入' },
];
function transitionOf(title: string): { action: string; body: string } {
for (const t of TRANSITION) {
if (title.startsWith(t.prefix)) return { action: t.action, body: title.slice(t.prefix.length) };
}
return { action: '状态推进', body: title };
}
/** action 条目有两种:写草稿与调工具 —— 靠 detail 上的 `ledger` 记号分,不靠措辞 */
function actionEntryOf(e: MemoryEntry): { action: string; body: string } {
if (e.detail?.['ledger'] === undefined) return { action: '调用工具', body: e.title };
const p = '写草稿:';
return { action: '写入草稿', body: e.title.startsWith(p) ? e.title.slice(p.length) : e.title };
}
function actionOf(e: MemoryEntry): string {
const d = e.detail ?? {};
const cardKey = String(d['k'] ?? d['mode'] ?? '');
switch (e.kind) {
case 'trigger':
return '触发事件';
case 'observation':
return '系统事件';
case 'engine-transition':
return transitionOf(e.title).action;
case 'action':
return actionEntryOf(e).action;
case 'narration':
return NARRATION_ACTION[String(d['tag'] ?? '')] ?? '阶段结论';
case 'card-sent':
return CARD_ACTION[cardKey] ?? '发出卡片';
case 'card-reply':
return REPLY_ACTION[cardKey] ?? '员工回传';
// 人在界面上的动作:点确认 = 员工确认,看来件 = 员工阅件
case 'human-input':
return d['act'] === 'read-comm' ? '员工阅件' : '员工确认';
case 'ledger-write':
return '写入台账';
case 'result':
return '工具产出';
case 'action-intent':
return '动作意向';
case 'release-verdict':
return '放行裁决';
case 'terminal':
return '任务收尾';
case 'control':
return '治理干预';
case 'thought':
return '模型思考';
case 'evaluation':
return '判据求值';
}
}
/**
* 主语 —— 日志每一行都要有主语,不然是一串没头脑的流水账。
*
* 三种:**任务助手**(AI 干的)、**系统**(基座干的:起单、事件到达、收尾)、
* 以及**具体某个人**。人名要查库,域里查不了,所以这里只交出 `actorId`,
* 由上层换成名字;换不到就退回「员工」,不编名字。
*/
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
const by = e.detail?.['by'];
const id = typeof by === 'string' && by !== '' ? by : undefined;
switch (e.kind) {
case 'trigger':
case 'observation':
case 'terminal':
return { actor: '系统', actorId: undefined, who: 'system' };
/*
* **AI 办完的事要记在 AI 名下**(2026-08-02 用户:日志「要能看出人和 AI 分别干了啥」)。
*
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
* · 「完成一项」「开始办」——**AI 报的**(它 finish / start,引擎照办);
* · 「起单」「自动收步」「驳回重入」—— 引擎自己做主的。
* 全署系统,人就看不出这一单里 AI 到底干了什么、自己干了什么 ——
* 而那正是他翻这一屏想知道的事。留痕上带 `by: 'agent'` 的从这里认。
*/
case 'engine-transition':
return e.detail?.['by'] === 'agent' || /^(完成|开始办)/.test(e.title)
? { actor: '任务助手', actorId: undefined, who: 'agent' }
: { actor: '系统', actorId: undefined, who: 'system' };
case 'card-reply':
case 'human-input':
case 'release-verdict':
case 'control':
return { actor: '', actorId: id, who: 'owner' };
/*
* 台账写入有两种,主语不同:**放行合并**是基座在人签完字之后做的一次落账
* (记在 AI 名下会让人以为是它自作主张写了正式表);**人工修订**是人自己改的
* (留痕带上了他的 id)。认 id,不认措辞。
*/
case 'ledger-write':
return id === undefined ? { actor: '系统', actorId: undefined, who: 'system' } : { actor: '', actorId: id, who: 'owner' };
default:
return { actor: '任务助手', actorId: undefined, who: 'agent' };
}
}
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
/**
* **机械动作** —— 进得了骨架,但人看了没有用处的那些。
*
* 判据是「这一条能不能让人做出判断」:
* · 装配技能 / 装载案例模型 —— 引擎每单必做,一模一样,说不出这一单的任何事;
* · `internal` 标的观察(已在进行中 / 同拍余下作废)—— 引擎给自己看的踪迹;
* · 人事件的到达 —— 一串卡号,而这件事上一行已经用人话说过了。
*
* (动作那一整类 `action` 不在这儿滤 —— 它压根不进骨架,见上面 SKELETON。)
*
* 不用「白名单标题」那一路:标题措辞随时会改,改一次过滤就失灵而没人发现。
* 这里认的是 kind + detail 上的结构化标记,措辞怎么变都不影响。
*/
/**
* **同一件事的复述** —— 人签完字那一刻,基座与引擎各留了一条痕,说的是同一件事。
*
* 实测一单的日志尾巴是这样的(用户 2026-08-02 提的「日志是给人看的」):
* · 成果落账,共 10 行(试跑:另有 1 行只留在活动缓存) ← 放行写合并,**带得住实情**
* · 成果已落账,交给下游 ← 发射输出口,同一件事
* · 成果落账,共 10 行,已交下游 ← 引擎流转,还是同一件事
* · 这一单已放行完成 ← 终态
* 四行里有三行在说「落账了」。留痕那一侧三条都该在(排障要分得清是谁做的),
* 但**日志这一侧只该说一遍**:留合并那一条(它是唯一说得出「几行没进正式表」的),
* 另外两条的信息它全包含。
*/
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
function machinery(e: MemoryEntry): boolean {
if (e.detail?.['internal'] === true) return true;
if (RESTATED.some((re) => re.test(e.title))) return true;
if (e.kind === 'thought') return true;
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
/*
* **人事件的到达**:对人它只是「事件到达(human):d455688a-…」一串卡号,
* 而这件事上一行已经用人话说过了(「员工确认:写入 customer.profile」)。
* 同一次点击记两行,后一行还是 UUID —— 对话区早就滤掉了它,日志该同一口径。
* 数据/时间事件的到达留着:那些是外面来的,人确实要知道什么时候到的。
*/
if (e.kind === 'observation' && e.detail?.['kind'] === 'human') return true;
return false;
}
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
return entries
.filter((e) => SKELETON.has(e.kind))
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
.map((e) => ({
id: e.id,
at: e.at,
side: e.side,
sideLabel: ATTRIBUTION_SIDE_LABEL[e.side],
action: actionOf(e),
...actorOf(e),
title: TEMPLATE[e.kind](e),
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
eventRef: e.eventRef,
@@ -76,6 +355,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
human: [],
'human-verdict': [],
'world-wait': [],
trigger: [],
};
for (const line of lines) out[line.side].push(line);
return out;
@@ -84,7 +364,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
/* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */
export type Metrics = {
/** 自主率 = 机器产出 ÷(机器产出 + 人产出);裁决与等待不进分母 */
/** 自主率 = 机器产出 ÷(机器产出 + 员工动作);裁决与等待不进分母 */
readonly autonomy: number;
/** 纠正率 = 被纠正批次 ÷ 提交批次 */
readonly correction: number;
@@ -21,8 +21,25 @@ export const ENTRY_KINDS = [
'release-verdict',
'terminal',
'human-input',
/**
* **AI 对人说的话** —— 活动主动交代进展的那一句(「三条已归一,缺 1 个交期」)。
*
* 与 `thought` 分开:thought 是给自己留的痕(装配了哪些技能、为什么这么判),
* narration 是**给人看的**,它进对话区。混成一类的后果是要么把机器踪迹全倒进对话区,
* 要么人在对话区里一个字也看不到 —— 后者实测发生过:A1 跑了十几拍,
* 对话区从头到尾空白,人只看得见「多了一个任务」。
*/
'narration',
/** 治理干预:控制事件 / 恢复 / 改派 —— 人对过程的裁断,不是引擎自转 */
'control',
/**
* **触发事件**(2026-07-31 用户定)—— 这一单是被什么叫起来的。
*
* 从前它记成 `observation`,归侧推成「机器产出」—— 而机器什么也没产出:
* 一封信到了、系统把这一环指给了你,这是**外面发生的事**,
* 不是 AI 干的活,也不该进自主率的分子分母。它自成一类。
*/
'trigger',
] as const;
export type EntryKind = (typeof ENTRY_KINDS)[number];
@@ -66,6 +83,9 @@ export type MemoryEntry = {
*/
export function deriveSide(kind: EntryKind): AttributionSide {
switch (kind) {
// 触发事件自成一侧:既不是机器产出也不是人做的,它是这一单的起因
case 'trigger':
return 'trigger';
case 'observation':
case 'thought':
case 'action':
@@ -74,6 +94,7 @@ export function deriveSide(kind: EntryKind): AttributionSide {
case 'evaluation':
case 'ledger-write':
case 'action-intent':
case 'narration': // 交代进展是机器产出:它是 AI 干活的一部分,不是人的时间
return 'machine';
case 'card-reply':
case 'human-input':
@@ -0,0 +1,222 @@
/**
* 提交放行 → 落地 的**用例链**(OS 7.3):这条链断过,而域测测不出来。
*
* 断的样子:活路径绕过 `submitForRelease` 直接发验收卡 → 意向永停 `drafted` →
* `land()` 只执行 `prechecked` → **写类动作登记完永远不执行**,而每一步单看都"正常"。
* 所以这里测的不是某个函数,是三件事的**接缝**:
* ① 提交把意向从 drafted 推到 prechecked;
* ② 卡面拿到的是**已验证**的动作清单(不是空数组);
* ③ 落地只执行 prechecked,且用的幂等键与登记时是同一个。
*/
import { describe, expect, it } from 'vitest';
import type { ActionIntent } from '@contracts';
import { taskIdSchema, staffIdSchema } from '@contracts';
import { createReleaseService } from '../internal/usecase/release-service.ts';
import type { ReleaseServiceDeps } from '../internal/usecase/release-service.ts';
import { idempotencyKey } from '../internal/domain/precheck.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
type Harness = {
deps: ReleaseServiceDeps;
intents: ActionIntent[];
executed: Array<{ tool: string; idempotencyKey: string }>;
cards: Array<{ criteria: unknown; actions: ReadonlyArray<{ label: string }>; fields: unknown }>;
emitted: string[];
};
function harness(over: { conflicts?: Array<{ field: string; key: Record<string, string> }>; invocable?: boolean } = {}): Harness {
const intents: ActionIntent[] = [];
const executed: Harness['executed'] = [];
const cards: Harness['cards'] = [];
const emitted: string[] = [];
let seq = 0;
const deps: ReleaseServiceDeps = {
intents: {
save: async (intent) => void intents.push(intent),
list: async () => intents,
setState: async (id, state) => {
const at = intents.findIndex((i) => i.id === id);
if (at >= 0) intents[at] = { ...intents[at]!, state };
},
},
ledger: {
checkConflicts: async () => over.conflicts ?? [],
releaseWrite: async () => ({
mergedRows: 2,
emittedEvents: 2,
simulatedRows: 0,
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
}),
},
tools: {
isInvocable: async () => ({ ok: over.invocable ?? true, ...(over.invocable === false ? { reason: '已停用' } : {}) }),
validateInput: async () => ({ ok: true, errors: [] }),
validateTarget: async () => undefined,
execute: async (input) => {
executed.push({ tool: input.tool, idempotencyKey: input.idempotencyKey });
return { id: input.idempotencyKey, ok: true, retryable: false };
},
},
cards: {
issueAcceptance: async (input) => {
cards.push({ criteria: input.criteria, actions: input.actions, fields: input.fields });
return { id: 'wo-1' };
},
issueLandingFailure: async () => ({ id: 'wo-fail' }),
},
tasks: {
activityIdOf: async () => 'quote-intake',
outputPortsOf: async () => ({ mandatory: ['intake'], selective: [] }),
},
ports: { emit: async (input) => void emitted.push(input.port) },
memory: { append: async () => undefined },
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
ids: { next: () => `id-${++seq}` },
};
return { deps, intents, executed, cards, emitted };
}
const writeIntent = (id: string): Omit<ActionIntent, 'taskId' | 'state'> => ({
id,
tool: 'mail.send',
input: { to: 'a@b.c' },
idempotencyKey: idempotencyKey(TASK, id),
label: `发报价邮件 ${id}`,
detail: '对外发出正式报价单',
});
const submit = (h: Harness) =>
createReleaseService(h.deps).submitForRelease({
taskId: TASK,
ownerId: OWNER,
humanCallCap: 4,
deliverableId: 'intake-record',
title: '收口验收',
basis: [{ k: '提交理由', v: '必做项已齐' }],
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
criteria: [{ key: 'typed', label: '每条都已打标', level: 'L2', pass: true }],
});
describe('提交放行:预检 → 意向 prechecked → 已验证动作上卡', () => {
it('登记的写类意向被推到 prechecked —— 不推,放行时就一个都不会执行', async () => {
const h = harness();
const release = createReleaseService(h.deps);
await release.registerIntent(TASK, writeIntent('i-1'));
expect(h.intents[0]?.state).toBe('drafted');
await submit(h);
expect(h.intents[0]?.state).toBe('prechecked');
});
it('卡面的动作清单 = 已验证的那份,不是空数组', async () => {
const h = harness();
const release = createReleaseService(h.deps);
await release.registerIntent(TASK, writeIntent('i-1'));
await submit(h);
expect(h.cards).toHaveLength(1);
expect(h.cards[0]?.actions.map((a) => a.label)).toEqual(['发报价邮件 i-1']);
// 判据是调用方按契约逐条标注的,不是硬编码单条 L5
expect(h.cards[0]?.criteria).toEqual([{ label: '每条都已打标', level: 'L2', pass: true }]);
});
/*
* 「有没有产出」的证据必须是**本单自己写下的行**。装配层数的是 `taskDrafts`,
* 不是任务视图 —— 视图 = 正式版 + 本单草稿,拿它去数,别单放行过的记录就成了自己的交付。
* 实测踩过:一单什么都没写就提交放行,三件必交付物全判「有」,卡照发人照签;
* 这道门于是只在第一单有效。这里守的是 release 侧的另一半:**说缺就必须拒**。
*/
it('必交付物缺一即拒,且**不发卡** —— 打回环内修正', async () => {
const h = harness();
const release = createReleaseService(h.deps);
await expect(
release.submitForRelease({
taskId: TASK,
ownerId: OWNER,
humanCallCap: 4,
deliverableId: 'intake-record',
title: '收口验收',
basis: [{ k: '理由', v: 'x' }],
deliverables: [{ key: 'type-table', label: '料号分类表', present: false }],
criteria: [],
}),
).rejects.toThrow(/料号分类表/);
expect(h.cards).toHaveLength(0);
});
it('预检不过即拒,且不发卡、意向留在 drafted', async () => {
const h = harness({ invocable: false });
const release = createReleaseService(h.deps);
await release.registerIntent(TASK, writeIntent('i-1'));
await expect(submit(h)).rejects.toThrow(/已停用/);
expect(h.cards).toHaveLength(0);
expect(h.intents[0]?.state).toBe('drafted');
});
it('他单改了同一字段 → 数据冲突,打回重拟', async () => {
const h = harness({ conflicts: [{ field: 'target_price', key: { pn: 'X1' } }] });
await expect(submit(h)).rejects.toThrow(/target_price/);
});
});
/*
* 可修订字段要**真的上卡**。
*
* `SubmitForReleaseInput.fields` 声明了却从来没被接线:调用方一路传下来的字段
* 在 `issueAcceptance` 那一步被丢在地上,卡面 `fields` 恒空 —— 验收报告只能看不能改,
* 人工修订形同虚设(第 12 章③)。**声明了不接线比不声明更坏:调用方以为传到了。**
*/
describe('可修订字段上卡 —— 验收报告「AI 已填好、可编辑」', () => {
it('传了就上卡,带 AI 拟值与落回地址', async () => {
const h = harness();
await createReleaseService(h.deps).submitForRelease({
taskId: TASK,
ownerId: OWNER,
humanCallCap: 4,
deliverableId: 'intake-record',
title: '收口验收',
basis: [{ k: '提交理由', v: '齐了' }],
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
criteria: [],
fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }],
});
expect(h.cards[0]?.fields).toEqual([
{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' },
]);
});
it('没传就不上 —— 只看不改也是一种合法形态,不硬塞空表单', async () => {
const h = harness();
await submit(h);
expect(h.cards[0]?.fields).toBeUndefined();
});
});
describe('落地:只执行 prechecked,幂等键与登记时同一个', () => {
it('走完提交再落地 —— 动作真的执行了', async () => {
const h = harness();
const release = createReleaseService(h.deps);
await release.registerIntent(TASK, writeIntent('i-1'));
await submit(h);
const outcome = await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
expect(h.executed).toHaveLength(1);
// 两侧同一把钥匙:登记时算的键,落地时用的键
expect(h.executed[0]?.idempotencyKey).toBe(idempotencyKey(TASK, 'i-1'));
expect(h.intents[0]?.state).toBe('landed');
expect(outcome.emittedPorts).toEqual(['intake']);
});
it('没过提交(意向仍 drafted)→ 落地一个都不执行 —— 这正是断链时的症状', async () => {
const h = harness();
const release = createReleaseService(h.deps);
await release.registerIntent(TASK, writeIntent('i-1'));
await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
expect(h.executed).toHaveLength(0);
expect(h.intents[0]?.state).toBe('drafted');
});
});
+5
View File
@@ -50,6 +50,11 @@ export type SubmitForReleaseInput = {
readonly criteria: readonly CriterionCheck[];
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
*/
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
};
export type SubmitOutcome = {
@@ -50,7 +50,16 @@ export function summarize(
/* ── 必交付物与判据 ─────────────────────────────────────────────────── */
export type DeliverableCheck = { readonly key: string; readonly label: string; readonly present: boolean };
export type CriterionCheck = { readonly key: string; readonly label: string; readonly level: AcceptanceLevel; readonly pass: boolean };
export type CriterionCheck = {
readonly key: string;
readonly label: string;
readonly level: AcceptanceLevel;
readonly pass: boolean;
/** 裁量项(L3/L4)裁过没有 —— 机检项与 L5 不带这一维 */
readonly settled?: boolean | undefined;
/** 裁断的理由:裁成「不满足」时,这句话才是签字的人要看的东西 */
readonly rationale?: string | undefined;
};
/** 必交付物**缺一不得提交放行**(AM 01) */
export function missingDeliverables(checks: readonly DeliverableCheck[]): DeliverableCheck[] {
@@ -132,9 +132,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
title: input.title,
basis: input.basis,
deliverable: input.deliverableId,
criteria: input.criteria.map((c) => ({ label: c.label, level: c.level, pass: c.pass })),
criteria: input.criteria.map((c) => ({
label: c.label,
level: c.level,
pass: c.pass,
// 裁量项那一维原样带上卡:裁过没有、凭什么 —— 丢一样,人就只能凭感觉签
...(c.settled === undefined ? {} : { settled: c.settled }),
...(c.rationale === undefined ? {} : { rationale: c.rationale }),
})),
// 卡面呈现的即**已验证**的动作清单
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
/*
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
*
* 这一行从前**没有**:`SubmitForReleaseInput.fields` 声明了却从来没被用过,
* 调用方一路传下来的字段在这里被丢在地上,于是卡面 `fields` 恒空 ——
* 报告只能看不能改,人工修订形同虚设(第 12 章③)。
* 声明了不接线比不声明更坏:调用方以为传到了。
*/
...(input.fields === undefined || input.fields.length === 0 ? {} : { fields: input.fields }),
/*
* 验收页的正文块 —— 绑台账的可编辑表就走这里。
* 人在这一屏上改的是**成果本身**(那份清单),不是旁边几个整单级字段;
* 改完由基座写回草稿层,放行时随之合并 —— AI 不经手。
*/
...(input.blocks === undefined || input.blocks.length === 0 ? {} : { blocks: input.blocks }),
});
return { report, workOrderId: card.id };
@@ -146,10 +168,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
// 放行写:草稿层合并入正式版(原子,过冲突检查),此刻才产生数据事件
const merged = await deps.ledger.releaseWrite(input.taskId);
/*
* **落地结果要留成结构化的**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
*
* 从前只有标题那一句话,人要知道「这一签下去到底发生了什么」,得去读日志、
* 再自己把「合并 9 行」拆成哪几张表各几行。那是让人替系统做归并。
* 逐表计数落进 `detail`,验收卡办结之后照它摆一屏 —— 摆的是事实,不是转述。
*/
const byTable = new Map<string, number>();
for (const row of merged.merged) {
const at = `${row.domain}.${row.object}`;
byTable.set(at, (byTable.get(at) ?? 0) + 1);
}
await deps.memory.append({
taskId: input.taskId,
kind: 'ledger-write',
title: `放行写合并 ${merged.mergedRows} 行`,
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
title:
merged.simulatedRows === 0
? `放行写合并 ${merged.mergedRows} 行`
: `放行写合并 ${merged.mergedRows} 行(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`,
detail: {
rows: merged.mergedRows,
simulated: merged.simulatedRows,
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
},
});
// 外部动作意向执行,附幂等键;已成动作留存不回滚
@@ -238,6 +281,13 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
taskId: input.taskId,
kind: 'release-verdict',
title: `落地收敛,发射输出口:${emitted.join('、') || '(无)'}`,
/*
* 发了哪几口、**带了哪些字段过去** —— 验收卡办结后照它摆
* 「这一单转去哪儿了、带了什么」(2026-08-02 用户定)。
* 载荷就是已落地的事实,所以这里记的是它的键;值不记 —— 值在成果件里,
* 那一屏答的是「带了哪些东西」,不是把数据再抄一遍。
*/
detail: { ports: [...emitted], carried: Object.keys(landedFacts).slice(0, 40) },
});
return { verdict, emittedPorts: emitted, ledgerRowsMerged: merged.mergedRows, merged: merged.merged };
+27 -1
View File
@@ -19,6 +19,8 @@ export type LedgerGateway = {
releaseWrite(taskId: TaskId): Promise<{
mergedRows: number;
emittedEvents: number;
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
simulatedRows: number;
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
merged: ReadonlyArray<{
domain: string;
@@ -61,8 +63,32 @@ export type CardGateway = {
title: string;
basis: ReadonlyArray<{ k: string; v: string }>;
deliverable: string;
criteria: ReadonlyArray<{ label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean }>;
/*
* `settled` / `rationale` 是**裁量项**那一维:裁过没有、裁断的理由。
* 「裁了但办不到」与「压根没裁」在卡上不能长成同一个红叉 —— 前者是把判断交给人,
* 后者是活没干完;而理由那一句正是签字的人要看的东西。
*/
criteria: ReadonlyArray<{
label: string;
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
pass: boolean;
settled?: boolean | undefined;
rationale?: string | undefined;
}>;
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
*/
fields?: ReadonlyArray<Record<string, unknown>>;
/**
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
*/
blocks?: ReadonlyArray<Record<string, unknown>>;
}): Promise<{ id: string }>;
issueLandingFailure(input: {
taskId: TaskId;
+5
View File
@@ -23,6 +23,11 @@
port 注入:memory / event / message / interaction / ledger
被依赖: process, org(停用前置检查的在途任务), report, app
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
「试跑一个活动」起出来的单(不挂工作流)与登记直起单长得一模一样。
不变量(OS 04 / 4.3,AM 10 / 12.1):
- **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 ——
缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给
@@ -76,6 +76,7 @@ function harness(): Harness {
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
},
},
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
ids: { next: () => `t-${(seq += 1)}` },
};
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
notifier: { notify: async () => undefined },
interaction: { setPhase: async () => undefined },
intake: { askCompletion: async () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
ids: { next: () => 'x' },
};
+3
View File
@@ -52,6 +52,8 @@ export type Task = {
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
readonly workflowId?: string;
readonly nodeId?: string;
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
readonly origin?: string;
readonly ownerId: StaffId;
readonly title: string;
readonly runtime: TaskRuntimeState;
@@ -73,6 +75,7 @@ export type StartTaskInput = {
readonly title: string;
readonly workflowId?: string;
readonly nodeId?: string;
readonly origin?: string;
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
readonly data: Record<string, unknown>;
readonly message?: string;
@@ -11,6 +11,11 @@ export const taskTable = sys.table(
activityId: text('activity_id').notNull(),
workflowId: text('workflow_id'),
nodeId: text('node_id'),
/**
* 起单来源:`testbed` = 设计台试跑,其余留空。
* 认得出试跑单,才拔得掉 —— 仿真不是生产,它不该在别人工作台上过夜。
*/
origin: text('origin'),
/** 恰有一名负责人 */
ownerId: text('owner_id').notNull(),
title: text('title').notNull(),
@@ -34,6 +39,7 @@ export const taskTable = sys.table(
(t) => [
index('task_owner_idx').on(t.ownerId, t.runtime),
index('task_workflow_idx').on(t.workflowId),
index('task_origin_idx').on(t.origin),
index('task_terminal_idx').on(t.terminal),
],
);
@@ -16,6 +16,7 @@ function toTask(row: Row): Task {
id: row.id as TaskId,
activityId: row.activityId,
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
...(row.origin === null ? {} : { origin: row.origin }),
...(row.nodeId === null ? {} : { nodeId: row.nodeId }),
ownerId: row.ownerId as StaffId,
title: row.title,
@@ -43,6 +44,7 @@ export function createTaskStore(db: Db): TaskStore {
id: task.id,
activityId: task.activityId,
workflowId: task.workflowId ?? null,
origin: task.origin ?? null,
nodeId: task.nodeId ?? null,
ownerId: task.ownerId,
title: task.title,
@@ -9,6 +9,7 @@ import type { StartTaskInput, Task, TaskService, TickOutcome } from '../../contr
import { TaskAlreadyTerminalError, TaskNotFoundError } from '../../contract.ts';
import type {
ActivityLoader,
CommRecorder,
Clock,
IdGenerator,
IntakeDesk,
@@ -45,6 +46,8 @@ export type TaskServiceDeps = {
* 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。
*/
readonly intake: IntakeDesk;
/** 沟通记录 —— 这一单是被一封信叫起来的话,起单那一刻把来件归档 */
readonly comms: CommRecorder;
readonly clock: Clock;
readonly ids: IdGenerator;
};
@@ -101,6 +104,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
activityId: input.activityId,
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
...(input.origin === undefined ? {} : { origin: input.origin }),
ownerId: input.ownerId,
title: input.title,
runtime: missing.length > 0 ? 'awaiting-human' : 'running',
@@ -118,6 +122,44 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
// message 计入初始观察、全程留痕;**不得扩大授权边界**
detail: { data: input.data, message: input.message, envelope: loaded.envelope },
});
/*
* **来件先归档,再开跑** —— 顺序有意义:人一点开这一单,沟通记录里就已经躺着
* 那封信;等模型跑几拍之后再补,那几十秒里人看到的是「这单从哪来的?不知道」。
* 声明了才归(`contract.input.inboundComm`);没声明的活动这一步整个不发生。
*/
const inbound = loaded.inboundComm;
if (inbound !== undefined) {
const pick = (field: string | undefined): unknown => (field === undefined ? undefined : input.data[field]);
const attachments = pick(inbound.attachmentsField);
await deps.comms.record({
taskId: task.id,
direction: 'in',
channel: inbound.channel,
party: String(pick(inbound.partyField) ?? ''),
subject: String(pick(inbound.subjectField) ?? task.title),
body: String(pick(inbound.bodyField) ?? ''),
attachments: Array.isArray(attachments) ? attachments : [],
note: '开案来件 —— 这一单就是被它叫起来的',
});
/*
* **对话流的第一句话由系统说**(2026-07-31 用户定)。
*
* 从前人点开这一单,看到的第一句是 AI 的解读 —— 而他还不知道
* 「这单从哪来的、为什么派给我」。那两件事不该由 AI 顺口交代:
* 它们是**系统的事实**(一封信触发了这条流,系统把这一环指给了你),
* AI 说出来反而像它在自我介绍。
*
* 带一个「查看邮件」的去处:原件就在沟通记录里,一步跳过去。
* **点不点都不挡路** —— 这条消息之后 AI 照常开始解读,不等人按。
*/
await deps.memory.append({
taskId: task.id,
// **触发事件**:这一单是被这封信叫起来的 —— 不是机器产出,也不是谁的动作
kind: 'trigger',
title: `一封邮件触发了这条流程,系统指定你负责「${task.title}」。`,
detail: { eventType: 'data', openComms: true },
});
}
await deps.engine.load(task.id, loaded.businessModel);
await deps.interaction.setPhase(task.id, 'active');
if (missing.length > 0) {
@@ -163,6 +205,23 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
return { task: finished, verdict, progressed: false };
}
/*
* **先把「在办事」写进投影,再开跑**(2026-07-31 修)。
*
* 一拍要跑好几秒 —— 模型在算。而这一列从前是**整拍跑完才写**(见下面第 224 行),
* 于是被人唤醒的那一单,这几秒里投影仍停在 `awaiting-event`:
* 人点完数据录入卡,对话区四秒一动不动(实测 4.3 秒),他以为没点上、再点一次。
* 引擎明明在算,投影却说在等外部 —— 界面不撒谎,那这一列就得在开跑前写。
*
* `setPhase` 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取。
* 一拍跑完照旧按 outcome 写回真实状态;若这一拍抛了,单停在 running,
* 推进器下一轮会再推它一次 —— 那本来就是该做的事(重试),不是卡死。
*/
if (task.runtime !== 'running') {
await deps.store.update(taskId, { runtime: 'running' });
await deps.interaction.setPhase(taskId, 'active');
}
const startedAt = deps.clock.now().getTime();
const outcome = await deps.engine.advance(taskId);
// 步计量:loop 的一次决策循环 / flow 的一个声明步。
@@ -246,11 +305,20 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
/*
* 投递痕只记**这一条**(标题格式与对话流投影的过滤约定一致:human 的不进对话区)。
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
*/
await deps.memory.append({
taskId,
kind: 'observation',
title: `事件到达:${event.kind}/${event.ref}`,
detail: event.payload,
title: `事件到达(${event.kind}):${event.ref}`,
/*
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
*/
detail: { ...event.payload, kind: event.kind },
});
/*
+30 -2
View File
@@ -25,11 +25,36 @@ export type ActivityLoader = {
envelope: Record<string, unknown>;
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
inputSchema: Record<string, unknown>;
/**
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
*/
inboundComm?: {
channel: string;
partyField: string;
subjectField?: string | undefined;
bodyField?: string | undefined;
attachmentsField?: string | undefined;
};
businessModel: unknown;
stallAfter: string;
}>;
};
/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */
export type CommRecorder = {
record(input: {
taskId: TaskId;
direction: 'in' | 'out';
channel: string;
party: string;
subject: string;
body: string;
attachments: readonly unknown[];
note: string;
}): Promise<void>;
};
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};
@@ -76,8 +101,11 @@ export type LandingGateway = {
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
/**
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
*/
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
+28 -3
View File
@@ -10,11 +10,14 @@
`EffectLevel` `RegisterToolInput` `RegistrationVerdict` `AuditFinding` `Preauthorization`
- 错误:`ToolNotFoundError` `ToolNotAllowedError` `ToolDisabledError` `ToolReplayMissError`
`RegistrationRejectedError`
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `SchemaValidator` `SecretBox` `Clock`
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `ToolHandoffStore` `SchemaValidator`
`SecretBox` `Clock`;数据类型 `ToolHandoff` `HandoffField`
- 用例工厂:`createToolboxService` `createToolInvoker`
- 纯函数:`dispositionOf` `checkEvolution` `staticAudit` `auditPassed` `toolReplayKey`
`callKeyOf` `handoffFields` `coerceBySchema`(未命中转人三件)
- adapter:`createToolRegistry` `createToolReplayStore` `createSchemaValidator` `createToolRuntime`
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable`
`createToolHandoffStore`
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable` `toolHandoffTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入,与 llm 同一实现)、`ToolRuntime`
@@ -27,6 +30,9 @@
声明了却查无此工具 / 已停用如实报,不静默给空壳。三路同一张清单:
`function`(功能工具,经工具调用一缝:白名单/录制回放/写类只登记意向)、
`human`(**人类工具**,系统内化、零声明即得 —— 调人是 Harness 给的能力)、
`ledger`(**台账工具** `ledger.write`,2026-07-30:写草稿是基座六件之 ledger,
零声明即得 —— 活动干完的活总得有地方放;`effect` 记 `read` 不是笔误,草稿层不触达世界,
不该被当写类工具去登记动作意向)、
`llm`(**LLM 五件**:抽取/归类/摘要/裁断/改写,经模型网关一缝;
仅当活动声明了模型用途名才上总线 —— 没声明却会问模型是越权,用量也无从归因)
- **一名两用**(2026-07-29):`name` 是**机器名**(全局唯一逻辑名,活动按它声明白名单、
@@ -43,6 +49,23 @@
- **注册两审**:静态审有 error 即不上架;试运行审要求样例集非空且全过
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
- **未命中转人:人是工具的一种实现**(2026-07-30,§6.2)。`invoke` 遇未注册**不再抛** ——
返回 `tool-unregistered`;有契约无实现返回 `tool-unimplemented`。两者都不是失败,
是「这一步该由人做」:app 据此开一张执行卡,人填回的表单值**就是这次调用的出参**。
- 两分不可合:未注册的卡面只有工具名 + 本次入参 + 一格自由文本(系统不知道该问什么,
就不编造字段);有契约无实现的卡面 = 契约 `guide` 段 + 入参 + 按出参 Schema 生成的表单。
- `guide`(ask/steps/standard)是**面向人**的作业指导,与面向 AI 的 `description` 分开写,
随包版本冻结 —— 不临场让模型编步骤。
- 一次调用只发一张卡:`callKeyOf` 以工具名 + **键序无关**的入参指纹作内容地址;
同 `(taskId, callKey)` 唯一。没有它,Loop 每拍都会为同一件事再堆一张卡。
- 人填的字符串按出参 Schema 归型;**归不了型不硬转**,原样交回 ——
硬转出 `NaN` / `{}` 会被活动当真值往下走,错得看不见。
- **未授权仍抛** `ToolNotAllowedError`:越权是治理配置问题,不得靠转人掩盖。
近变(2026-07-30):新增**工具转人台** —— `ToolHandoffStore` 端口 + `tool_handoff` 表 +
`tool.guide` 列 + 域内三件纯函数;`ToolInvoker.invoke` 未注册由抛改为回错误码
(**行为变更**:调用方从前靠 catch,现在要看 `error.code`)。
发卡 / 立监听 / 记答案在 app 装配层 —— toolbox 是 L2,不得认识 L3 的 human-tool。
近变(2026-07-28):模块面未变,**试运行审的行为修正** ——
此前 `register` 给试运行传空连接,靠 `endpoint` 干活的工具永远「无实现」过不了门,
@@ -51,4 +74,6 @@
app 侧配套:未注册第一方处理器的工具走通用 HTTP 实现(`tools/http-tool.ts`),
导入的自定义工具由此可真调、可真审。
影响半径: index/contract/port 未变 → 对外零影响
影响半径: index/port 加法式扩容(新端口类型 + 三件纯函数 + 一张表);
`invoke` 对未注册工具的**行为**变了(抛 → 回 `tool-unregistered`)——
下游只有 app 的 `toolGateway` 一处消费,已同步改为转人。
@@ -0,0 +1,78 @@
/**
* 未命中转人(§6.2)—— **人是工具的一种实现**,人填回的值就是这次调用的出参。
*
* 这里锁三件容易悄悄错的事:
* ① 入参指纹要**稳定**(键序无关)—— 不稳就等于没有幂等,每拍再发一张卡;
* ② 出参 Schema 决定问人哪几格 —— 没 Schema 时只准问「结果是什么」,不许编字段;
* ③ 人填的字符串按声明归型,归不了就**原样交回**,不许硬转出 NaN。
*/
import { describe, expect, it } from 'vitest';
import { callKeyOf, coerceBySchema, handoffFields } from '../internal/domain/handoff.ts';
describe('入参指纹:同一次调用只问人一次', () => {
it('键序不同、内容相同 → 同一把键', () => {
expect(callKeyOf('rfq.parse', { a: 1, b: { y: 2, x: 3 } })).toBe(callKeyOf('rfq.parse', { b: { x: 3, y: 2 }, a: 1 }));
});
it('内容变了就是另一次调用', () => {
expect(callKeyOf('rfq.parse', { pn: 'X1' })).not.toBe(callKeyOf('rfq.parse', { pn: 'X2' }));
});
it('同入参不同工具不得共用答案', () => {
expect(callKeyOf('a.do', { pn: 'X1' })).not.toBe(callKeyOf('b.do', { pn: 'X1' }));
});
});
describe('出参 Schema → 回填表单', () => {
it('没 Schema(连契约都没有)只给一格自由文本 —— 不编造字段骗人填', () => {
expect(handoffFields(undefined)).toEqual([
{ key: 'result', label: '这件事的结果(照实填写)', kind: 'textarea', required: true },
]);
});
it('按 properties 逐格生成,类型决定控件,required 照声明', () => {
const fields = handoffFields({
properties: {
target_price: { type: 'number', title: '目标价' },
note: { type: 'string' },
rows: { type: 'array' },
},
required: ['target_price'],
});
expect(fields).toEqual([
{ key: 'target_price', label: '目标价', kind: 'number', required: true },
{ key: 'note', label: 'note', kind: 'text', required: false },
{ key: 'rows', label: 'rows', kind: 'textarea', required: false },
]);
});
});
describe('按声明归型:归不了不硬转', () => {
it('数值 / 布尔 / JSON 各按声明归位', () => {
const output = {
properties: { qty: { type: 'integer' }, urgent: { type: 'boolean' }, rows: { type: 'array' }, memo: { type: 'string' } },
};
expect(coerceBySchema(output, { qty: '120', urgent: 'true', rows: '[{"pn":"X1"}]', memo: '照原样' })).toEqual({
qty: 120,
urgent: true,
rows: [{ pn: 'X1' }],
memo: '照原样',
});
});
it('该是数字却填了一句话 → 原样交回,不给 NaN', () => {
expect(coerceBySchema({ properties: { qty: { type: 'number' } } }, { qty: '问过供应商再说' })).toEqual({
qty: '问过供应商再说',
});
});
it('该是数组却填了自然语言 → 原样交回,不给空对象', () => {
expect(coerceBySchema({ properties: { rows: { type: 'array' } } }, { rows: '三家都没报' })).toEqual({
rows: '三家都没报',
});
});
it('没 Schema 就原样透传 —— 系统不猜人的意思', () => {
expect(coerceBySchema(undefined, { result: '已线下问清' })).toEqual({ result: '已线下问清' });
});
});
@@ -2,7 +2,7 @@
* 工具总线的声明面 —— **Agent 能调什么由声明定死**,不由它临场想(domain 测试零 mock)。
*/
import { describe, expect, it } from 'vitest';
import { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
import { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
import type { CatalogEntry } from '../internal/domain/tool-bus.ts';
const entry = (over: Partial<CatalogEntry> & { name: string }): CatalogEntry => ({
@@ -47,11 +47,20 @@ describe('compileToolBus —— 声明 × 目录', () => {
});
/* 人类工具是 Harness 给的能力,不是活动自带的本事 —— 每个活动天然可以调人 */
it('人类工具恒在:一件工具都没声明也能调人', () => {
/*
* 系统内化两件恒在:调人与写台账草稿。
* 前者是「AI 对世界只有触达与等待两种姿势」的等待面;后者是基座六件之 ledger ——
* 活动干完的活总得有地方放,不给就等于让它干完即忘。
*/
it('人类工具与台账工具恒在:一件工具都没声明也能调人、也能写草稿', () => {
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
expect(report.tools).toHaveLength(1);
expect(report.tools[0]).toEqual(HUMAN_TOOL);
expect(report.tools[0]!.kind).toBe('human');
expect(report.tools.map((t) => t.id)).toEqual([HUMAN_TOOL.id, LEDGER_TOOL.id]);
expect(report.tools.map((t) => t.kind)).toEqual(['human', 'ledger']);
});
it('台账工具的效应是 read —— 草稿不触达世界,不该被当写类工具去登记动作意向', () => {
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
expect(report.tools.find((t) => t.id === LEDGER_TOOL.id)?.effect).toBe('read');
});
/* 没声明模型的活动突然会「问模型」= 越权,且用量无从归因 */
@@ -0,0 +1,91 @@
/**
* 工具未命中的两分(§6.2)—— 这条缝断过,而断的样子很像「正常报错」。
*
* 从前 `invoke` 遇到未注册的工具直接抛 `ToolNotFoundError`:回合当场崩,
* 人在工作台上什么也看不到 —— 名义上「工具未注册」,实际上是**这一单一步走不动**。
*
* 现在两分:
* · 未注册 → 返回 `tool-unregistered`,由 app 转人(人是工具的一种实现);
* · 未授权 → **仍然抛**。越权是治理配置的问题,不能靠转人掩盖过去。
*/
import { describe, expect, it } from 'vitest';
import { ToolNotAllowedError, ToolNotFoundError } from '../contract.ts';
import type { ToolContract } from '../internal/domain/tool-contract.ts';
import { createToolInvoker } from '../internal/usecase/tool-invoker.ts';
import type { ToolInvokerDeps } from '../internal/usecase/tool-invoker.ts';
const CONTRACT: ToolContract = {
name: 'rfq.parse',
displayName: 'RFQ 解析',
version: '1.0.0',
description: '从来件里抽出料号与数量',
input: { type: 'object' },
output: { type: 'object', properties: { rows: { type: 'array' } } },
effect: 'read',
errors: [],
idempotent: true,
timeoutMs: 3000,
retries: 1,
connectionKeys: [],
};
function deps(over: { known?: ToolContract; allowlist?: string[]; handler?: boolean } = {}): ToolInvokerDeps {
return {
registry: {
list: async () => (over.known === undefined ? [] : [over.known]),
get: async (name) => (over.known?.name === name ? over.known : undefined),
isEnabled: async () => true,
upsert: async () => undefined,
setEnabled: async () => undefined,
connectionCipher: async () => undefined,
setAllowlist: async () => undefined,
allowlistOf: async () => over.allowlist ?? ['rfq.parse'],
referencedBy: async () => [],
},
runtime: {
execute: async () =>
over.handler === true
? { ok: true, output: { rows: [] } }
: { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: '工具无实现:rfq.parse' } },
},
replay: { get: async () => undefined, put: async () => undefined },
schemas: { validate: () => ({ ok: true, errors: [] }) },
secrets: { seal: (v) => v, open: (v) => v },
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
mode: 'live',
};
}
const call = { tool: 'rfq.parse', input: { mail: 'x' }, activityId: 'quote-intake' };
describe('未注册:不抛,如实回一个可分辨的错误码', () => {
it('invoke 返回 tool-unregistered —— 让调用方去转人,而不是让回合崩', async () => {
const result = await createToolInvoker(deps()).invoke(call);
expect(result.ok).toBe(false);
expect(result.error?.code).toBe('tool-unregistered');
expect(result.error?.message).toContain('rfq.parse');
});
it('有契约但系统侧没有实现 → tool-unimplemented(与「连契约都没有」两分)', async () => {
const result = await createToolInvoker(deps({ known: CONTRACT })).invoke(call);
expect(result.error?.code).toBe('tool-unimplemented');
});
it('有契约有实现照常执行 —— 转人只在真没有实现时发生', async () => {
const result = await createToolInvoker(deps({ known: CONTRACT, handler: true })).invoke(call);
expect(result.ok).toBe(true);
expect(result.output).toEqual({ rows: [] });
});
});
describe('未授权仍抛:越权不得靠转人掩盖', () => {
it('不在白名单 → ToolNotAllowedError', async () => {
await expect(createToolInvoker(deps({ known: CONTRACT, allowlist: [] })).invoke(call)).rejects.toBeInstanceOf(
ToolNotAllowedError,
);
});
it('disposition 仍要求契约在场 —— 没有契约就无从判定动作性质', async () => {
await expect(createToolInvoker(deps()).disposition(call, [])).rejects.toBeInstanceOf(ToolNotFoundError);
});
});
+7 -1
View File
@@ -32,6 +32,8 @@ export type {
IdGenerator,
SchemaValidator,
SecretBox,
ToolHandoff,
ToolHandoffStore,
ToolRegistry,
ToolReplayStore,
ToolRuntime,
@@ -45,14 +47,18 @@ export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
/* 工具总线的声明面(纯逻辑):声明 × 目录 → 一张清单;人类工具与 LLM 五件系统内化 */
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
export { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
/* 未命中转人(§6.2):入参指纹 / 出参 Schema → 回填表单 / 按声明归型 */
export type { HandoffField } from './internal/domain/handoff.ts';
export { callKeyOf, coerceBySchema, handoffFields } from './internal/domain/handoff.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
export {
createSchemaValidator,
createToolRegistry,
createToolReplayStore,
createToolHandoffStore,
createToolRuntime,
isAllowed,
} from './internal/adapter/toolbox-store.ts';
@@ -1,7 +1,7 @@
/**
* toolbox 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
*/
import { boolean, index, integer, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { boolean, index, integer, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const toolTable = sys.table(
@@ -21,6 +21,8 @@ export const toolTable = sys.table(
retries: integer('retries').notNull().default(1),
connectionKeys: jsonb('connection_keys').notNull().default([]),
capabilities: jsonb('capabilities'),
/** 面向人的作业指导段 —— 转人时照抄上卡,不临场让模型编步骤(§6.2) */
guide: jsonb('guide'),
pure: boolean('pure').notNull().default(false),
ownerActivityId: text('owner_activity_id'),
/** 凭证隔离:值加密落库,不入包、不入活动、不入留痕明文(ADR-009) */
@@ -48,3 +50,36 @@ export const replayToolTable = sys.table('replay_tool', {
result: jsonb('result').notNull(),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* 工具转人台(§6.2 未命中转人)—— **人是工具的一种实现**。
*
* 系统里没有这件工具(无契约或有契约无实现)时,不报错、不空转:开一张执行卡请人来做,
* 人填回的表单值**就是这次工具调用的出参**。这张表记的就是「这次调用交给谁、答没答、答了什么」。
*
* `callKey` 是内容地址(工具 + 入参指纹):同一个调用重问一次拿的是同一份答案 ——
* 没有它,Loop 每拍都会为同一件事再发一张卡。
*/
export const toolHandoffTable = sys.table(
'tool_handoff',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
tool: text('tool').notNull(),
/** 内容地址:工具 + 入参指纹 —— 同调用同答案 */
callKey: text('call_key').notNull(),
input: jsonb('input').notNull(),
/** 请人做的那张执行卡 */
workOrderId: text('work_order_id').notNull(),
/** pending = 已发卡等答;answered = 人已填,出参可取 */
state: text('state').notNull().default('pending'),
/** 人填回的值 = 工具出参 */
output: jsonb('output'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
answeredAt: timestamp('answered_at', { withTimezone: true }),
},
(t) => [
uniqueIndex('tool_handoff_call_key').on(t.taskId, t.callKey),
index('tool_handoff_order_idx').on(t.workOrderId),
],
);
@@ -5,10 +5,10 @@ import { Ajv } from 'ajv';
import addFormats from 'ajv-formats';
import { and, eq } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import type { SchemaValidator, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
import type { SchemaValidator, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
import type { ToolCall, ToolResult } from '../../contract.ts';
import type { EffectLevel, ToolContract } from '../domain/tool-contract.ts';
import { replayToolTable, toolAllowlistTable, toolTable } from './toolbox-schema.ts';
import { replayToolTable, toolAllowlistTable, toolHandoffTable, toolTable } from './toolbox-schema.ts';
type Row = typeof toolTable.$inferSelect;
@@ -28,6 +28,7 @@ function toContract(row: Row): ToolContract {
retries: row.retries,
connectionKeys: row.connectionKeys as string[],
...(row.capabilities === null ? {} : { capabilities: row.capabilities as ToolContract['capabilities'] }),
...(row.guide === null ? {} : { guide: row.guide as ToolContract['guide'] }),
pure: row.pure,
...(row.ownerActivityId === null ? {} : { ownerActivityId: row.ownerActivityId }),
};
@@ -62,6 +63,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
retries: contract.retries,
connectionKeys: contract.connectionKeys,
capabilities: contract.capabilities ?? null,
guide: contract.guide ?? null,
pure: contract.pure ?? false,
ownerActivityId: contract.ownerActivityId ?? null,
connectionCipher,
@@ -84,6 +86,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
retries: values.retries,
connectionKeys: values.connectionKeys,
capabilities: values.capabilities,
guide: values.guide,
pure: values.pure,
ownerActivityId: values.ownerActivityId,
// 未传凭证即保持原值(只写不读)
@@ -222,3 +225,51 @@ export async function isAllowed(db: Db, activityId: string, tool: string): Promi
.limit(1);
return rows.length > 0;
}
/** 转人台 —— 同任务同 callKey 唯一(重问拿同一份答案,不再发第二张卡) */
export function createToolHandoffStore(db: Db): ToolHandoffStore {
const toHandoff = (row: typeof toolHandoffTable.$inferSelect): ToolHandoff => ({
id: row.id,
taskId: row.taskId,
tool: row.tool,
callKey: row.callKey,
input: row.input as Record<string, unknown>,
workOrderId: row.workOrderId,
state: row.state === 'answered' ? 'answered' : 'pending',
...(row.output === null ? {} : { output: row.output as Record<string, unknown> }),
});
return {
async find(taskId, callKey) {
const rows = await db
.select()
.from(toolHandoffTable)
.where(and(eq(toolHandoffTable.taskId, taskId), eq(toolHandoffTable.callKey, callKey)))
.limit(1);
return rows[0] === undefined ? undefined : toHandoff(rows[0]);
},
async open(handoff) {
await db
.insert(toolHandoffTable)
.values({
id: handoff.id,
taskId: handoff.taskId,
tool: handoff.tool,
callKey: handoff.callKey,
input: handoff.input,
workOrderId: handoff.workOrderId,
state: 'pending',
})
// 并发下两拍同时开同一张:先到的赢,后到的静默让位(同 callKey 只该有一张卡)
.onConflictDoNothing();
},
async answer(workOrderId, output) {
const rows = await db
.update(toolHandoffTable)
.set({ state: 'answered', output, answeredAt: new Date() })
.where(eq(toolHandoffTable.workOrderId, workOrderId))
.returning();
return rows[0] === undefined ? undefined : toHandoff(rows[0]);
},
};
}
@@ -0,0 +1,119 @@
/**
* 未命中转人的纯逻辑(§6.2)—— **人是工具的一种实现**。
*
* 系统里没有这件工具时,不报错、不空转:开一张执行卡请人做,人填回的表单值
* 就是这次调用的出参。这里只放三件纯计算 —— 发卡、存台、立监听都在 app 装配层。
*/
/**
* 入参指纹:同一次调用只问人一次,重问拿同一份答案。
*
* 没有它,Loop 每拍都会为同一件事再发一张卡 —— 负责人工位上堆起一摞一模一样的
* 「请代办」,谁也不知道该答哪张。
*
* 键必须**稳定**:对象键先排序再序列化,不然 `{a,b}` 与 `{b,a}` 会被当成两次不同的调用。
* 这里用的是纯 TS 的 FNV-1a(domain 不得触 node:crypto);它是内容地址,不是安全摘要 ——
* 单任务内十几次调用的碰撞概率可忽略,而长度也一并进键,进一步压掉碰撞。
*/
export function callKeyOf(tool: string, input: Readonly<Record<string, unknown>>): string {
const text = canonicalize(input);
return `${tool}#${text.length}#${fnv1a64(text)}`;
}
function canonicalize(value: unknown): string {
if (value === undefined) return 'null';
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`;
}
/** FNV-1a,分高低两半各 32 位算,拼成 16 位十六进制 —— 纯运算,无依赖 */
function fnv1a64(text: string): string {
let lo = 0x811c9dc5;
let hi = 0x01000193;
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
lo = Math.imul(lo ^ code, 0x01000193) >>> 0;
hi = Math.imul(hi ^ (code + i), 0x85ebca6b) >>> 0;
}
return `${lo.toString(16).padStart(8, '0')}${hi.toString(16).padStart(8, '0')}`;
}
export type HandoffField = {
readonly key: string;
readonly label: string;
readonly kind: 'text' | 'textarea' | 'number';
readonly required: boolean;
};
/**
* 出参 Schema → 回填表单。人照着填的这几格,就是这次调用的返回值。
*
* 没有 Schema(连契约都没有)只给一格自由文本 —— 系统不知道该问什么,
* 就如实只问「结果是什么」,不编造字段骗人填。
*/
export function handoffFields(output: Readonly<Record<string, unknown>> | undefined): HandoffField[] {
const properties = output?.['properties'];
if (typeof properties !== 'object' || properties === null) {
return [{ key: 'result', label: '这件事的结果(照实填写)', kind: 'textarea', required: true }];
}
const declared = output?.['required'];
const required = Array.isArray(declared) ? declared.map(String) : [];
return Object.entries(properties as Record<string, unknown>).map(([key, raw]) => {
const def = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const type = String(def['type'] ?? 'string');
return {
key,
label: String(def['title'] ?? def['description'] ?? key),
kind:
type === 'number' || type === 'integer'
? ('number' as const)
: type === 'string'
? ('text' as const)
: ('textarea' as const),
required: required.includes(key),
};
});
}
/**
* 人填的是字符串,活动等的是出参 Schema 声明的类型 —— 按声明归型。
*
* 归不了型(该是数组却填了一句自然语言)**不硬转、不丢**:原样交回去。
* 硬转出一个 `NaN` 或 `{}`,活动会当成真值继续往下走 —— 那比一个明摆着不合型的
* 字符串更坏,因为错得看不见。
*/
export function coerceBySchema(
output: Readonly<Record<string, unknown>> | undefined,
values: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const properties = output?.['properties'];
if (typeof properties !== 'object' || properties === null) return { ...values };
const props = properties as Record<string, unknown>;
const typed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values)) {
const raw = props[key];
const def = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const type = String(def['type'] ?? 'string');
if (typeof value !== 'string' || type === 'string') {
typed[key] = value;
continue;
}
if (type === 'number' || type === 'integer') {
const n = Number(value);
typed[key] = value.trim() !== '' && Number.isFinite(n) ? n : value;
continue;
}
if (type === 'boolean') {
typed[key] = value === 'true' ? true : value === 'false' ? false : value;
continue;
}
try {
typed[key] = JSON.parse(value);
} catch {
typed[key] = value;
}
}
return typed;
}
@@ -24,7 +24,7 @@ export type BusTool = {
/** 入参契约(JSON Schema)—— 装配层据此生成 zod,模型据此填参 */
readonly input: Record<string, unknown>;
readonly output?: Record<string, unknown>;
readonly kind: 'function' | 'human' | 'llm';
readonly kind: 'function' | 'human' | 'llm' | 'ledger';
/**
* 效应:`read` 当场调当场回;`write` **不当场落地** ——
* 过程中只登记动作意向,放行那一刻才由系统执行(OS 06「过程不落地」)。
@@ -74,6 +74,42 @@ export const HUMAN_TOOL: BusTool = {
effect: 'read',
};
/**
* **台账工具**(系统内化):写草稿。
*
* 基座六件里的 `ledger` —— 活动过程写的唯一形态(铁律②:写只有草稿与意向,
* 登记那一刻世界上什么都没变)。它和调人一样零声明即得:每个活动都要往自己的
* 工作台上记东西,这是 Harness 给的能力,不是活动自带的本事。
*
* `effect` 记 `read` 不是笔误:草稿层**不触达世界**,不该被当成写类工具去登记动作意向 ——
* 那条路是留给真正会改变外部系统的动作的。
*/
export const LEDGER_TOOL: BusTool = {
id: 'ledger.write',
description:
'写台账草稿:把这一步的成果按「域.对象」逐行记下来(草稿层,放行前世界上什么都没变)。' +
'一次可写多行 —— 一份 BOM 一次写完。只准写本活动清单声明过的地址与列名。',
input: {
type: 'object',
required: ['address', 'rows'],
properties: {
address: { type: 'string', description: '台账地址「域.对象」—— 照可写台账字典原样抄,不要拆开' },
rows: {
type: 'array',
description: '要写的行:key 是主键列,patch 是其余列',
items: {
type: 'object',
required: ['key', 'patch'],
properties: { key: { type: 'object' }, patch: { type: 'object' } },
},
},
},
},
output: { type: 'object', properties: { written: { type: 'integer' } } },
kind: 'ledger',
effect: 'read',
};
/**
* **LLM 工具**(系统内化,五件通用):大模型驱动的通用能力,不是「一个模型实例」。
*
@@ -207,6 +243,8 @@ export function compileToolBus(input: ToolBusInput): ToolBusReport {
}
tools.push(HUMAN_TOOL);
// 台账草稿写:零声明即得(基座六件之 ledger)—— 不给它,活动干完的活没地方放
tools.push(LEDGER_TOOL);
if (input.declaredModels.length > 0) tools.push(...LLM_TOOLS);
return { tools, missing, disabled };
@@ -21,6 +21,23 @@ export type ToolContract = {
readonly version: string;
/** 面向 AI 的适用说明:何时用、语义、边界 */
readonly description: string;
/**
* 面向**人**的作业指导段 —— 系统里没有这件工具(未注册)时,转人执行卡上的指导书从这里来。
*
* 与 `description` 分开写:那一份是给 AI 判「该不该调」的,这一份是给人看「怎么做」的。
* 声明式、可审、随包版本冻结 —— 不临场让模型编一段步骤,那样每次说法都不一样,
* 人照着做出来的东西也就不一样。
*/
readonly guide?:
| {
/** 一句话说清要人做什么 */
readonly ask: string;
/** 分步要点 —— 执行卡的作业指导书 */
readonly steps?: readonly string[] | undefined;
/** 什么算做完 */
readonly standard?: string | undefined;
}
| undefined;
readonly input: Record<string, unknown>;
readonly output: Record<string, unknown>;
readonly effect: EffectLevel;
@@ -38,9 +38,17 @@ export function toolReplayKey(call: ToolCall): string {
}
export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
/**
* 取契约。**未注册不抛** —— 返回 undefined,由调用方转人(§6.2)。
*
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」:声明了系统里没有,
* 那是**系统的缺口**,不是活动的错误。抛出去只会让回合崩,人什么也看不到;
* 转人则是「这一步系统还干不了,请你来干」—— 活照样往下走。
* 未授权仍抛:越权是治理配置问题,不能靠转人掩盖过去。
*/
async function loadContract(call: ToolCall) {
const contract = await deps.registry.get(call.tool);
if (contract === undefined) throw new ToolNotFoundError(call.tool);
if (contract === undefined) return undefined;
// 白名单:纯工具是活动私有作用域,功能工具按活动白名单
if (contract.pure === true) {
@@ -62,6 +70,15 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
}
const contract = await loadContract(call);
if (contract === undefined) {
// 系统里连契约都没有:转人时卡面只有工具名与本次输入,如实说明
return {
ok: false,
error: { code: 'tool-unregistered', retryable: false, message: `系统里没有这件工具:${call.tool}` },
latencyMs: 0,
mode: deps.mode,
};
}
if (!(await deps.registry.isEnabled(call.tool))) throw new ToolDisabledError(call.tool);
const inputCheck = deps.schemas.validate(contract.input, call.input);
@@ -106,6 +123,7 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
async disposition(call, preauthorized): Promise<CallDisposition> {
const contract = await loadContract(call);
if (contract === undefined) throw new ToolNotFoundError(call.tool);
return dispositionOf(contract, call.operation ?? call.tool, preauthorized);
},
@@ -113,7 +131,8 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
async validate(call) {
const contract = await loadContract(call);
if (contract.capabilities?.validate !== true) return undefined;
// 无契约就无从预校验 —— 预检那边会以「工具不可用」记一条 finding,不在这里造结论
if (contract?.capabilities?.validate !== true) return undefined;
return run({ ...call, operation: 'validate' });
},
+22
View File
@@ -34,6 +34,28 @@ export type ToolReplayStore = {
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
*/
/**
* 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。
* 内容地址 `callKey` 保证同一次调用只问人一次。
*/
export type ToolHandoffStore = {
find(taskId: string, callKey: string): Promise<ToolHandoff | undefined>;
open(handoff: Omit<ToolHandoff, 'state' | 'output'>): Promise<void>;
/** 人答完:值即出参 */
answer(workOrderId: string, output: Record<string, unknown>): Promise<ToolHandoff | undefined>;
};
export type ToolHandoff = {
readonly id: string;
readonly taskId: string;
readonly tool: string;
readonly callKey: string;
readonly input: Record<string, unknown>;
readonly workOrderId: string;
readonly state: 'pending' | 'answered';
readonly output?: Record<string, unknown> | undefined;
};
export type ToolRuntime = {
execute(input: {
readonly contract: ToolContract;