Archived
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:
co-authored by
Claude Opus 5
parent
14d4fa046a
commit
f0444f1417
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] }>;
|
||||
};
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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[] };
|
||||
};
|
||||
Reference in New Issue
Block a user