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

手改是**兜 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
+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;