diff --git a/README.md b/README.md index bdeefb8..2203fd7 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ the trace, just like the audit log. | Adding it to an app | Wrap logic in framework primitives | Decorate functions you already have | | CRUD on a database | Often raw SQL tools, or custom nodes | Calls your typed methods, inheriting your auth | | Debuggability | Debug the framework's internals | Debug your own small loop and your own functions | -| Languages | Python-first | Python + Go today (Go in alpha); TypeScript next | +| Languages | Python-first | Python, Go, and TypeScript — one shared conformance suite | ## Status @@ -413,7 +413,7 @@ before `1.0`. - [x] Instant web integration — `reins.endpoint`, ``, session-identity → RLS - [x] A flagship end-to-end example (FastAPI + SQLAlchemy, gated writes) — `make demo` - [x] **Go package** (`packages/go`) — same verbs, passes the shared conformance suite; capabilities + classification + linter, the `Ask`/`Run` loop, full safety (policy/approval/audit/RLS), progressive disclosure, an OpenAI-compatible adapter for all providers above, GORM `FromORM`, and a `reins inspect|ask|chat ` CLI. Remaining: a native Go code-mode sandbox. -- [ ] TypeScript package, verified against the same shared conformance suite +- [x] **TypeScript package** (`packages/js`) — same verbs, **passes the shared conformance suite**; the core types + `Model`/`FakeModel`, capabilities + classification + linter, the async `ask`/`run` loop, full safety (policy/approval/audit/RLS), and dual progressive disclosure. Remaining: a native TS code-mode sandbox, `fromORM` (Prisma/Drizzle), a CLI, and ``. ## Development diff --git a/packages/js/package-lock.json b/packages/js/package-lock.json index bf0be78..4fee611 100644 --- a/packages/js/package-lock.json +++ b/packages/js/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0-alpha.1", "license": "MIT", "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", + "js-yaml": "^5.2.1", "prettier": "^3.4.2", "typescript": "^5.7.2", "vitest": "^3.0.5" @@ -881,6 +883,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -1006,6 +1015,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1190,6 +1206,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", diff --git a/packages/js/package.json b/packages/js/package.json index a0f632c..26df3e0 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20" }, @@ -25,7 +27,9 @@ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", + "js-yaml": "^5.2.1", "prettier": "^3.4.2", "typescript": "^5.7.2", "vitest": "^3.0.5" diff --git a/packages/js/src/disclosure.ts b/packages/js/src/disclosure.ts new file mode 100644 index 0000000..58d1849 --- /dev/null +++ b/packages/js/src/disclosure.ts @@ -0,0 +1,204 @@ +import type { CapabilityResult, JSONSchema, SchemaIndex, ToolCall, ToolSpec } from './types.js'; +import { resultOk, resultError, toolSpec } from './types.js'; +import type { CapabilityRegistry } from './registry.js'; +import { ReinsError } from './errors.js'; + +/** + * Dual progressive disclosure (M7.5, invariant §2.12): never dump every capability + * schema or the whole data model into context. Above a small threshold the model + * sees one constant-size `search_capabilities` tool instead of the full registry; + * matches are exposed and become directly callable. A {@link SchemaIndex}, when + * attached, is likewise reachable only through `search_schema`. Mirrors the Python + * and Go ports so the base-context behavior is language-neutral (asserted by the + * shared conformance suite). + */ + +export const SEARCH_CAPABILITIES = 'search_capabilities'; +export const SEARCH_SCHEMA = 'search_schema'; + +const DEFER_AFTER = 12; // above this many capabilities, defer them behind search +const MAX_CAPABILITY_MATCH = 8; +const MAX_TABLE_MATCH = 5; +const MAX_QUERY_CHARS = 200; + +const querySchema: JSONSchema = { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, +}; + +const searchCapabilitiesSpec: ToolSpec = { + name: SEARCH_CAPABILITIES, + description: + 'Search the available operations by keyword and load the matches. ' + + 'Call this first to find the operations you need; matches become directly callable.', + input_schema: querySchema, +}; + +const searchSchemaSpec: ToolSpec = { + name: SEARCH_SCHEMA, + description: "Search the application's data model by keyword. Returns only the relevant tables.", + input_schema: querySchema, +}; + +/** + * Per-run state: what the model can currently see. `forceDefer` undefined (auto) + * defers capabilities behind search when more than {@link DEFER_AFTER} are + * registered; a boolean forces it on or off. + */ +export class Disclosure { + private readonly exposed = new Set(); + + constructor( + private readonly registry: CapabilityRegistry, + private readonly schema?: SchemaIndex, + private readonly forceDefer?: boolean, + ) { + if (this.deferred() && registry.get(SEARCH_CAPABILITIES)) { + throw new ReinsError( + `capability "${SEARCH_CAPABILITIES}" collides with the built-in discovery tool`, + 'rename your capability — that name is reserved while disclosure is active', + ); + } + if (schema && registry.get(SEARCH_SCHEMA)) { + throw new ReinsError( + `capability "${SEARCH_SCHEMA}" collides with the built-in discovery tool`, + 'rename your capability — that name is reserved while a schema is attached', + ); + } + } + + private deferred(): boolean { + return this.forceDefer ?? this.registry.list().length > DEFER_AFTER; + } + + /** The tool surface for the next model turn — tiny and constant when deferred. */ + tools(): ToolSpec[] { + const specs: ToolSpec[] = []; + if (this.deferred()) { + specs.push(searchCapabilitiesSpec); + for (const name of [...this.exposed].sort()) { + const cap = this.registry.get(name); + if (cap) { + specs.push(toolSpec(cap.spec)); + } + } + } else { + specs.push(...this.registry.toolSpecs()); + } + if (this.schema) { + specs.push(searchSchemaSpec); + } + return specs; + } + + /** + * Handle a discovery call, or return undefined if `call` is not one. Discovery is + * a harness-internal read: deterministic, never gated, allowed under ask(). + */ + answer(call: ToolCall): CapabilityResult | undefined { + if (call.name === SEARCH_CAPABILITIES && this.deferred()) { + return this.query(call, (q) => this.searchCaps(q)); + } + if (call.name === SEARCH_SCHEMA && this.schema) { + return this.query(call, (q) => this.searchTables(q)); + } + return undefined; + } + + private query(call: ToolCall, handler: (q: string) => CapabilityResult): CapabilityResult { + const raw = call.arguments?.['query']; + let q = typeof raw === 'string' ? raw.trim() : ''; + if (q === '') { + return resultError( + `${call.name}: 'query' must be a non-empty string\n → pass keywords, e.g. query="refund order"`, + ); + } + if (q.length > MAX_QUERY_CHARS) { + q = q.slice(0, MAX_QUERY_CHARS); + } + return handler(q); + } + + private searchCaps(query: string): CapabilityResult { + const tokens = tokenize(query); + const ranked = this.registry + .list() + .map((spec) => ({ spec, score: score(tokens, spec.name, spec.description) })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score || compareStr(a.spec.name, b.spec.name)); + if (ranked.length === 0) { + return resultError(`no operations matched "${query}"\n → try different keywords`); + } + const lines = ['Found operation(s); they are now directly callable:']; + for (const r of ranked.slice(0, MAX_CAPABILITY_MATCH)) { + this.exposed.add(r.spec.name); + lines.push(` ${r.spec.name}(${schemaParamNames(r.spec.input_schema).join(', ')})`); + } + return resultOk(lines.join('\n')); + } + + private searchTables(query: string): CapabilityResult { + const tokens = tokenize(query); + const tables = this.schema?.tables ?? []; + const ranked = tables + .map((table) => ({ table, score: score(tokens, table.name, table.description ?? '') })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score || compareStr(a.table.name, b.table.name)); + if (ranked.length === 0) { + return resultError(`no tables matched "${query}"\n → try different keywords`); + } + const lines = ['Found table(s):']; + for (const r of ranked.slice(0, MAX_TABLE_MATCH)) { + lines.push(` ${r.table.name} (fields: ${(r.table.columns ?? []).join(', ')})`); + } + return resultOk(lines.join('\n')); + } +} + +function schemaParamNames(schema?: JSONSchema): string[] { + const props = schema?.['properties']; + if (typeof props === 'object' && props !== null) { + return Object.keys(props).sort(); + } + return []; +} + +function compareStr(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Lowercase alphanumeric word set — the shared tokenization for keyword matching. */ +function tokenize(text: string): Set { + const out = new Set(); + for (const word of text.toLowerCase().split(/[^a-z0-9]+/)) { + if (word) { + out.add(word); + } + } + return out; +} + +/** Keyword relevance: exact name token +3, name substring +2, description token +1. */ +function score(query: Set, name: string, description: string): number { + const nameTokens = tokenize(name); + const descTokens = tokenize(description); + let total = 0; + for (const token of query) { + if (nameTokens.has(token)) { + total += 3; + } else { + for (const nt of nameTokens) { + if (nt.includes(token)) { + total += 2; + break; + } + } + } + if (descTokens.has(token)) { + total += 1; + } + } + return total; +} diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 5fe8c8d..668a41e 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -21,4 +21,5 @@ export * from './policy.js'; export * from './approval.js'; export * from './audit.js'; export * from './rls.js'; +export * from './disclosure.js'; export * from './loop.js'; diff --git a/packages/js/src/loop.ts b/packages/js/src/loop.ts index 75b1a10..5523519 100644 --- a/packages/js/src/loop.ts +++ b/packages/js/src/loop.ts @@ -7,6 +7,8 @@ import type { ModelResponse, RunResult, RunState, + SchemaIndex, + ToolCall, Usage, } from './types.js'; import type { Model } from './model.js'; @@ -26,6 +28,7 @@ import { } from './approval.js'; import { type AuditRecord, type AuditSink, MemoryAuditSink, redactArguments } from './audit.js'; import { scopeViolation } from './rls.js'; +import { Disclosure } from './disclosure.js'; /** * The harness loop and the Agent — the TypeScript mirror of the ETCSLV lifecycle: @@ -75,6 +78,10 @@ export interface RunOptions { approve?: ApprovalHandler | ApprovalFn; audit?: AuditSink; confirmBulk?: boolean; + /** A data-model index reachable via search_schema (progressive disclosure). */ + schema?: SchemaIndex; + /** Force capability disclosure on/off (default: auto, above the defer threshold). */ + defer?: boolean; } /** The immutable per-run wiring the loop's helpers share. */ @@ -87,6 +94,7 @@ interface RunEnv { ctx: Context; canWrite: boolean; autonomy: AutonomyLevel; + disclosure: Disclosure; } /** The mutable per-run state the loop accumulates. */ @@ -122,6 +130,7 @@ export async function runLoop( ctx: { principal: options.principal, trace_id: newTraceId() }, canWrite, autonomy, + disclosure: new Disclosure(registry, options.schema, options.defer), }; const tally: Tally = { executed: [], wrote: false, audit: [] }; @@ -150,7 +159,7 @@ export async function runLoop( ); } - const resp = await model.complete(messages, registry.toolSpecs(), params); + const resp = await model.complete(messages, env.disclosure.tools(), params); usage = addUsage(usage, resp.usage); messages.push(resp.message); last = resp; @@ -197,7 +206,18 @@ async function act( usage: Usage, turn: number, ): Promise { - const items = toolCalls.map((call) => ({ call, cap: env.registry.get(call.name) })); + // Discovery calls (search_capabilities / search_schema) are harness-internal + // reads: answered here, free at every autonomy level, allowed under ask() (§2.12). + const pending: ToolCall[] = []; + for (const call of toolCalls) { + const answered = env.disclosure.answer(call); + if (answered) { + messages.push(toolMessage(call.id, answered)); + continue; + } + pending.push(call); + } + const items = pending.map((call) => ({ call, cap: env.registry.get(call.name) })); if (!env.canWrite) { // ask(): structurally read-only — refuse the whole turn if any known call writes (§2.1). @@ -447,6 +467,10 @@ export interface AgentConfig { confirmBulk?: boolean; /** Override the policy engine (default: the autonomy ladder). */ policy?: Policy; + /** A data-model index reachable via search_schema (progressive disclosure). */ + schema?: SchemaIndex; + /** Force capability disclosure on/off (default: auto, above the defer threshold). */ + defer?: boolean; } /** @@ -489,6 +513,8 @@ export class Agent { if (this.config.approve !== undefined) options.approve = this.config.approve; if (this.config.confirmBulk !== undefined) options.confirmBulk = this.config.confirmBulk; if (this.config.policy !== undefined) options.policy = this.config.policy; + if (this.config.schema !== undefined) options.schema = this.config.schema; + if (this.config.defer !== undefined) options.defer = this.config.defer; const rep = await runLoop(this.model, this.registry, goal, canWrite, options); return { output: rep.output, reason: rep.finish, usage: rep.usage, trace_id: rep.trace_id }; diff --git a/packages/js/tests/conformance.test.ts b/packages/js/tests/conformance.test.ts new file mode 100644 index 0000000..f3de058 --- /dev/null +++ b/packages/js/tests/conformance.test.ts @@ -0,0 +1,157 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as yaml from 'js-yaml'; +import { describe, it, expect } from 'vitest'; +import { + runLoop, + CapabilityRegistry, + FakeModel, + finalResponse, + callResponse, + classify, + lint, + type AutonomyLevel, + type BoundCapability, + type ModelResponse, + type RunOptions, +} from '../src/index.js'; + +/** + * The TypeScript adapter for the cross-language conformance suite (M7.5): it loads + * the same language-neutral cases in spec/conformance/cases/*.yaml and drives the TS + * harness with a scripted FakeModel, asserting each case's `expect` block. This is + * how parity (§2.21) and the safety invariants are locked across Python, Go, and TS. + * + * Code-mode programs are a language-specific dialect (a {program} step carries + * Python source), so those golden cases are skipped here with a recorded reason — + * TS code-mode is validated by native tests instead. Every other case is neutral. + */ + +const casesDir = fileURLToPath(new URL('../../../spec/conformance/cases', import.meta.url)); + +interface ConformanceCase { + name: string; + verb?: string; + goal?: string; + capabilities?: string[]; + model_script?: Array>; + autonomy?: string; + approvals?: string; + budget?: { max_turns?: number }; + capability?: Record; + expect: Record; +} + +function loadCases(): ConformanceCase[] { + return readdirSync(casesDir) + .filter((f) => f.endsWith('.yaml')) + .sort() + .map((f) => yaml.load(readFileSync(join(casesDir, f), 'utf8')) as ConformanceCase); +} + +/** A permissive stub: additionalProperties, access classified from the name, returns []. */ +function stub(name: string): BoundCapability { + return { + spec: { + name, + description: `Stub capability ${name}.`, + input_schema: { type: 'object', additionalProperties: true }, + access: classify(name), + confirm: false, + idempotent: false, + }, + handler: () => [], + returnsRaw: false, + }; +} + +function scriptModel(script: Array>): FakeModel { + const responses: ModelResponse[] = []; + script.forEach((step, i) => { + if ('final' in step) { + responses.push(finalResponse(String(step['final']))); + } else if ('call' in step) { + responses.push( + callResponse( + `call_${i}`, + String(step['call']), + (step['args'] as Record) ?? {}, + ), + ); + } + }); + return new FakeModel(...responses); +} + +async function driveGolden(c: ConformanceCase): Promise { + const reg = new CapabilityRegistry(); + for (const name of c.capabilities ?? []) { + reg.register(stub(name)); + } + const options: RunOptions = { approve: () => c.approvals === 'allow' }; + if (c.autonomy) { + options.autonomy = c.autonomy as AutonomyLevel; + } + if (c.budget?.max_turns) { + options.budget = { max_turns: c.budget.max_turns }; + } + + const rep = await runLoop( + scriptModel(c.model_script ?? []), + reg, + c.goal ?? '', + c.verb === 'run', + options, + ); + + const e = c.expect; + if (typeof e['outcome'] === 'string') { + expect(rep.outcome, 'outcome').toBe(e['outcome']); + } + if (typeof e['reason'] === 'string') { + expect(rep.reason, 'reason').toBe(e['reason']); + } + if (Array.isArray(e['executed'])) { + expect(rep.executed, 'executed').toEqual(e['executed']); + } + if (typeof e['audited'] === 'boolean') { + expect(rep.audited, 'audited').toBe(e['audited']); + } +} + +function driveLinter(c: ConformanceCase): void { + const cap = c.capability!; + const report = lint( + String(cap['name'] ?? ''), + String(cap['description'] ?? ''), + Number(cap['params'] ?? 0), + cap['returns'] === 'raw_row', + ); + const e = c.expect; + if (e['lint'] === 'pass') { + expect(report.ok, `violations: ${report.violations.join(', ')}`).toBe(true); + } + if (e['lint'] === 'fail') { + expect(report.ok).toBe(false); + } + if (Array.isArray(e['violations'])) { + expect([...report.violations].sort()).toEqual([...(e['violations'] as string[])].sort()); + } +} + +describe('conformance suite (shared spec/conformance/cases)', () => { + const cases = loadCases(); + expect(cases.length).toBeGreaterThan(0); + + for (const c of cases) { + if (c.capability) { + it(c.name, () => driveLinter(c)); + } else if ((c.model_script ?? []).some((s) => 'program' in s)) { + // Code-mode program dialect is language-specific (Python source) — see contract §7.5. + it.skip(`${c.name} (code-mode program is a language-specific dialect)`, () => {}); + } else { + it(c.name, () => driveGolden(c)); + } + } +}); diff --git a/packages/js/tests/disclosure.test.ts b/packages/js/tests/disclosure.test.ts new file mode 100644 index 0000000..7b416bb --- /dev/null +++ b/packages/js/tests/disclosure.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { + Disclosure, + CapabilityRegistry, + capability, + SEARCH_CAPABILITIES, + SEARCH_SCHEMA, + ReinsError, + type SchemaIndex, +} from '../src/index.js'; + +const TOPICS = [ + 'refunds', + 'orders', + 'users', + 'products', + 'invoices', + 'payments', + 'shipments', + 'returns', + 'reviews', + 'carts', + 'coupons', + 'sessions', + 'logs', +]; + +function bigRegistry(): CapabilityRegistry { + const reg = new CapabilityRegistry(); + for (const t of TOPICS) { + reg.register(capability(`find_${t}`, { description: `Find ${t}.` }, () => [])); + } + return reg; // 13 capabilities → above the defer threshold +} + +describe('Disclosure — deferral', () => { + it('shows only the search tool when many capabilities are registered', () => { + const d = new Disclosure(bigRegistry()); + const tools = d.tools(); + expect(tools).toHaveLength(1); + expect(tools[0]?.name).toBe(SEARCH_CAPABILITIES); + }); + + it('exposes the full surface when few capabilities are registered', () => { + const reg = new CapabilityRegistry(); + reg.register(capability('find_orders', { description: 'Find orders.' }, () => [])); + const d = new Disclosure(reg); + expect(d.tools().map((t) => t.name)).toEqual(['find_orders']); + }); + + it('search exposes the matches and makes them directly callable', () => { + const d = new Disclosure(bigRegistry()); + const result = d.answer({ + id: 'c1', + name: SEARCH_CAPABILITIES, + arguments: { query: 'refunds' }, + }); + expect(result?.ok).toBe(true); + expect(String(result?.value)).toContain('find_refunds'); + // After the search, the matched capability appears in the tool surface. + const names = d.tools().map((t) => t.name); + expect(names).toContain(SEARCH_CAPABILITIES); + expect(names).toContain('find_refunds'); + expect(names).not.toContain('find_logs'); // unrelated, not exposed + }); + + it('reports no matches with a hint', () => { + const d = new Disclosure(bigRegistry()); + const result = d.answer({ id: 'c1', name: SEARCH_CAPABILITIES, arguments: { query: 'zzzzz' } }); + expect(result?.ok).toBe(false); + expect(result?.error).toContain('no operations matched'); + }); + + it('rejects an empty query', () => { + const d = new Disclosure(bigRegistry()); + const result = d.answer({ id: 'c1', name: SEARCH_CAPABILITIES, arguments: { query: ' ' } }); + expect(result?.ok).toBe(false); + expect(result?.error).toContain("'query' must be a non-empty string"); + }); + + it('does not answer a non-discovery call', () => { + const d = new Disclosure(bigRegistry()); + expect(d.answer({ id: 'c1', name: 'find_refunds', arguments: {} })).toBeUndefined(); + }); +}); + +describe('Disclosure — schema search', () => { + const schema: SchemaIndex = { + tables: [ + { name: 'refunds', description: 'Issued refunds.', columns: ['id', 'order_id', 'amount'] }, + { name: 'orders', description: 'Customer orders.', columns: ['id', 'status'] }, + ], + }; + + it('adds search_schema and returns matching tables', () => { + const reg = new CapabilityRegistry(); + reg.register(capability('find_orders', { description: 'Find orders.' }, () => [])); + const d = new Disclosure(reg, schema); + expect(d.tools().map((t) => t.name)).toContain(SEARCH_SCHEMA); + + const result = d.answer({ id: 'c1', name: SEARCH_SCHEMA, arguments: { query: 'refunds' } }); + expect(result?.ok).toBe(true); + expect(String(result?.value)).toContain('refunds'); + expect(String(result?.value)).toContain('amount'); + }); +}); + +describe('Disclosure — collisions (control-plane)', () => { + it('rejects a capability that collides with the discovery tool when deferred', () => { + const reg = bigRegistry(); + reg.register( + capability(SEARCH_CAPABILITIES, { description: 'A colliding capability.' }, () => []), + ); + expect(() => new Disclosure(reg)).toThrow(ReinsError); + }); +});