From 6bc1feea13d855c68a945db82fd6cb96a6c61851 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:49:09 -0400 Subject: [PATCH 1/3] feat(kernel-utils): add described*() combinators for guard+schema authoring Add a `./described` export whose combinators author an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source. The combinator namespace is exported as `S` (mirroring `@endo/patterns`'s `M`): each leaf (`S.string`/`number`/`boolean`/`arrayOf`/`record`/`object`/`nothing`) yields a `{ pattern, schema }` pair; `S.arg` names a positional parameter; and `S.method` / `S.interface` assemble them into a `{ guard, schema }` / `{ interfaceGuard, schemas }` ready to splat into `makeDiscoverableExo`. Because the enforced pattern and the descriptive schema are projected from the same authored leaves, their conformance is a construction invariant rather than an after-the-fact check. Method guards use `M.callWhen(...).returns(...)` (exo methods are invoked across an eventual-send boundary) and the interface guard sets `defaultGuards: 'passable'` so the injected `__getDescription__` is allowed. Co-Authored-By: Claude Opus 4.8 --- packages/kernel-utils/CHANGELOG.md | 1 + packages/kernel-utils/package.json | 10 + packages/kernel-utils/src/described.test.ts | 190 +++++++++++++ packages/kernel-utils/src/described.ts | 298 ++++++++++++++++++++ packages/kernel-utils/src/index.test.ts | 1 + packages/kernel-utils/src/index.ts | 8 + 6 files changed, 508 insertions(+) create mode 100644 packages/kernel-utils/src/described.test.ts create mode 100644 packages/kernel-utils/src/described.ts diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index e03168b0f3..94ba1e0303 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add a `./described` export with a combinator namespace `S` (`S.string`/`S.number`/`S.boolean`/`S.arrayOf`/`S.record`/`S.object`/`S.nothing` leaves, plus `S.arg`/`S.method`/`S.interface`) that authors an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source, so a discoverable exo's enforced shape and its `__getDescription__` hint cannot drift ([#958](https://github.com/MetaMask/ocap-kernel/pull/958)) - Add `getLibp2pRelayHome()` to the `./nodejs` exports, returning the libp2p relay's bookkeeping directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) — kept separate from `$OCAP_HOME` so one relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) - `startRelay()` accepts an optional `publicIp` that is fed to libp2p's `appendAnnounce`, so a relay running on a NAT-backed host can announce its publicly-reachable IPv4 alongside its bound NIC addresses ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index b57ed11fbc..9ea10f3982 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -59,6 +59,16 @@ "default": "./dist/discoverable.cjs" } }, + "./described": { + "import": { + "types": "./dist/described.d.mts", + "default": "./dist/described.mjs" + }, + "require": { + "types": "./dist/described.d.cts", + "default": "./dist/described.cjs" + } + }, "./libp2p": { "import": { "types": "./dist/libp2p-relay.d.mts", diff --git a/packages/kernel-utils/src/described.test.ts b/packages/kernel-utils/src/described.test.ts new file mode 100644 index 0000000000..7655e438e0 --- /dev/null +++ b/packages/kernel-utils/src/described.test.ts @@ -0,0 +1,190 @@ +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import { describe, expect, it } from 'vitest'; + +import { S } from './described.ts'; + +type MethodGuardPayload = { + argGuards: unknown[]; + optionalArgGuards?: unknown[]; + returnGuard: unknown; +}; + +const payloadOf = (guard: unknown): MethodGuardPayload => + getMethodGuardPayload(guard as never) as unknown as MethodGuardPayload; + +describe('leaves', () => { + it.each([ + { + name: 'string', + described: S.string('a word'), + schema: { type: 'string', description: 'a word' }, + ok: 'hello', + bad: 42, + }, + { + name: 'number', + described: S.number(), + schema: { type: 'number' }, + ok: 42, + bad: 'hello', + }, + { + name: 'boolean', + described: S.boolean(), + schema: { type: 'boolean' }, + ok: true, + bad: 1, + }, + ])( + 'builds a $name leaf whose pattern and schema agree', + ({ described, schema, ok, bad }) => { + expect(described.schema).toStrictEqual(schema); + expect(matches(ok, described.pattern)).toBe(true); + expect(matches(bad, described.pattern)).toBe(false); + }, + ); + + it('builds an arrayOf leaf', () => { + const described = S.arrayOf(S.number(), 'the summands'); + expect(described.schema).toStrictEqual({ + type: 'array', + items: { type: 'number' }, + description: 'the summands', + }); + expect(matches([1, 2, 3], described.pattern)).toBe(true); + expect(matches(['a'], described.pattern)).toBe(false); + }); + + it('builds an open record leaf that allows any keys', () => { + const described = S.record('attachments'); + expect(described.schema).toStrictEqual({ + type: 'object', + properties: {}, + additionalProperties: true, + description: 'attachments', + }); + expect(matches({ anything: 1, goes: 'here' }, described.pattern)).toBe( + true, + ); + expect(matches(42, described.pattern)).toBe(false); + }); + + it('builds a closed object leaf with required and optional properties', () => { + const described = S.object( + { id: S.string(), label: S.string() }, + { optional: ['label'] }, + ); + expect(described.schema).toStrictEqual({ + type: 'object', + properties: { id: { type: 'string' }, label: { type: 'string' } }, + required: ['id'], + }); + expect(matches({ id: 'x' }, described.pattern)).toBe(true); + expect(matches({ id: 'x', label: 'y' }, described.pattern)).toBe(true); + expect(matches({ label: 'y' }, described.pattern)).toBe(false); + }); + + it('builds a void return leaf with no schema', () => { + const described = S.nothing(); + expect(described.schema).toBeUndefined(); + expect(matches(undefined, described.pattern)).toBe(true); + expect(matches('something', described.pattern)).toBe(false); + }); +}); + +describe('S.method', () => { + it('builds a guard and schema from named args', () => { + const method = S.method( + 'Add a list of numbers.', + [S.arg('summands', S.arrayOf(S.number()))], + S.number('The sum of the numbers.'), + ); + expect(method.schema).toStrictEqual({ + description: 'Add a list of numbers.', + args: { summands: { type: 'array', items: { type: 'number' } } }, + returns: { type: 'number', description: 'The sum of the numbers.' }, + }); + const payload = payloadOf(method.guard); + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards ?? []).toHaveLength(0); + }); + + it('omits `returns` from the schema for a void method', () => { + const method = S.method( + 'Return a final response.', + [S.arg('final', S.string())], + S.nothing(), + ); + expect(method.schema.returns).toBeUndefined(); + expect('returns' in method.schema).toBe(false); + }); + + it('places optional args in the guard as trailing optionals', () => { + const method = S.method( + 'Return a final response.', + [ + S.arg('final', S.string()), + S.arg('attachments', S.record(), { optional: true }), + ], + S.nothing(), + ); + const payload = payloadOf(method.guard); + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(1); + expect(method.schema.args).toStrictEqual({ + final: { type: 'string' }, + attachments: { + type: 'object', + properties: {}, + additionalProperties: true, + }, + }); + }); + + it('handles a no-arg method', () => { + const method = S.method('Get the moon phase.', [], S.string()); + expect(method.schema.args).toStrictEqual({}); + expect(payloadOf(method.guard).argGuards).toHaveLength(0); + }); + + it('throws when an optional argument precedes a required one', () => { + expect(() => + S.method( + 'bad', + [S.arg('a', S.string(), { optional: true }), S.arg('b', S.string())], + S.nothing(), + ), + ).toThrow(/optional arguments must be trailing/u); + }); +}); + +describe('S.interface', () => { + it('collects method guards and schemas, defaulting unlisted methods to passable', () => { + const { interfaceGuard, schemas } = S.interface('Math', { + add: S.method( + 'Add a list of numbers.', + [S.arg('summands', S.arrayOf(S.number()))], + S.number('The sum of the numbers.'), + ), + count: S.method( + 'Count characters.', + [S.arg('word', S.string('The string to measure.'))], + S.number(), + ), + }); + + expect(Object.keys(schemas)).toStrictEqual(['add', 'count']); + const payload = getInterfaceGuardPayload(interfaceGuard) as unknown as { + interfaceName: string; + methodGuards: Record; + defaultGuards?: string; + }; + expect(payload.interfaceName).toBe('Math'); + expect(Object.keys(payload.methodGuards)).toStrictEqual(['add', 'count']); + expect(payload.defaultGuards).toBe('passable'); + }); +}); diff --git a/packages/kernel-utils/src/described.ts b/packages/kernel-utils/src/described.ts new file mode 100644 index 0000000000..693cad6c7d --- /dev/null +++ b/packages/kernel-utils/src/described.ts @@ -0,0 +1,298 @@ +import { M } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import type { JsonSchema, MethodSchema } from './schema.ts'; + +/** + * A described value: an `@endo/patterns` {@link Pattern} (the enforced shape) paired + * with the {@link JsonSchema} that hangs descriptive text on that shape. + * + * The pattern is the source of truth for the invocable shape; the schema is a + * semantic-hint projection. Authoring both from one leaf is what makes their + * conformance a construction invariant rather than an after-the-fact check. + */ +export type Described = { + pattern: Pattern; + schema: JsonSchema; +}; + +/** + * Like {@link Described}, but the schema may be absent — used for a method's + * return position, where a `void` return has a pattern ({@link M.undefined}) but + * no JSON Schema counterpart (JSON Schema cannot express `void`/`undefined`). + */ +export type DescribedReturn = { + pattern: Pattern; + schema: JsonSchema | undefined; +}; + +/** + * A named positional method parameter: a {@link Described} value plus the name + * under which it is hung in the method's {@link MethodSchema.args} and, after + * discovery, the key by which a caller supplies it. + */ +export type NamedArg = { + name: string; + described: Described; + optional: boolean; +}; + +/** + * A described method: the {@link MethodGuard} that enforces its call shape and + * the {@link MethodSchema} that describes it. Both are projected from the same + * authored leaves, so they cannot drift. + */ +export type DescribedMethod = { + guard: MethodGuard; + schema: MethodSchema; +}; + +/** + * A described interface: the {@link InterfaceGuard} that the exo membrane + * enforces, and the per-method {@link MethodSchema} map to pass as the + * `__getDescription__` payload. Splat both into `makeDiscoverableExo`. + */ +export type DescribedInterface = { + interfaceGuard: InterfaceGuard; + schemas: Record; +}; + +const withDescription = ( + schema: JsonSchema, + description: string | undefined, +): JsonSchema => + description === undefined ? schema : { ...schema, description }; + +/** + * A string leaf: matches a string; describes `{ type: 'string' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described string. + */ +const string = (description?: string): Described => + harden({ + pattern: M.string(), + schema: withDescription({ type: 'string' }, description), + }); + +/** + * A number leaf: matches a number; describes `{ type: 'number' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described number. + */ +const number = (description?: string): Described => + harden({ + pattern: M.number(), + schema: withDescription({ type: 'number' }, description), + }); + +/** + * A boolean leaf: matches a boolean; describes `{ type: 'boolean' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described boolean. + */ +const boolean = (description?: string): Described => + harden({ + pattern: M.boolean(), + schema: withDescription({ type: 'boolean' }, description), + }); + +/** + * An array leaf: matches an array whose elements match `items`; describes + * `{ type: 'array', items }`. + * + * @param items - The described element type. + * @param description - Optional human/LLM-facing description. + * @returns The described array. + */ +const arrayOf = (items: Described, description?: string): Described => + harden({ + pattern: M.arrayOf(items.pattern), + schema: withDescription( + { type: 'array', items: items.schema }, + description, + ), + }); + +/** + * An open object leaf: matches any record (extra keys allowed); describes + * `{ type: 'object', properties: {}, additionalProperties: true }`. + * + * Use when the shape is genuinely open (e.g. free-form attachments). + * + * @param description - Optional human/LLM-facing description. + * @returns The described open object. + */ +const record = (description?: string): Described => + harden({ + pattern: M.record(), + schema: withDescription( + { type: 'object', properties: {}, additionalProperties: true }, + description, + ), + }); + +/** + * A closed/shaped object leaf: matches a record with the given properties, + * where keys not listed in `optional` are required. Describes + * `{ type: 'object', properties, required }`. + * + * @param properties - The described properties, keyed by name. + * @param options - Options bag. + * @param options.optional - Property names that may be omitted. + * @param options.description - Optional human/LLM-facing description. + * @returns The described object. + */ +const object = ( + properties: Record, + options: { optional?: string[]; description?: string } = {}, +): Described => { + const { optional = [], description } = options; + const optionalSet = new Set(optional); + const requiredPatterns: Record = {}; + const optionalPatterns: Record = {}; + const schemaProperties: Record = {}; + const required: string[] = []; + for (const [key, described] of Object.entries(properties)) { + schemaProperties[key] = described.schema; + if (optionalSet.has(key)) { + optionalPatterns[key] = described.pattern; + } else { + requiredPatterns[key] = described.pattern; + required.push(key); + } + } + return harden({ + pattern: M.splitRecord(requiredPatterns, optionalPatterns), + schema: withDescription( + { type: 'object', properties: schemaProperties, required }, + description, + ), + }); +}; + +/** + * The void return leaf: matches `undefined` (an async method that resolves to + * nothing); has no JSON Schema counterpart. + * + * @returns The described void return. + */ +const nothing = (): DescribedReturn => + harden({ pattern: M.undefined(), schema: undefined }); + +/** + * Name a positional method parameter. + * + * @param name - The argument name (its key in {@link MethodSchema.args}). + * @param described - The described value at this position. + * @param options - Options bag. + * @param options.optional - Whether the argument may be omitted. Optional + * arguments must be trailing (enforced by {@link describedMethod}). + * @returns The named argument. + */ +const arg = ( + name: string, + described: Described, + options: { optional?: boolean } = {}, +): NamedArg => harden({ name, described, optional: options.optional ?? false }); + +/** + * Describe a method: build the {@link MethodGuard} (async, via `M.callWhen`, + * since discoverable-exo methods are invoked across an eventual-send boundary) + * and the matching {@link MethodSchema} from the same arguments. + * + * @param description - The method's description. + * @param args - The positional, named arguments. Optional arguments must all be + * trailing — `M.call(...).optional(...)` is positional, so an optional argument + * before a required one cannot be expressed. + * @param returns - The described return value (use {@link nothing} for `void`). + * @returns The described method. + */ +const describedMethod = ( + description: string, + args: NamedArg[], + returns: DescribedReturn, +): DescribedMethod => { + const firstOptional = args.findIndex((each) => each.optional); + if ( + firstOptional !== -1 && + args.slice(firstOptional).some((each) => !each.optional) + ) { + throw new Error( + 'describedMethod: optional arguments must be trailing (a required argument cannot follow an optional one).', + ); + } + + const required = args.filter((each) => !each.optional); + const optional = args.filter((each) => each.optional); + const base = M.callWhen(...required.map((each) => each.described.pattern)); + const guard = + optional.length > 0 + ? base + .optional(...optional.map((each) => each.described.pattern)) + .returns(returns.pattern) + : base.returns(returns.pattern); + + const schemaArgs: Record = {}; + for (const each of args) { + schemaArgs[each.name] = each.described.schema; + } + const schema: MethodSchema = { + description, + args: schemaArgs, + ...(returns.schema === undefined ? {} : { returns: returns.schema }), + }; + + return harden({ guard, schema }); +}; + +/** + * Describe an interface: collect method guards into an {@link InterfaceGuard} + * and method schemas into the `__getDescription__` payload. + * + * The guard uses `defaultGuards: 'passable'` so the `__getDescription__` method + * that `makeDiscoverableExo` injects (and which is not listed here) is allowed. + * + * @param name - The interface name. + * @param methods - The described methods, keyed by method name. + * @returns The interface guard and the per-method schema map. + */ +const describedInterface = ( + name: string, + methods: Record, +): DescribedInterface => { + const methodGuards: Record = {}; + const schemas: Record = {}; + for (const [methodName, method] of Object.entries(methods)) { + methodGuards[methodName] = method.guard; + schemas[methodName] = method.schema; + } + const interfaceGuard = M.interface(name, methodGuards, { + defaultGuards: 'passable', + }); + return harden({ interfaceGuard, schemas }); +}; + +/** + * Combinators for authoring an `@endo/patterns` guard and a {@link MethodSchema} + * description from a single source, so the two cannot drift. + * + * Leaves (`string`, `number`, `boolean`, `arrayOf`, `record`, `object`, + * `nothing`) each yield a `{ pattern, schema }` pair; `arg` names a positional + * parameter; `method` and `interface` assemble them. + */ +// eslint-disable-next-line id-length -- `S` is the intended terse public namespace, mirroring `@endo/patterns`'s `M`. +export const S = harden({ + string, + number, + boolean, + arrayOf, + record, + object, + nothing, + arg, + method: describedMethod, + interface: describedInterface, +}); diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index cc1985bc46..d3b32f36cc 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -11,6 +11,7 @@ describe('index', () => { 'DEFAULT_MAX_RETRY_ATTEMPTS', 'EmptyJsonArray', 'GET_DESCRIPTION', + 'S', 'abortableDelay', 'calculateReconnectionBackoff', 'delay', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index bc895d4a1b..e7c57b202b 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -2,6 +2,14 @@ export { prettifySmallcaps } from './prettify-smallcaps.ts'; export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; +export { S } from './described.ts'; +export type { + Described, + DescribedReturn, + NamedArg, + DescribedMethod, + DescribedInterface, +} from './described.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; export { jsonSchemaToStruct, From 1812ca4afd3ccac42fe577607fc18583958c81f0 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:01:12 -0400 Subject: [PATCH 2/3] feat(kernel-agents): author built-in capabilities as pattern-guarded exos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the `math`, `end`, and `examples` capabilities as discoverable exos built with the `described*()` combinators, so each capability's argument shape is enforced by the exo's interface guard at invocation rather than only advertised in the prompt. A mistyped argument now fails with a guard rejection at the membrane instead of surfacing deep inside the capability. Each module derives its `{ func, schema }` capability specs via a new synchronous `makeInternalCapabilities` constructor, which builds the pattern-guarded exo and projects a capability record from the just-authored schemas — without round-tripping through `GET_DESCRIPTION`. The exo is kept private as the in-realm enforcement membrane; internal capabilities are guarded closures, not passable exos (to cross a boundary, publish an exo and `discover` it). All existing consumers (example transcripts, e2e tests, the REPL evaluator, prepare-attempt) keep the same spec shape and `makeEnd` stays synchronous. `end`'s closed-over result object is intentionally left un-hardened so the exo method can mutate it. `getMoonPhase` loses its (already unsupported, `@ts-expect-error`'d) `enum` return hint; `end`'s off-spec per-argument `required` flags are gone, with `final` required and `attachments` optional expressed by the guard. Install the endoify mock as a package-wide vitest setup so capability modules, which now build exos at import, have a `harden` global before they load. Co-Authored-By: Claude Opus 4.8 --- packages/kernel-agents/CHANGELOG.md | 4 + .../src/capabilities/discover.ts | 126 +++++++++++++----- .../kernel-agents/src/capabilities/end.ts | 59 ++++---- .../src/capabilities/examples.ts | 86 ++++++------ .../kernel-agents/src/capabilities/math.ts | 69 +++++----- packages/kernel-agents/vitest.config.ts | 9 ++ 6 files changed, 213 insertions(+), 140 deletions(-) diff --git a/packages/kernel-agents/CHANGELOG.md b/packages/kernel-agents/CHANGELOG.md index 0c82cb1ed6..b91659d842 100644 --- a/packages/kernel-agents/CHANGELOG.md +++ b/packages/kernel-agents/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The built-in capabilities (`math`, `end`, `examples`) are now pattern-guarded discoverable exos authored with the `described*()` combinators, so their argument shapes are enforced by the exo's interface guard at invocation rather than only described in the prompt ([#959](https://github.com/MetaMask/ocap-kernel/pull/959)) + [Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel-agents/src/capabilities/discover.ts b/packages/kernel-agents/src/capabilities/discover.ts index 7d4f060238..2eba6bf067 100644 --- a/packages/kernel-agents/src/capabilities/discover.ts +++ b/packages/kernel-agents/src/capabilities/discover.ts @@ -1,12 +1,58 @@ import { E } from '@endo/eventual-send'; -import { GET_DESCRIPTION } from '@metamask/kernel-utils'; -import type { DiscoverableExo, MethodSchema } from '@metamask/kernel-utils'; +import { GET_DESCRIPTION, makeDiscoverableExo } from '@metamask/kernel-utils'; +import type { + DescribedInterface, + DiscoverableExo, + MethodSchema, +} from '@metamask/kernel-utils'; import type { CapabilityRecord, CapabilitySpec } from '../types.ts'; /** - * Discover the capabilities of a discoverable exo. Intended for use from inside a vat. - * This function fetches the schema from the discoverable exo and creates capabilities that can be used by kernel agents. + * Invoke a discoverable exo's method with positional arguments. The async + * variant ({@link discover}) sends over an eventual-send boundary; the local + * variant ({@link makeInternalCapabilities}) calls the in-realm exo directly. + * Either way the exo's interface guard enforces the argument shape. + */ +type Invoke = (method: string, positionalArgs: unknown[]) => unknown; + +/** + * Build a {@link CapabilityRecord} from a method-schema description, mapping each + * capability's object arguments to positional arguments for the exo method. + * + * IMPORTANT: this relies on each `schema.args` having keys in the same order as + * the method's parameters. Schemas authored with the `described*()` combinators + * (`@metamask/kernel-utils`) satisfy this by construction, since their `args` + * record is built in declared positional order. + * + * @param description - The exo's method schemas, keyed by method name. + * @param invoke - How to invoke a method with positional arguments. + * @returns The capability record. + */ +const capabilitiesFrom = ( + description: Record, + invoke: Invoke, +): CapabilityRecord => + Object.fromEntries( + Object.entries(description).map(([name, schema]) => { + const argNames = Object.keys(schema.args); + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const func = async (args: Record) => + invoke( + name, + argNames.map((argName) => args[argName]), + ); + return [name, { func, schema }] as [ + string, + CapabilitySpec, + ]; + }), + ); + +/** + * Discover the capabilities of a (possibly remote) discoverable exo. Fetches the + * schema over an eventual-send boundary and creates capabilities that invoke the + * exo's methods the same way. * * @param exo - The discoverable exo to convert to a capability record. * @returns A promise for a capability record. @@ -19,35 +65,49 @@ export const discover = async ( string, MethodSchema >; - - const capabilities: CapabilityRecord = Object.fromEntries( - Object.entries(description).map(([name, schema]) => { - // Get argument names in order from the schema. - // IMPORTANT: This relies on the schema's args object having keys in the same - // order as the method's parameters. The schema must be defined with argument - // names matching the method parameter order (e.g., for method `add(a, b)`, - // the schema must have `args: { a: ..., b: ... }` in that order). - // JavaScript objects preserve insertion order for string keys, so Object.keys() - // will return keys in the order they were defined in the schema. - const argNames = Object.keys(schema.args); - - // Create a capability function that accepts an args object - // and maps it to positional arguments for the exo method - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const func = async (args: Record) => { - // Map object arguments to positional arguments in schema order. - // The order of argNames matches the method parameter order by convention. - const positionalArgs = argNames.map((argName) => args[argName]); - // @ts-expect-error - E type doesn't remember method names - return E(exo)[name](...positionalArgs); - }; - - return [name, { func, schema }] as [ - string, - CapabilitySpec, - ]; - }), + return capabilitiesFrom(description, async (method, positionalArgs) => + // @ts-expect-error - E type doesn't remember method names + E(exo)[method](...positionalArgs), ); +}; - return capabilities; +/** + * Construct an in-realm capability record from a guard+schema description and + * the method implementations, building (and then keeping private) the + * pattern-guarded exo that enforces the argument shape on every call. + * + * Unlike {@link discover}, this never crosses an eventual-send boundary and + * never reads `GET_DESCRIPTION`: the schemas are the ones just authored with the + * `described*()` combinators (`@metamask/kernel-utils`), so there is no + * round-trip through the exo to recover what the caller already holds. The exo + * is used purely as the in-realm enforcement membrane and is not surfaced — + * internal capabilities are guarded closures, not passable exos. To expose a + * capability across a boundary, publish a {@link DiscoverableExo} and + * {@link discover} it instead. + * + * @param name - The exo/interface name. + * @param methods - The method implementations, keyed by method name. + * @param described - The interface guard and per-method schemas, e.g. from + * `S.interface(...)`. + * @returns A capability record keyed by the method names. + */ +export const makeInternalCapabilities = ( + name: string, + methods: Record Promise>, + described: DescribedInterface, +): CapabilityRecord => { + const { interfaceGuard, schemas } = described; + const exo = makeDiscoverableExo( + name, + methods as Record unknown>, + schemas, + interfaceGuard, + ); + const dispatch = exo as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + return capabilitiesFrom(schemas, (method, positionalArgs) => + dispatch[method]?.(...positionalArgs), + ) as CapabilityRecord; }; diff --git a/packages/kernel-agents/src/capabilities/end.ts b/packages/kernel-agents/src/capabilities/end.ts index 9dda4ab279..82288b30e0 100644 --- a/packages/kernel-agents/src/capabilities/end.ts +++ b/packages/kernel-agents/src/capabilities/end.ts @@ -1,5 +1,7 @@ +import { S } from '@metamask/kernel-utils'; + +import { makeInternalCapabilities } from './discover.ts'; import { ifDefined } from '../utils.ts'; -import { capability } from './capability.ts'; /** * A factory function to make a task's `end` capability, which stores the first @@ -10,36 +12,41 @@ import { capability } from './capability.ts'; */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const makeEnd = () => { + // Captured, mutable state for the first final result. Intentionally NOT + // hardened: the exo method below closes over and mutates it. const result: { final?: Result; attachments?: Record } = {}; - const end = capability( - async ({ - final, - attachments, - }: { - final: Result; - attachments?: Record; - }): Promise => { - if (!Object.hasOwn(result, 'final')) { - Object.assign(result, { final, ...ifDefined({ attachments }) }); - } - }, + + const { end } = makeInternalCapabilities( + 'End', { - description: 'Return a final response to the user.', - args: { - final: { - required: true, - type: 'string', - description: - 'A concise final response that restates the requested information.', - }, - attachments: { - required: false, - type: 'object', - description: 'Attachments to the final response.', - }, + async end( + final: Result, + attachments?: Record, + ): Promise { + if (!Object.hasOwn(result, 'final')) { + Object.assign(result, { final, ...ifDefined({ attachments }) }); + } }, }, + S.interface('End', { + end: S.method( + 'Return a final response to the user.', + [ + S.arg( + 'final', + S.string( + 'A concise final response that restates the requested information.', + ), + ), + S.arg('attachments', S.record('Attachments to the final response.'), { + optional: true, + }), + ], + S.nothing(), + ), + }), ); + return [end, () => 'final' in result, () => result.final as Result] as const; }; diff --git a/packages/kernel-agents/src/capabilities/examples.ts b/packages/kernel-agents/src/capabilities/examples.ts index 6e1a9c9383..8b6cb1bd03 100644 --- a/packages/kernel-agents/src/capabilities/examples.ts +++ b/packages/kernel-agents/src/capabilities/examples.ts @@ -1,43 +1,12 @@ -import { capability } from './capability.ts'; +import { S } from '@metamask/kernel-utils'; + +import { makeInternalCapabilities } from './discover.ts'; type SearchResult = { source: string; published: string; snippet: string; }; -export const search = capability( - async ({ query }: { query: string }): Promise => [ - { - source: 'https://www.google.com', - published: '2025-01-01', - snippet: `No information found for ${query}`, - }, - ], - { - description: 'Search the web for information.', - args: { query: { type: 'string', description: 'The query to search for' } }, - returns: { - type: 'array', - items: { - type: 'object', - properties: { - source: { - type: 'string', - description: 'The source of the information.', - }, - published: { - type: 'string', - description: 'The date the information was published.', - }, - snippet: { - type: 'string', - description: 'The snippet of information.', - }, - }, - }, - }, - }, -); const moonPhases = [ 'new moon', @@ -51,20 +20,45 @@ const moonPhases = [ ] as const; type MoonPhase = (typeof moonPhases)[number]; -export const getMoonPhase = capability( - async (): Promise => - moonPhases[Math.floor(Math.random() * moonPhases.length)] as MoonPhase, +const capabilities = makeInternalCapabilities( + 'Examples', { - description: 'Get the current phase of the moon.', - args: {}, - returns: { - type: 'string', - // TODO: Add enum support to the capability schema - // @ts-expect-error - enum is not supported by the capability schema - enum: moonPhases, - description: 'The current phase of the moon.', + async search(query: string): Promise { + return [ + { + source: 'https://www.google.com', + published: '2025-01-01', + snippet: `No information found for ${query}`, + }, + ]; + }, + async getMoonPhase(): Promise { + return moonPhases[ + Math.floor(Math.random() * moonPhases.length) + ] as MoonPhase; }, }, + S.interface('Examples', { + search: S.method( + 'Search the web for information.', + [S.arg('query', S.string('The query to search for'))], + S.arrayOf( + S.object({ + source: S.string('The source of the information.'), + published: S.string('The date the information was published.'), + snippet: S.string('The snippet of information.'), + }), + ), + ), + // TODO: Add enum support to the capability schema so the moon phases can be + // advertised as the allowed return values. + getMoonPhase: S.method( + 'Get the current phase of the moon.', + [], + S.string('The current phase of the moon.'), + ), + }), ); -export const exampleCapabilities = { search, getMoonPhase }; +export const { search, getMoonPhase } = capabilities; +export const exampleCapabilities = capabilities; diff --git a/packages/kernel-agents/src/capabilities/math.ts b/packages/kernel-agents/src/capabilities/math.ts index b328716276..97d30ee620 100644 --- a/packages/kernel-agents/src/capabilities/math.ts +++ b/packages/kernel-agents/src/capabilities/math.ts @@ -1,44 +1,43 @@ -import { capability } from './capability.ts'; +import { S } from '@metamask/kernel-utils'; -export const count = capability( - async ({ word }: { word: string }) => word.length, +import { makeInternalCapabilities } from './discover.ts'; + +const capabilities = makeInternalCapabilities( + 'Math', { - description: 'Count the number of characters in an arbitrary string', - args: { - word: { type: 'string', description: 'The string to get the length of.' }, + async count(word: string) { + return word.length; }, - returns: { - type: 'number', - description: 'The number of characters in the string.', + async add(summands: number[]) { + return summands.reduce((acc, summand) => acc + summand, 0); }, - }, -); - -export const add = capability( - async ({ summands }: { summands: number[] }) => - summands.reduce((acc, summand) => acc + summand, 0), - { - description: 'Add a list of numbers.', - args: { summands: { type: 'array', items: { type: 'number' } } }, - returns: { type: 'number', description: 'The sum of the numbers.' }, - }, -); - -export const multiply = capability( - async ({ factors }: { factors: number[] }) => - factors.reduce((acc, factor) => acc * factor, 1), - { - description: 'Multiply a list of numbers.', - args: { - factors: { - type: 'array', - description: 'The list of numbers to multiply.', - items: { type: 'number' }, - }, + async multiply(factors: number[]) { + return factors.reduce((acc, factor) => acc * factor, 1); }, - returns: { type: 'number', description: 'The product of the factors.' }, }, + S.interface('Math', { + count: S.method( + 'Count the number of characters in an arbitrary string', + [S.arg('word', S.string('The string to get the length of.'))], + S.number('The number of characters in the string.'), + ), + add: S.method( + 'Add a list of numbers.', + [S.arg('summands', S.arrayOf(S.number()))], + S.number('The sum of the numbers.'), + ), + multiply: S.method( + 'Multiply a list of numbers.', + [ + S.arg( + 'factors', + S.arrayOf(S.number(), 'The list of numbers to multiply.'), + ), + ], + S.number('The product of the factors.'), + ), + }), ); -const capabilities = { count, add, multiply }; +export const { count, add, multiply } = capabilities; export default capabilities; diff --git a/packages/kernel-agents/vitest.config.ts b/packages/kernel-agents/vitest.config.ts index a04eee63fa..cf701cf144 100644 --- a/packages/kernel-agents/vitest.config.ts +++ b/packages/kernel-agents/vitest.config.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -13,6 +14,14 @@ export default defineConfig((args) => { include: ['src/**/*.test.ts'], // Exclude E2E setup test from regular test runs exclude: ['test/e2e'], + // Capability modules build discoverable exos at import, which needs a + // `harden` global; install the endoify mock for every test in the + // package so it is present before any capability module loads. + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], }, }), ); From cf4502f162d2cca7f88374776169aa89117d05fb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:09:53 -0400 Subject: [PATCH 3/3] refactor(kernel-agents): make the exo membrane the sole capability arg enforcer Now that every capability is a pattern-guarded discoverable exo, retire the membraneless authoring and validation paths: - Remove the `capability()` authoring helper and the `validateCapabilityArgs` validator (and its now-dead module). A capability's arguments are enforced only by its exo's interface guard; the chat strategy no longer re-validates before invoking, relying on the guard rejection it already catches. - Add a `test/make-capability.ts` helper that builds a guarded capability from the `described*()` combinators via `makeInternalCapabilities`, and migrate the chat and JSON evaluator tests (and the capability test, repurposed to cover the surviving `extract*` helpers) onto it. - Collapse the redundant `CapabilitySchema` type into kernel-utils' `MethodSchema`: a capability's `schema` is exactly the `MethodSchema` its exo describes, so the parallel type (and its `ExtractRecordKeys` helper) is gone. - Drop the unused `@metamask/superstruct` dependency. BREAKING: `capability` and `validateCapabilityArgs` are no longer exported. Co-Authored-By: Claude Opus 4.8 --- packages/kernel-agents/CHANGELOG.md | 5 ++ packages/kernel-agents/package.json | 1 - .../src/capabilities/capability.test.ts | 34 +++++++---- .../src/capabilities/capability.ts | 22 +------ .../validate-capability-args.test.ts | 59 ------------------- .../capabilities/validate-capability-args.ts | 17 ------ .../src/strategies/chat-agent.test.ts | 57 ++++++++++-------- .../src/strategies/chat-agent.ts | 5 +- .../src/strategies/json/evaluator.test.ts | 13 ++-- packages/kernel-agents/src/types.ts | 1 - .../kernel-agents/src/types/capability.ts | 13 +--- .../kernel-agents/test/make-capability.ts | 35 +++++++++++ yarn.lock | 1 - 13 files changed, 112 insertions(+), 151 deletions(-) delete mode 100644 packages/kernel-agents/src/capabilities/validate-capability-args.test.ts delete mode 100644 packages/kernel-agents/src/capabilities/validate-capability-args.ts create mode 100644 packages/kernel-agents/test/make-capability.ts diff --git a/packages/kernel-agents/CHANGELOG.md b/packages/kernel-agents/CHANGELOG.md index b91659d842..109868d48c 100644 --- a/packages/kernel-agents/CHANGELOG.md +++ b/packages/kernel-agents/CHANGELOG.md @@ -10,5 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The built-in capabilities (`math`, `end`, `examples`) are now pattern-guarded discoverable exos authored with the `described*()` combinators, so their argument shapes are enforced by the exo's interface guard at invocation rather than only described in the prompt ([#959](https://github.com/MetaMask/ocap-kernel/pull/959)) +- A capability's arguments are now validated solely by its exo's interface guard (the membrane); the chat strategy no longer re-validates arguments before invoking a capability ([#960](https://github.com/MetaMask/ocap-kernel/pull/960)) + +### Removed + +- **BREAKING:** Remove the `capability()` authoring helper and the `validateCapabilityArgs` validator. Capabilities are authored as pattern-guarded discoverable exos (via the `described*()` combinators in `@metamask/kernel-utils`) and discovered into capability records, so there is no membraneless authoring path and the membrane is the sole argument enforcer ([#960](https://github.com/MetaMask/ocap-kernel/pull/960)) [Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index bc1358fae4..a6b65725b3 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -196,7 +196,6 @@ "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", - "@metamask/superstruct": "^3.2.1", "@ocap/kernel-language-model-service": "workspace:^", "partial-json": "^0.1.7", "ses": "^1.14.0" diff --git a/packages/kernel-agents/src/capabilities/capability.test.ts b/packages/kernel-agents/src/capabilities/capability.test.ts index d364c48b9e..b494bb3df4 100644 --- a/packages/kernel-agents/src/capabilities/capability.test.ts +++ b/packages/kernel-agents/src/capabilities/capability.test.ts @@ -1,17 +1,31 @@ +import { S } from '@metamask/kernel-utils'; import { describe, it, expect } from 'vitest'; -import { capability } from './capability.ts'; +import { extractCapabilities, extractCapabilitySchemas } from './capability.ts'; +import { makeCapability } from '../../test/make-capability.ts'; -describe('capability', () => { - it('creates a capability with func and schema', () => { - const testCapability = capability(async () => Promise.resolve('test'), { - description: 'a test capability', - args: {}, - }); - expect(testCapability.func).toBeInstanceOf(Function); - expect(testCapability.schema).toStrictEqual({ - description: 'a test capability', +describe('capability extraction', () => { + const makeRecord = () => ({ + ping: makeCapability( + 'Server', + 'ping', + async () => 'pong', + S.method('Ping', [], S.string()), + ), + }); + + it('extractCapabilities returns the functions keyed by name', async () => { + const funcs = extractCapabilities(makeRecord()); + expect(Object.keys(funcs)).toStrictEqual(['ping']); + expect(await funcs.ping(undefined as never)).toBe('pong'); + }); + + it('extractCapabilitySchemas returns the schemas keyed by name', () => { + const schemas = extractCapabilitySchemas(makeRecord()); + expect(schemas.ping).toStrictEqual({ + description: 'Ping', args: {}, + returns: { type: 'string' }, }); }); }); diff --git a/packages/kernel-agents/src/capabilities/capability.ts b/packages/kernel-agents/src/capabilities/capability.ts index 6bce8cb45f..ca74cd4cfb 100644 --- a/packages/kernel-agents/src/capabilities/capability.ts +++ b/packages/kernel-agents/src/capabilities/capability.ts @@ -1,24 +1,8 @@ -import type { ExtractRecordKeys } from '../types/capability.ts'; -import type { - CapabilityRecord, - CapabilitySpec, - CapabilitySchema, - Capability, -} from '../types.ts'; +import type { MethodSchema } from '@metamask/kernel-utils'; -/** - * Create a capability specification. - * - * @param func - The function to create a capability specification for - * @param schema - The schema for the capability - * @returns A capability specification - */ -export const capability = , Return = null>( - func: Capability, - schema: CapabilitySchema>, -): CapabilitySpec => ({ func, schema }); +import type { CapabilityRecord, CapabilitySpec } from '../types.ts'; -type SchemaEntry = [string, { schema: CapabilitySchema }]; +type SchemaEntry = [string, { schema: MethodSchema }]; /** * Extract only the serializable schemas from the capabilities * diff --git a/packages/kernel-agents/src/capabilities/validate-capability-args.test.ts b/packages/kernel-agents/src/capabilities/validate-capability-args.test.ts deleted file mode 100644 index 21bdd4b0ca..0000000000 --- a/packages/kernel-agents/src/capabilities/validate-capability-args.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { validateCapabilityArgs } from './validate-capability-args.ts'; - -describe('validateCapabilityArgs', () => { - it('accepts values matching primitive arg schemas', () => { - expect(() => - validateCapabilityArgs( - { a: 1, b: 2 }, - { - description: 'add', - args: { - a: { type: 'number' }, - b: { type: 'number' }, - }, - }, - ), - ).not.toThrow(); - }); - - it('throws when a required argument is missing', () => { - expect(() => - validateCapabilityArgs( - { a: 1 }, - { - description: 'add', - args: { - a: { type: 'number' }, - b: { type: 'number' }, - }, - }, - ), - ).toThrow(/At path: b -- Expected a number/u); - }); - - it('throws when a value does not match the schema', () => { - expect(() => - validateCapabilityArgs( - { a: 'not-a-number' }, - { - description: 'x', - args: { a: { type: 'number' } }, - }, - ), - ).toThrow(/Expected a number/u); - }); - - it('does nothing when there are no declared arguments', () => { - expect(() => - validateCapabilityArgs( - { extra: 1 }, - { - description: 'ping', - args: {}, - }, - ), - ).not.toThrow(); - }); -}); diff --git a/packages/kernel-agents/src/capabilities/validate-capability-args.ts b/packages/kernel-agents/src/capabilities/validate-capability-args.ts deleted file mode 100644 index 7b4668f26e..0000000000 --- a/packages/kernel-agents/src/capabilities/validate-capability-args.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { methodArgsToStruct } from '@metamask/kernel-utils/json-schema-to-struct'; -import { assert } from '@metamask/superstruct'; - -import type { CapabilitySchema } from '../types.ts'; - -/** - * Assert `values` match the capability's declared argument schemas using Superstruct. - * - * @param values - Parsed tool arguments (a plain object). - * @param capabilitySchema - {@link CapabilitySchema} for this capability. - */ -export function validateCapabilityArgs( - values: Record, - capabilitySchema: CapabilitySchema, -): void { - assert(values, methodArgsToStruct(capabilitySchema.args)); -} diff --git a/packages/kernel-agents/src/strategies/chat-agent.test.ts b/packages/kernel-agents/src/strategies/chat-agent.test.ts index f9f068efdc..de909e26f5 100644 --- a/packages/kernel-agents/src/strategies/chat-agent.test.ts +++ b/packages/kernel-agents/src/strategies/chat-agent.test.ts @@ -1,5 +1,6 @@ import '@ocap/repo-tools/test-utils/mock-endoify'; +import { S } from '@metamask/kernel-utils'; import type { ChatMessage, ChatResult, @@ -9,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'; import { makeChatAgent } from './chat-agent.ts'; import type { BoundChat } from './chat-agent.ts'; -import { capability } from '../capabilities/capability.ts'; +import { makeCapability } from '../../test/make-capability.ts'; const makeToolCall = ( id: string, @@ -62,15 +63,17 @@ describe('makeChatAgent', () => { }); it('dispatches a tool call and returns final text answer', async () => { - const add = vi.fn(async ({ a, b }: { a: number; b: number }) => a + b); - const addCap = capability(add, { - description: 'Add two numbers', - args: { - a: { type: 'number' }, - b: { type: 'number' }, - }, - returns: { type: 'number' }, - }); + const add = vi.fn(async (a: number, b: number) => a + b); + const addCap = makeCapability( + 'Math', + 'add', + add, + S.method( + 'Add two numbers', + [S.arg('a', S.number()), S.arg('b', S.number())], + S.number(), + ), + ); let call = 0; const chat: BoundChat = async () => { @@ -86,17 +89,18 @@ describe('makeChatAgent', () => { const agent = makeChatAgent({ chat, capabilities: { add: addCap } }); const result = await agent.task('add 3 and 4'); - expect(add).toHaveBeenCalledWith({ a: 3, b: 4 }); + expect(add).toHaveBeenCalledWith(3, 4); expect(result).toBe('7'); }); it('injects tool result message before next turn', async () => { const recorded: ChatMessage[][] = []; - const ping = capability(async () => 'pong', { - description: 'Ping', - args: {}, - returns: { type: 'string' }, - }); + const ping = makeCapability( + 'Server', + 'ping', + async () => 'pong', + S.method('Ping', [], S.string()), + ); let call = 0; const chat: BoundChat = async ({ messages }) => { @@ -152,10 +156,12 @@ describe('makeChatAgent', () => { }); it('throws when invocation budget is exceeded', async () => { - const ping = capability(async () => 'pong', { - description: 'Ping', - args: {}, - }); + const ping = makeCapability( + 'Server', + 'ping', + async () => 'pong', + S.method('Ping', [], S.string()), + ); const chat: BoundChat = async () => makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]); @@ -177,11 +183,12 @@ describe('makeChatAgent', () => { it('passes tools to the chat function', async () => { const recordedTools: unknown[] = []; - const ping = capability(async () => 'pong', { - description: 'Ping the server', - args: {}, - returns: { type: 'string' }, - }); + const ping = makeCapability( + 'Server', + 'ping', + async () => 'pong', + S.method('Ping the server', [], S.string()), + ); const chat: BoundChat = async ({ tools }) => { recordedTools.push(tools); diff --git a/packages/kernel-agents/src/strategies/chat-agent.ts b/packages/kernel-agents/src/strategies/chat-agent.ts index 99c6a187c7..66c21eaebc 100644 --- a/packages/kernel-agents/src/strategies/chat-agent.ts +++ b/packages/kernel-agents/src/strategies/chat-agent.ts @@ -7,7 +7,6 @@ import type { import { parseToolArguments } from '@ocap/kernel-language-model-service/utils/parse-tool-arguments'; import { extractCapabilitySchemas } from '../capabilities/capability.ts'; -import { validateCapabilityArgs } from '../capabilities/validate-capability-args.ts'; import type { Agent } from '../types/agent.ts'; import { Message } from '../types/messages.ts'; import type { CapabilityRecord, Experience } from '../types.ts'; @@ -178,7 +177,9 @@ export const makeChatAgent = ({ let toolResult: unknown; try { const args = parseToolArguments(argsJson); - validateCapabilityArgs(args, spec.schema); + // The capability is backed by a pattern-guarded exo, so its + // interface guard enforces the argument shape; a mismatch rejects + // here and is reported as the tool error below. toolResult = await spec.func(args as never); } catch (error) { const errorContent = `Error calling ${name}: ${(error as Error).message}`; diff --git a/packages/kernel-agents/src/strategies/json/evaluator.test.ts b/packages/kernel-agents/src/strategies/json/evaluator.test.ts index fa1cf1290d..a6883d1d47 100644 --- a/packages/kernel-agents/src/strategies/json/evaluator.test.ts +++ b/packages/kernel-agents/src/strategies/json/evaluator.test.ts @@ -1,15 +1,18 @@ +import { S } from '@metamask/kernel-utils'; import { describe, it, expect } from 'vitest'; import { makeEvaluator } from './evaluator.ts'; import { AssistantMessage, CapabilityResultMessage } from './messages.ts'; -import { capability } from '../../capabilities/capability.ts'; +import { makeCapability } from '../../../test/make-capability.ts'; describe('invokeCapabilities', () => { it("invokes the assistant's chosen capability", async () => { - const testCapability = capability(async () => Promise.resolve('test'), { - description: 'a test capability', - args: {}, - }); + const testCapability = makeCapability( + 'Test', + 'testCapability', + async () => Promise.resolve('test'), + S.method('a test capability', [], S.string()), + ); const evaluator = makeEvaluator({ capabilities: { testCapability } }); const result = await evaluator( [], diff --git a/packages/kernel-agents/src/types.ts b/packages/kernel-agents/src/types.ts index 82d53a5d3e..f3d6c6c52f 100644 --- a/packages/kernel-agents/src/types.ts +++ b/packages/kernel-agents/src/types.ts @@ -1,7 +1,6 @@ export type { Capability, CapabilityRecord, - CapabilitySchema, CapabilitySpec, } from './types/capability.ts'; export type { TaskArgs } from './types/task.ts'; diff --git a/packages/kernel-agents/src/types/capability.ts b/packages/kernel-agents/src/types/capability.ts index 9d6dbc9280..acd29e2f3e 100644 --- a/packages/kernel-agents/src/types/capability.ts +++ b/packages/kernel-agents/src/types/capability.ts @@ -1,24 +1,15 @@ -import type { JsonSchema } from '@metamask/kernel-utils'; +import type { MethodSchema } from '@metamask/kernel-utils'; export type Capability, Return = null> = ( args: Args, ) => Promise; -export type CapabilitySchema = { - description: string; - args: Record; - returns?: JsonSchema; -}; - -export type ExtractRecordKeys = - Rec extends Record ? Key : never; - export type CapabilitySpec< Args extends Record = Record, Return = void, > = { func: Capability; - schema: CapabilitySchema>; + schema: MethodSchema; }; export type CapabilityRecord = Record< diff --git a/packages/kernel-agents/test/make-capability.ts b/packages/kernel-agents/test/make-capability.ts new file mode 100644 index 0000000000..6cb7c647ba --- /dev/null +++ b/packages/kernel-agents/test/make-capability.ts @@ -0,0 +1,35 @@ +import { S } from '@metamask/kernel-utils'; +import type { DescribedMethod } from '@metamask/kernel-utils'; + +import { makeInternalCapabilities } from '../src/capabilities/discover.ts'; +import type { CapabilitySpec } from '../src/types.ts'; + +/** + * Build a single capability backed by a pattern-guarded discoverable exo, for + * tests that need an ad-hoc capability. Mirrors how the built-in capabilities + * are authored, so the exo's interface guard enforces the method's argument + * shape — there is no membraneless authoring path. + * + * @param name - The exo/interface name. + * @param method - The method (and capability) name. + * @param impl - The method implementation (positional arguments). + * @param described - The method's guard and schema (use the `described*()` + * combinators from `@metamask/kernel-utils`). + * @returns The capability spec. + */ +export const makeCapability = ( + name: string, + method: string, + impl: (...args: never[]) => unknown, + described: DescribedMethod, +): CapabilitySpec => { + const capabilities = makeInternalCapabilities( + name, + { [method]: impl } as Record< + string, + (...args: never[]) => Promise + >, + S.interface(name, { [method]: described }), + ); + return capabilities[method] as CapabilitySpec; +}; diff --git a/yarn.lock b/yarn.lock index f5c8836960..cdce88f2c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3989,7 +3989,6 @@ __metadata: "@metamask/kernel-errors": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" - "@metamask/superstruct": "npm:^3.2.1" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3"