M2: 基座能力 —— memory / toolbox / channel / message

- memory   任务记忆四性质(只追加·信封盖戳·治理先行·归侧)、活动记忆四层与条目三件套、
           任务日志两级投影(结构化零 LLM / 叙述性小结不入事实库)、度量八指标只读投影
- toolbox  注册两审(静态审 + 试运行审)、效应等级决定运行时待遇、契约演进只增不破坏、
           白名单与纯工具私有作用域、ToolInvoker(两缝之二,live/record/replay)
- channel  通道实例配置、入站归一(解析 → 系统写 → 数据事件)、出站发送与关联记录登记
- message  通知 / 跨任务征答(就地作答 · 驳回重答只增不改)/ 死信 / 无主兜底

app:message 接上 event 的 MessageFallback;派发器按目标 ID 取 advisory lock 串行消费 +
消费幂等表;/api/ingest 模拟邮件通道(mock 只在外界)

已验证:无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落 OP 岗位的消息要素
门禁五道全绿;150 个单元测试 + 1 个 PG 集成测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:16:52 +08:00
co-authored by Claude Opus 5
parent 14d4fa046a
commit f0444f1417
73 changed files with 7488 additions and 31 deletions
+36
View File
@@ -0,0 +1,36 @@
# module: toolbox
职责: 功能工具注册与两审、调用契约、效应等级与运行时待遇、连接配置声明与凭证绑定、
白名单授权、调用留痕、纯工具的活动私有作用域、`ToolInvoker`(**两条缝之二**)
不负责: 动作意向的存放与放行落地(见 ledger 的 `ActionIntent` 与 release)、
人类工具与 LLM 工具(前者见 human-tool,后者是本模块的一类工具契约)
公开出口: index.ts
- 类型:`ToolboxService` `ToolInvoker` `ToolContract` `ToolCall` `ToolResult` `CallDisposition`
`EffectLevel` `RegisterToolInput` `RegistrationVerdict` `AuditFinding` `Preauthorization`
- 错误:`ToolNotFoundError` `ToolNotAllowedError` `ToolDisabledError` `ToolReplayMissError`
`RegistrationRejectedError`
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `SchemaValidator` `SecretBox` `Clock`
- 用例工厂:`createToolboxService` `createToolInvoker`
- 纯函数:`dispositionOf` `checkEvolution` `staticAudit` `auditPassed` `toolReplayKey`
- adapter:`createToolRegistry` `createToolReplayStore` `createSchemaValidator` `createToolRuntime`
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入,与 llm 同一实现)、`ToolRuntime`
被依赖: channel, release, activity-registry, skill-engine, loop-engine, flow-engine, task,
app(治理台 · 工具管理)
不变量:
- **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10)
- `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError`
- **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域;
不在名单即 `ToolNotAllowedError`,活动代码无从绕开
- **效应等级决定待遇**:读类即调;写类登记为动作意向;唯一例外是契约**显式逐项列举**的预授权
- **契约演进只增不破坏**:出参只增字段、入参新增必为可选、效应等级不得变更;
破坏性变更 = 新工具名
- **注册两审**:静态审有 error 即不上架;试运行审要求样例集非空且全过
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,139 @@
/** 调用契约、效应等级与注册静态审 —— domain 测试零 mock(OS 7.3) */
import { describe, expect, it } from 'vitest';
import { checkEvolution, dispositionOf } from '../internal/domain/tool-contract.ts';
import type { ToolContract } from '../internal/domain/tool-contract.ts';
import { auditPassed, staticAudit } from '../internal/domain/registration-audit.ts';
const contract = (over: Partial<ToolContract> = {}): ToolContract => ({
name: 'crm.customer-read',
version: '1.0.0',
description: '读取客户资料库中的客户档案,用于取客户等级与付款条款',
input: { type: 'object', properties: { customerCode: { type: 'string' } }, required: ['customerCode'] },
output: { type: 'object', properties: { grade: { type: 'string' }, terms: { type: 'string' } } },
effect: 'read',
errors: [{ code: 'not-found', retryable: false, message: '客户不存在' }],
idempotent: true,
timeoutMs: 15_000,
retries: 2,
connectionKeys: ['endpoint', 'apiKey'],
...over,
});
describe('dispositionOf —— 效应等级决定运行时待遇', () => {
it('读类:过程中自由调用', () => {
expect(dispositionOf(contract(), 'read', [])).toEqual({ kind: 'invoke-now', reason: 'read' });
});
it('写类:登记为动作意向,集中在放行时落地', () => {
expect(dispositionOf(contract({ effect: 'write' }), 'send', [])).toEqual({ kind: 'register-intent' });
});
it('写类 + 显式预授权:过程中即时执行', () => {
const disposition = dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'nudge', [
{ tool: 'mail.send', operation: 'nudge' },
]);
expect(disposition).toEqual({ kind: 'invoke-now', reason: 'preauthorized' });
});
it('预授权必须逐项列举:工具对了动作不对,仍是意向', () => {
expect(
dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'quote', [
{ tool: 'mail.send', operation: 'nudge' },
]),
).toEqual({ kind: 'register-intent' });
});
});
describe('checkEvolution —— 契约演进只增不破坏', () => {
it('出参只增字段:合法', () => {
const next = contract({
output: { type: 'object', properties: { grade: {}, terms: {}, risk: {} } },
});
expect(checkEvolution(contract(), next)).toEqual([]);
});
it('出参删字段:拦下', () => {
const next = contract({ output: { type: 'object', properties: { grade: {} } } });
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['output.terms']);
});
it('入参新增必填:拦下', () => {
const next = contract({
input: {
type: 'object',
properties: { customerCode: {}, region: {} },
required: ['customerCode', 'region'],
},
});
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.region']);
});
it('入参新增可选:合法', () => {
const next = contract({
input: { type: 'object', properties: { customerCode: {}, region: {} }, required: ['customerCode'] },
});
expect(checkEvolution(contract(), next)).toEqual([]);
});
it('入参删字段:拦下', () => {
const next = contract({ input: { type: 'object', properties: {}, required: [] } });
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.customerCode']);
});
it('效应等级变更:拦下', () => {
expect(checkEvolution(contract(), contract({ effect: 'write', idempotent: true })).map((v) => v.field)).toContain(
'effect',
);
});
it('改名 = 破坏性变更,必须换新工具名', () => {
const v = checkEvolution(contract(), contract({ name: 'crm.customer-read-v2' }));
expect(v).toHaveLength(1);
expect(v[0]?.field).toBe('name');
});
});
describe('staticAudit —— 注册两审之静态审', () => {
it('规范契约通过', () => {
expect(auditPassed(staticAudit(contract()))).toBe(true);
});
it('工具名须是小写点分标识', () => {
expect(staticAudit(contract({ name: 'CRM_Read' })).map((f) => f.code)).toContain('name-invalid');
});
it('描述过短即拦', () => {
expect(staticAudit(contract({ description: '读' })).map((f) => f.code)).toContain('description-missing');
});
it('入参不是 object schema 即拦', () => {
expect(staticAudit(contract({ input: { type: 'string' } })).map((f) => f.code)).toContain('schema-invalid');
});
it('写类工具必须声明幂等性 —— 落地要附幂等键', () => {
const findings = staticAudit(contract({ effect: 'write', idempotent: false }));
expect(findings.map((f) => f.code)).toContain('idempotency-missing');
expect(auditPassed(findings)).toBe(false);
});
it('超时须在合理区间', () => {
expect(staticAudit(contract({ timeoutMs: 0 })).map((f) => f.code)).toContain('timeout-invalid');
expect(staticAudit(contract({ timeoutMs: 999_999 })).map((f) => f.code)).toContain('timeout-invalid');
});
it('纯工具:无连接配置、无外效应、绑活动作用域', () => {
const pure = contract({ pure: true, connectionKeys: [], effect: 'read', ownerActivityId: 'act-1' });
expect(auditPassed(staticAudit(pure))).toBe(true);
expect(staticAudit({ ...pure, connectionKeys: ['x'] }).map((f) => f.code)).toContain('pure-tool-has-connection');
expect(staticAudit({ ...pure, effect: 'write', idempotent: true }).map((f) => f.code)).toContain('effect-invalid');
const unscoped = staticAudit({ ...pure, ownerActivityId: undefined });
expect(unscoped.map((f) => f.code)).toContain('pure-tool-not-scoped');
});
it('功能工具没申报连接目标只给告警,不阻塞', () => {
const findings = staticAudit(contract({ connectionKeys: [] }));
expect(findings.map((f) => f.code)).toContain('connection-keys-missing');
expect(auditPassed(findings)).toBe(true);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* module: toolbox —— 对外类型(编码规范 §10.1)。
*
* 两条缝之二(§7.3):`ToolInvoker` 支持 record | replay | live ——
* **任何工具执行不经 ToolInvoker,回放审就对它失明**(§12.5#10)。
*/
import type { ActionIntent, TaskId } from '@contracts';
import type {
AuditFinding,
} from './internal/domain/registration-audit.ts';
import type {
CallDisposition,
EffectLevel,
EvolutionViolation,
Preauthorization,
ToolContract,
} from './internal/domain/tool-contract.ts';
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract };
export const INVOKER_MODES = ['live', 'record', 'replay'] as const;
export type InvokerMode = (typeof INVOKER_MODES)[number];
export class ToolNotFoundError extends Error {
constructor(readonly tool: string) {
super(`工具未注册:${tool}`);
this.name = 'ToolNotFoundError';
}
}
export class ToolNotAllowedError extends Error {
constructor(
readonly tool: string,
readonly activityId: string,
) {
super(`工具不在本活动白名单:${tool}(活动 ${activityId})—— 越权在结构上不可能`);
this.name = 'ToolNotAllowedError';
}
}
export class ToolDisabledError extends Error {
constructor(readonly tool: string) {
super(`工具已停用:${tool} —— 活动按失败路径处理`);
this.name = 'ToolDisabledError';
}
}
export class ToolReplayMissError extends Error {
constructor(readonly key: string) {
super(`工具回放缺录:${key} —— 回放模式下不得回退为实调`);
this.name = 'ToolReplayMissError';
}
}
export class RegistrationRejectedError extends Error {
constructor(readonly findings: readonly AuditFinding[]) {
super(`工具注册未过审:${findings.map((f) => f.message).join('; ')}`);
this.name = 'RegistrationRejectedError';
}
}
export type ToolCall = {
readonly tool: string;
readonly operation?: string;
readonly input: Record<string, unknown>;
readonly activityId: string;
readonly taskId?: TaskId;
readonly stepNo?: number;
};
export type ToolResult = {
readonly ok: boolean;
readonly output?: Record<string, unknown>;
readonly error?: { readonly code: string; readonly retryable: boolean; readonly message: string };
readonly latencyMs: number;
readonly mode: InvokerMode;
};
/** 调用留痕:入参快照、出参、时延与错误自动留痕入任务记忆(OS 7.3) */
export type InvocationTrace = {
readonly tool: string;
readonly input: Record<string, unknown>;
readonly result: ToolResult;
readonly at: string;
};
/**
* 工具调用器 —— 活动基座六件里的「工具(调用契约)」。
* **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。
*/
export type ToolInvoker = {
readonly mode: InvokerMode;
/** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */
disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise<CallDisposition>;
invoke(call: ToolCall): Promise<ToolResult>;
/** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */
validate(call: ToolCall): Promise<ToolResult | undefined>;
withMode(mode: InvokerMode): ToolInvoker;
};
export type RegisterToolInput = {
readonly contract: ToolContract;
/** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */
readonly samples: ReadonlyArray<{ input: Record<string, unknown>; expectShape: Record<string, unknown> }>;
/** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */
readonly connectionValues?: Readonly<Record<string, string>>;
};
export type RegistrationVerdict = {
readonly accepted: boolean;
readonly staticFindings: readonly AuditFinding[];
readonly evolution: readonly EvolutionViolation[];
readonly trialRun: { readonly ran: number; readonly passed: number; readonly failures: readonly string[] };
};
export type ToolboxService = {
/** 注册两审:静态审读清单,试运行审验代码 */
register(input: RegisterToolInput): Promise<RegistrationVerdict>;
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
/** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */
setEnabled(name: string, enabled: boolean): Promise<void>;
bindConnection(name: string, values: Readonly<Record<string, string>>): Promise<void>;
/** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */
assertContractsAvailable(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
};
+56
View File
@@ -0,0 +1,56 @@
/**
* module: toolbox —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ActionIntent,
AuditFinding,
CallDisposition,
EffectLevel,
EvolutionViolation,
InvocationTrace,
InvokerMode,
Preauthorization,
RegisterToolInput,
RegistrationVerdict,
ToolCall,
ToolContract,
ToolInvoker,
ToolResult,
ToolboxService,
} from './contract.ts';
export {
INVOKER_MODES,
RegistrationRejectedError,
ToolDisabledError,
ToolNotAllowedError,
ToolNotFoundError,
ToolReplayMissError,
} from './contract.ts';
export type {
Clock,
IdGenerator,
SchemaValidator,
SecretBox,
ToolRegistry,
ToolReplayStore,
ToolRuntime,
} from './port.ts';
export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts';
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
export {
createSchemaValidator,
createToolRegistry,
createToolReplayStore,
createToolRuntime,
isAllowed,
} from './internal/adapter/toolbox-store.ts';
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
@@ -0,0 +1,48 @@
/**
* toolbox 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
*/
import { boolean, index, integer, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const toolTable = sys.table(
'tool',
{
name: text('name').primaryKey(),
version: text('version').notNull(),
description: text('description').notNull(),
input: jsonb('input').notNull(),
output: jsonb('output').notNull(),
effect: text('effect').notNull(),
errors: jsonb('errors').notNull().default([]),
idempotent: boolean('idempotent').notNull().default(false),
timeoutMs: integer('timeout_ms').notNull().default(30_000),
retries: integer('retries').notNull().default(1),
connectionKeys: jsonb('connection_keys').notNull().default([]),
capabilities: jsonb('capabilities'),
pure: boolean('pure').notNull().default(false),
ownerActivityId: text('owner_activity_id'),
/** 凭证隔离:值加密落库,不入包、不入活动、不入留痕明文(ADR-009) */
connectionCipher: jsonb('connection_cipher'),
enabled: boolean('enabled').notNull().default(true),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('tool_effect_idx').on(t.effect), index('tool_owner_idx').on(t.ownerActivityId)],
);
/** 白名单:授权在白名单与契约,不在活动代码(OS 04) */
export const toolAllowlistTable = sys.table(
'tool_allowlist',
{
activityId: text('activity_id').notNull(),
tool: text('tool').notNull(),
},
(t) => [primaryKey({ columns: [t.activityId, t.tool] })],
);
/** 工具录制回放 —— 与 llm 的 replay_model 同构(§7.3 两缝) */
export const replayToolTable = sys.table('replay_tool', {
key: text('key').primaryKey(),
call: jsonb('call').notNull(),
result: jsonb('result').notNull(),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,213 @@
/**
* toolbox 存储与运行时 adapter —— Drizzle / ajv 只准出现在 adapter(编码规范 §5.3 / §11.4)。
*/
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 { ToolCall, ToolResult } from '../../contract.ts';
import type { EffectLevel, ToolContract } from '../domain/tool-contract.ts';
import { replayToolTable, toolAllowlistTable, toolTable } from './toolbox-schema.ts';
type Row = typeof toolTable.$inferSelect;
function toContract(row: Row): ToolContract {
return {
name: row.name,
version: row.version,
description: row.description,
input: row.input as Record<string, unknown>,
output: row.output as Record<string, unknown>,
effect: row.effect as EffectLevel,
errors: row.errors as ToolContract['errors'],
idempotent: row.idempotent,
timeoutMs: row.timeoutMs,
retries: row.retries,
connectionKeys: row.connectionKeys as string[],
...(row.capabilities === null ? {} : { capabilities: row.capabilities as ToolContract['capabilities'] }),
pure: row.pure,
...(row.ownerActivityId === null ? {} : { ownerActivityId: row.ownerActivityId }),
};
}
export function createToolRegistry(db: Db): ToolRegistry {
return {
async list() {
const rows = await db.select().from(toolTable).orderBy(toolTable.name);
return rows.map(toContract);
},
async get(name) {
const rows = await db.select().from(toolTable).where(eq(toolTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toContract(rows[0]);
},
async isEnabled(name) {
const rows = await db.select({ enabled: toolTable.enabled }).from(toolTable).where(eq(toolTable.name, name)).limit(1);
return rows[0]?.enabled ?? false;
},
async upsert(contract, connectionCipher) {
const values = {
name: contract.name,
version: contract.version,
description: contract.description,
input: contract.input,
output: contract.output,
effect: contract.effect,
errors: contract.errors,
idempotent: contract.idempotent,
timeoutMs: contract.timeoutMs,
retries: contract.retries,
connectionKeys: contract.connectionKeys,
capabilities: contract.capabilities ?? null,
pure: contract.pure ?? false,
ownerActivityId: contract.ownerActivityId ?? null,
connectionCipher,
};
await db
.insert(toolTable)
.values(values)
.onConflictDoUpdate({
target: toolTable.name,
set: {
version: values.version,
description: values.description,
input: values.input,
output: values.output,
effect: values.effect,
errors: values.errors,
idempotent: values.idempotent,
timeoutMs: values.timeoutMs,
retries: values.retries,
connectionKeys: values.connectionKeys,
capabilities: values.capabilities,
pure: values.pure,
ownerActivityId: values.ownerActivityId,
// 未传凭证即保持原值(只写不读)
...(connectionCipher === null ? {} : { connectionCipher }),
},
});
},
async setEnabled(name, enabled) {
await db.update(toolTable).set({ enabled }).where(eq(toolTable.name, name));
},
async connectionCipher(name) {
const rows = await db.select({ c: toolTable.connectionCipher }).from(toolTable).where(eq(toolTable.name, name)).limit(1);
const value = rows[0]?.c;
return value === null || value === undefined ? undefined : (value as Record<string, string>);
},
async setAllowlist(activityId, tools) {
await db.transaction(async (tx) => {
await tx.delete(toolAllowlistTable).where(eq(toolAllowlistTable.activityId, activityId));
if (tools.length === 0) return;
await tx
.insert(toolAllowlistTable)
.values(tools.map((tool) => ({ activityId, tool })))
.onConflictDoNothing();
});
},
async allowlistOf(activityId) {
const rows = await db
.select({ tool: toolAllowlistTable.tool })
.from(toolAllowlistTable)
.where(eq(toolAllowlistTable.activityId, activityId));
return rows.map((r) => r.tool);
},
async referencedBy(name) {
const rows = await db
.select({ activityId: toolAllowlistTable.activityId })
.from(toolAllowlistTable)
.where(eq(toolAllowlistTable.tool, name));
return rows.map((r) => r.activityId);
},
};
}
export function createToolReplayStore(db: Db): ToolReplayStore {
return {
async put(record) {
await db
.insert(replayToolTable)
.values({ key: record.key, call: record.call, result: record.result, recordedAt: record.recordedAt })
.onConflictDoUpdate({
target: replayToolTable.key,
set: { call: record.call, result: record.result, recordedAt: record.recordedAt },
});
},
async get(key) {
const rows = await db.select().from(replayToolTable).where(eq(replayToolTable.key, key)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return { call: row.call as ToolCall, result: row.result as ToolResult };
},
};
}
/** ADR-003:声明层 = JSON Schema 2020-12 子集,运行时校验用 ajv */
export function createSchemaValidator(): SchemaValidator {
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
return {
validate(schema, value) {
const validate = ajv.compile(schema);
const ok = validate(value) === true;
return {
ok,
errors: ok ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`.trim()),
};
},
};
}
/**
* 工具执行运行时 —— 实现对系统。
* 处理器由 app 注册(第一方功能工具与纯工具);运行时**只把申报的连接目标交给处理器**,
* 不提供任何其它环境能力(OS 7.3)。
*/
export type ToolHandler = (input: {
readonly input: Record<string, unknown>;
readonly connection: Readonly<Record<string, string>>;
readonly operation: string | undefined;
readonly idempotencyKey: string | undefined;
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
export function createToolRuntime(handlers: ReadonlyMap<string, ToolHandler>): ToolRuntime {
return {
async execute({ contract, connection, call, idempotencyKey }) {
const handler = handlers.get(contract.name);
if (handler === undefined) {
return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } };
}
// 只把申报的连接键交出去,多余的环境值一律不透传
const scoped: Record<string, string> = {};
for (const key of contract.connectionKeys) {
const value = connection[key];
if (value !== undefined) scoped[key] = value;
}
const timeout = new Promise<{ ok: false; error: { code: string; retryable: boolean; message: string } }>((resolve) =>
setTimeout(
() => resolve({ ok: false, error: { code: 'timeout', retryable: true, message: `超时 ${contract.timeoutMs}ms` } }),
contract.timeoutMs,
),
);
return Promise.race([
handler({
input: call.input,
connection: scoped,
operation: call.operation,
idempotencyKey,
}),
timeout,
]);
},
};
}
/** 供 app 使用的辅助:把 `and`/`eq` 的组合暴露给白名单查询(保持 adapter 内聚) */
export async function isAllowed(db: Db, activityId: string, tool: string): Promise<boolean> {
const rows = await db
.select({ tool: toolAllowlistTable.tool })
.from(toolAllowlistTable)
.where(and(eq(toolAllowlistTable.activityId, activityId), eq(toolAllowlistTable.tool, tool)))
.limit(1);
return rows.length > 0;
}
@@ -0,0 +1,83 @@
/**
* 注册两审的静态审 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。
*
* 静态审:签名、无动态执行、调用契约 schema 校验、效应等级核验、连接配置声明齐备。
* 试运行审(以 mock 绑定跑契约测试集)在 usecase —— 它要跑代码,不是纯判定。
*/
import type { ToolContract } from './tool-contract.ts';
export type AuditFinding = {
readonly code:
| 'name-invalid'
| 'description-missing'
| 'schema-invalid'
| 'effect-invalid'
| 'connection-keys-missing'
| 'timeout-invalid'
| 'idempotency-missing'
| 'pure-tool-has-connection'
| 'pure-tool-not-scoped';
readonly severity: 'error' | 'warn';
readonly message: string;
};
const NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
export function staticAudit(contract: ToolContract): AuditFinding[] {
const out: AuditFinding[] = [];
if (!NAME.test(contract.name)) {
out.push({ code: 'name-invalid', severity: 'error', message: '工具名须是小写点分标识,如 crm.customer-read' });
}
if (contract.description.trim().length < 8) {
out.push({
code: 'description-missing',
severity: 'error',
message: '描述是面向 AI 的适用说明(何时用、语义、边界),不得为空或过短',
});
}
if (!isObjectSchema(contract.input) || !isObjectSchema(contract.output)) {
out.push({ code: 'schema-invalid', severity: 'error', message: '入参 / 出参必须是 object 型 JSON Schema' });
}
if (contract.timeoutMs <= 0 || contract.timeoutMs > 300_000) {
out.push({ code: 'timeout-invalid', severity: 'error', message: '超时须在 (0, 300000] 毫秒之间' });
}
if (contract.effect === 'write' && !contract.idempotent) {
// 写类动作执行时附幂等键 —— 契约必须声明幂等性(OS 7.3 调用运行时)
out.push({
code: 'idempotency-missing',
severity: 'error',
message: '写类工具必须声明幂等性:放行落地时要附幂等键',
});
}
if (contract.pure === true) {
// 纯工具的简化规格:无连接配置、无外效应、活动私有作用域
if (contract.connectionKeys.length > 0) {
out.push({ code: 'pure-tool-has-connection', severity: 'error', message: '纯工具不得有连接配置' });
}
if (contract.effect !== 'read') {
out.push({ code: 'effect-invalid', severity: 'error', message: '纯工具无外效应,效应等级只能是读' });
}
if (contract.ownerActivityId === undefined) {
out.push({ code: 'pure-tool-not-scoped', severity: 'error', message: '纯工具须绑定所属活动(私有作用域)' });
}
} else if (contract.connectionKeys.length === 0) {
// 功能工具触达外界,必须申报连接目标 —— 运行时只可访问注册申报的连接目标
out.push({
code: 'connection-keys-missing',
severity: 'warn',
message: '功能工具未申报连接配置键:运行时将无任何可访问目标',
});
}
return out;
}
export function auditPassed(findings: readonly AuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error');
}
function isObjectSchema(schema: Record<string, unknown>): boolean {
return schema['type'] === 'object' && typeof schema['properties'] === 'object' && schema['properties'] !== null;
}
@@ -0,0 +1,107 @@
/**
* 调用契约与效应等级 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。
*
* 工具承载**权力**,约束严于活动包,三面分离:契约对活动,实现对系统,凭证对治理台。
*/
/** 效应等级决定运行时待遇 —— 读类自由调,写类登记为动作意向 */
export const EFFECT_LEVELS = ['read', 'write'] as const;
export type EffectLevel = (typeof EFFECT_LEVELS)[number];
export const EFFECT_LEVEL_LABEL: Readonly<Record<EffectLevel, string>> = { read: '读', write: '写' };
export type ToolContract = {
/** 全局唯一逻辑名 */
readonly name: string;
readonly version: string;
/** 面向 AI 的适用说明:何时用、语义、边界 */
readonly description: string;
readonly input: Record<string, unknown>;
readonly output: Record<string, unknown>;
readonly effect: EffectLevel;
/** 结构化错误码 + 可重试标志 */
readonly errors: ReadonlyArray<{ code: string; retryable: boolean; message: string }>;
readonly idempotent: boolean;
readonly timeoutMs: number;
readonly retries: number;
/** 连接配置声明:只声明键,值不入包(OS 7.3 凭证隔离) */
readonly connectionKeys: readonly string[];
/** ADR-005:`capabilities.validate` 存在即在落地预检时调 */
readonly capabilities?: { readonly validate?: boolean | undefined } | undefined;
/** 纯工具:无外效应,随活动包自带,作用域是活动私有 */
readonly pure?: boolean | undefined;
readonly ownerActivityId?: string | undefined;
};
/* ── 运行时待遇(OS 7.3) ────────────────────────────────────────────── */
export type CallDisposition =
/** 读类:过程中可自由调用(白名单内),读取即快照留痕 */
| { readonly kind: 'invoke-now'; readonly reason: 'read' }
/** 契约显式预授权的写类动作可在过程中即时执行 —— 预授权必须逐项列举 */
| { readonly kind: 'invoke-now'; readonly reason: 'preauthorized' }
/** 写类:登记为动作意向(入参冻结,存入草稿态),放行后由系统执行落地 */
| { readonly kind: 'register-intent' };
export type Preauthorization = { readonly tool: string; readonly operation: string };
export function dispositionOf(
contract: ToolContract,
operation: string,
preauthorized: readonly Preauthorization[],
): CallDisposition {
if (contract.effect === 'read') return { kind: 'invoke-now', reason: 'read' };
const listed = preauthorized.some((p) => p.tool === contract.name && p.operation === operation);
return listed ? { kind: 'invoke-now', reason: 'preauthorized' } : { kind: 'register-intent' };
}
/* ── 契约演进只增不破坏(OS 7.3 必须) ─────────────────────────────── */
export type EvolutionViolation = { readonly field: string; readonly message: string };
/**
* 出参**只增字段**;入参新增字段**必为可选**;破坏性变更 = 新工具名。
* 这里只检查 JSON Schema 的 properties / required 两面 —— 声明层是 JSON Schema 子集(ADR-003)。
*/
export function checkEvolution(previous: ToolContract, next: ToolContract): EvolutionViolation[] {
const out: EvolutionViolation[] = [];
if (previous.name !== next.name) {
out.push({ field: 'name', message: '破坏性变更必须换新工具名,不得原地改名' });
return out;
}
if (previous.effect !== next.effect) {
out.push({ field: 'effect', message: `效应等级不得变更(${previous.effect} → ${next.effect})` });
}
const prevOut = propertyNames(previous.output);
for (const field of prevOut) {
if (!propertyNames(next.output).includes(field)) {
out.push({ field: `output.${field}`, message: '出参只增字段,不得删除' });
}
}
const prevRequired = requiredNames(previous.input);
const nextRequired = requiredNames(next.input);
for (const field of nextRequired) {
if (!prevRequired.includes(field)) {
out.push({ field: `input.${field}`, message: '入参新增字段必为可选,不得直接必填' });
}
}
for (const field of propertyNames(previous.input)) {
if (!propertyNames(next.input).includes(field)) {
out.push({ field: `input.${field}`, message: '入参不得删除已有字段' });
}
}
return out;
}
function propertyNames(schema: Record<string, unknown>): string[] {
const props = schema['properties'];
if (props === null || typeof props !== 'object') return [];
return Object.keys(props as Record<string, unknown>);
}
function requiredNames(schema: Record<string, unknown>): string[] {
const req = schema['required'];
return Array.isArray(req) ? req.filter((x): x is string => typeof x === 'string') : [];
}
@@ -0,0 +1,137 @@
/**
* ToolInvoker —— 两条缝之二(编码规范 §7.3)。
*
* 一切调用经调用契约:入参快照、出参、时延与错误自动留痕;
* 白名单在此把关 —— **越权在结构上不可能**(OS 04 基座即笼子)。
*/
import type { CallDisposition, InvokerMode, ToolCall, ToolInvoker, ToolResult } from '../../contract.ts';
import {
ToolDisabledError,
ToolNotAllowedError,
ToolNotFoundError,
ToolReplayMissError,
} from '../../contract.ts';
import type {
Clock,
SchemaValidator,
SecretBox,
ToolRegistry,
ToolReplayStore,
ToolRuntime,
} from '../../port.ts';
import { dispositionOf } from '../domain/tool-contract.ts';
export type ToolInvokerDeps = {
readonly registry: ToolRegistry;
readonly runtime: ToolRuntime;
readonly replay: ToolReplayStore;
readonly schemas: SchemaValidator;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly mode: InvokerMode;
};
/** 录制回放定位:任务 × 步序号;缺定位退到工具名 + 入参指纹 */
export function toolReplayKey(call: ToolCall): string {
if (call.taskId !== undefined && call.stepNo !== undefined) return `task:${call.taskId}#${call.stepNo}:${call.tool}`;
return `fp:${call.tool}:${hash(JSON.stringify(sortKeys(call.input)))}`;
}
export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
async function loadContract(call: ToolCall) {
const contract = await deps.registry.get(call.tool);
if (contract === undefined) throw new ToolNotFoundError(call.tool);
// 白名单:纯工具是活动私有作用域,功能工具按活动白名单
if (contract.pure === true) {
if (contract.ownerActivityId !== call.activityId) throw new ToolNotAllowedError(call.tool, call.activityId);
} else {
const allowlist = await deps.registry.allowlistOf(call.activityId);
if (!allowlist.includes(call.tool)) throw new ToolNotAllowedError(call.tool, call.activityId);
}
return contract;
}
async function run(call: ToolCall, idempotencyKey?: string): Promise<ToolResult> {
const key = toolReplayKey(call);
if (deps.mode === 'replay') {
const record = await deps.replay.get(key);
if (record === undefined) throw new ToolReplayMissError(key);
return { ...record.result, mode: 'replay' };
}
const contract = await loadContract(call);
if (!(await deps.registry.isEnabled(call.tool))) throw new ToolDisabledError(call.tool);
const inputCheck = deps.schemas.validate(contract.input, call.input);
if (!inputCheck.ok) {
return {
ok: false,
error: { code: 'input-invalid', retryable: false, message: inputCheck.errors.join('; ') },
latencyMs: 0,
mode: deps.mode,
};
}
const cipher = (await deps.registry.connectionCipher(call.tool)) ?? {};
const connection: Record<string, string> = {};
for (const [k, v] of Object.entries(cipher)) connection[k] = deps.secrets.open(v);
const started = deps.clock.now().getTime();
const raw = await deps.runtime.execute({
contract,
connection,
call,
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
});
const latencyMs = deps.clock.now().getTime() - started;
const result: ToolResult = {
ok: raw.ok,
...(raw.output === undefined ? {} : { output: raw.output }),
...(raw.error === undefined ? {} : { error: raw.error }),
latencyMs,
mode: deps.mode,
};
if (deps.mode === 'record') {
await deps.replay.put({ key, call, result, recordedAt: deps.clock.now() });
}
return result;
}
const invoker: ToolInvoker = {
mode: deps.mode,
async disposition(call, preauthorized): Promise<CallDisposition> {
const contract = await loadContract(call);
return dispositionOf(contract, call.operation ?? call.tool, preauthorized);
},
invoke: (call) => run(call),
async validate(call) {
const contract = await loadContract(call);
if (contract.capabilities?.validate !== true) return undefined;
return run({ ...call, operation: 'validate' });
},
withMode(mode) {
return createToolInvoker({ ...deps, mode });
},
};
return invoker;
}
function sortKeys(value: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) out[key] = value[key];
return out;
}
function hash(text: string): string {
let h = 5381;
for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
return h.toString(36);
}
@@ -0,0 +1,115 @@
/**
* toolbox 用例 —— 注册两审、白名单、凭证绑定(编码规范 §5.3;语义出处 OS 7.1 / 7.3)。
*/
import type { RegisterToolInput, RegistrationVerdict, ToolboxService } from '../../contract.ts';
import { RegistrationRejectedError } from '../../contract.ts';
import type { Clock, SchemaValidator, SecretBox, ToolRegistry, ToolRuntime } from '../../port.ts';
import { auditPassed, staticAudit } from '../domain/registration-audit.ts';
import { checkEvolution } from '../domain/tool-contract.ts';
export type ToolboxServiceDeps = {
readonly registry: ToolRegistry;
readonly runtime: ToolRuntime;
readonly schemas: SchemaValidator;
readonly secrets: SecretBox;
readonly clock: Clock;
};
export function createToolboxService(deps: ToolboxServiceDeps): ToolboxService {
return {
/** 注册两审:静态审(读清单)+ 试运行审(以 mock 绑定跑契约测试集),全过方可上架 */
async register(input: RegisterToolInput): Promise<RegistrationVerdict> {
const staticFindings = staticAudit(input.contract);
const previous = await deps.registry.get(input.contract.name);
const evolution = previous === undefined ? [] : checkEvolution(previous, input.contract);
const failures: string[] = [];
let passed = 0;
if (auditPassed(staticFindings) && evolution.length === 0) {
for (const [index, sample] of input.samples.entries()) {
const inputCheck = deps.schemas.validate(input.contract.input, sample.input);
if (!inputCheck.ok) {
failures.push(`样例 ${index + 1} 入参不合契约:${inputCheck.errors.join('; ')}`);
continue;
}
const run = await deps.runtime.execute({
contract: input.contract,
connection: {},
call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' },
});
if (!run.ok) {
failures.push(`样例 ${index + 1} 执行失败:${run.error?.message ?? '未知'}`);
continue;
}
const shapeCheck = deps.schemas.validate(sample.expectShape, run.output ?? {});
if (!shapeCheck.ok) {
failures.push(`样例 ${index + 1} 出参形状不符:${shapeCheck.errors.join('; ')}`);
continue;
}
passed += 1;
}
}
const accepted =
auditPassed(staticFindings) && evolution.length === 0 && failures.length === 0 && input.samples.length > 0;
if (accepted) {
const cipher =
input.connectionValues === undefined
? null
: Object.fromEntries(Object.entries(input.connectionValues).map(([k, v]) => [k, deps.secrets.seal(v)]));
await deps.registry.upsert(input.contract, cipher);
}
return {
accepted,
staticFindings,
evolution,
trialRun: { ran: input.samples.length, passed, failures },
};
},
async list() {
return deps.registry.list();
},
async get(name) {
return deps.registry.get(name);
},
async setEnabled(name, enabled) {
if (!enabled) {
// 有活动声明引用的工具不得删除;停用是合法动作,调用返回结构化错误
await deps.registry.referencedBy(name);
}
await deps.registry.setEnabled(name, enabled);
},
async bindConnection(name, values) {
const contract = await deps.registry.get(name);
if (contract === undefined) {
throw new RegistrationRejectedError([
{ code: 'name-invalid', severity: 'error', message: `工具未注册:${name}` },
]);
}
const cipher = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, deps.secrets.seal(v)]));
await deps.registry.upsert(contract, cipher);
},
async setAllowlist(activityId, tools) {
await deps.registry.setAllowlist(activityId, tools);
},
async allowlistOf(activityId) {
return deps.registry.allowlistOf(activityId);
},
async assertContractsAvailable(names) {
const all = await deps.registry.list();
const known = new Set(all.map((c) => c.name));
const missing = [...new Set(names)].filter((n) => !known.has(n));
return { ok: missing.length === 0, missing };
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/toolbox",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+49
View File
@@ -0,0 +1,49 @@
/**
* module: toolbox —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ToolContract } from './internal/domain/tool-contract.ts';
import type { ToolCall, ToolResult } from './contract.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 凭证密封 —— 与 llm 同一实现,由 app 注入(ADR-009) */
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
export type ToolRegistry = {
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
isEnabled(name: string): Promise<boolean>;
upsert(contract: ToolContract, connectionCipher: Record<string, string> | null): Promise<void>;
setEnabled(name: string, enabled: boolean): Promise<void>;
connectionCipher(name: string): Promise<Record<string, string> | undefined>;
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 有活动声明引用即不得删除 */
referencedBy(name: string): Promise<string[]>;
};
/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */
export type ToolReplayStore = {
put(record: { key: string; call: ToolCall; result: ToolResult; recordedAt: Date }): Promise<void>;
get(key: string): Promise<{ call: ToolCall; result: ToolResult } | undefined>;
};
/**
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
*/
export type ToolRuntime = {
execute(input: {
readonly contract: ToolContract;
readonly connection: Readonly<Record<string, string>>;
readonly call: ToolCall;
readonly idempotencyKey?: string;
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
};
/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};