Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user