diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index a7d0b299e..ed642a7ea 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -102,6 +102,7 @@ "node": "^20.11 || >=22" }, "dependencies": { + "@endo/eventual-send": "^1.3.4", "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", diff --git a/packages/kernel-agents/src/capabilities/discover.ts b/packages/kernel-agents/src/capabilities/discover.ts new file mode 100644 index 000000000..a8e71e703 --- /dev/null +++ b/packages/kernel-agents/src/capabilities/discover.ts @@ -0,0 +1,49 @@ +import { E } from '@endo/eventual-send'; +import type { 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. + * + * @param exo - The discoverable exo to convert to a capability record. + * @returns A promise for a capability record. + */ +export const discover = async ( + exo: DiscoverableExo, +): Promise => { + // @ts-expect-error - E type doesn't remember method names + const description = (await E(exo).describe()) as Record; + + 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 capabilities; +}; diff --git a/packages/kernel-agents/src/capabilities/examples.ts b/packages/kernel-agents/src/capabilities/examples.ts index 02fe23d71..6e1a9c938 100644 --- a/packages/kernel-agents/src/capabilities/examples.ts +++ b/packages/kernel-agents/src/capabilities/examples.ts @@ -18,7 +18,7 @@ export const search = capability( args: { query: { type: 'string', description: 'The query to search for' } }, returns: { type: 'array', - item: { + items: { type: 'object', properties: { source: { diff --git a/packages/kernel-agents/src/capabilities/math.ts b/packages/kernel-agents/src/capabilities/math.ts index d521899d7..b32871627 100644 --- a/packages/kernel-agents/src/capabilities/math.ts +++ b/packages/kernel-agents/src/capabilities/math.ts @@ -19,7 +19,7 @@ export const add = capability( summands.reduce((acc, summand) => acc + summand, 0), { description: 'Add a list of numbers.', - args: { summands: { type: 'array', item: { type: 'number' } } }, + args: { summands: { type: 'array', items: { type: 'number' } } }, returns: { type: 'number', description: 'The sum of the numbers.' }, }, ); @@ -33,7 +33,7 @@ export const multiply = capability( factors: { type: 'array', description: 'The list of numbers to multiply.', - item: { type: 'number' }, + items: { type: 'number' }, }, }, returns: { type: 'number', description: 'The product of the factors.' }, diff --git a/packages/kernel-agents/src/index.test.ts b/packages/kernel-agents/src/index.test.ts index c78c640e4..f28c1ae67 100644 --- a/packages/kernel-agents/src/index.test.ts +++ b/packages/kernel-agents/src/index.test.ts @@ -6,7 +6,7 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual( - expect.arrayContaining([]), + expect.arrayContaining(['discover']), ); }); }); diff --git a/packages/kernel-agents/src/index.ts b/packages/kernel-agents/src/index.ts index d2493229a..5860af6e6 100644 --- a/packages/kernel-agents/src/index.ts +++ b/packages/kernel-agents/src/index.ts @@ -1 +1,2 @@ export type { CapabilityRecord } from './types.ts'; +export { discover } from './capabilities/discover.ts'; diff --git a/packages/kernel-agents/src/types/capability.ts b/packages/kernel-agents/src/types/capability.ts index c16c41d59..9d6dbc928 100644 --- a/packages/kernel-agents/src/types/capability.ts +++ b/packages/kernel-agents/src/types/capability.ts @@ -1,4 +1,4 @@ -import type { JsonSchema } from './json-schema.ts'; +import type { JsonSchema } from '@metamask/kernel-utils'; export type Capability, Return = null> = ( args: Args, diff --git a/packages/kernel-agents/src/types/json-schema.ts b/packages/kernel-agents/src/types/json-schema.ts deleted file mode 100644 index 5c3574cc3..000000000 --- a/packages/kernel-agents/src/types/json-schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type JsonSchema = - | PrimitiveJsonSchema - | ArrayJsonSchema - | ObjectJsonSchemaProperty; - -type PrimitiveJsonSchema = { - type: 'string' | 'number' | 'boolean'; - description?: string; -}; - -type ArrayJsonSchema = { - type: 'array'; - description?: string; - item: JsonSchema; -}; - -type ObjectJsonSchemaProperty = { - type: 'object'; - description?: string; - properties: { - [key: string]: JsonSchema; - }; - required?: string[]; - additionalProperties?: boolean; -}; diff --git a/packages/kernel-agents/tsconfig.json b/packages/kernel-agents/tsconfig.json index d36996f86..f0958ab59 100644 --- a/packages/kernel-agents/tsconfig.json +++ b/packages/kernel-agents/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../kernel-language-model-service" }, { "path": "../kernel-utils" }, { "path": "../logger" }, + { "path": "../ocap-kernel" }, { "path": "../repo-tools" } ], "include": [ diff --git a/packages/kernel-test/src/vats/discoverable-capability-vat.js b/packages/kernel-test/src/vats/discoverable-capability-vat.js new file mode 100644 index 000000000..ab62724c0 --- /dev/null +++ b/packages/kernel-test/src/vats/discoverable-capability-vat.js @@ -0,0 +1,79 @@ +import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for a vat that exports a discoverable exo capability. + * + * @param {*} _vatPowers - Special powers granted to this vat (not used here). + * @param {*} _parameters - Initialization parameters from the vat's config object. + * @param {*} _baggage - Root of vat's persistent state (not used here). + * @returns {*} The root object for the new vat. + */ +export function buildRootObject(_vatPowers, _parameters, _baggage) { + const calculator = makeDiscoverableExo( + 'Calculator', + { + add: (a, b) => a + b, + multiply: (a, b) => a * b, + greet: (name) => `Hello, ${name}!`, + }, + { + add: { + description: 'Adds two numbers together', + args: { + a: { + type: 'number', + description: 'First number', + }, + b: { + type: 'number', + description: 'Second number', + }, + }, + returns: { + type: 'number', + description: 'The sum of the two numbers', + }, + }, + multiply: { + description: 'Multiplies two numbers together', + args: { + a: { + type: 'number', + description: 'First number', + }, + b: { + type: 'number', + description: 'Second number', + }, + }, + returns: { + type: 'number', + description: 'The product of the two numbers', + }, + }, + greet: { + description: 'Greets a person by name', + args: { + name: { + type: 'string', + description: 'The name of the person to greet', + }, + }, + returns: { + type: 'string', + description: 'A greeting message', + }, + }, + }, + ); + + return makeDefaultExo('root', { + bootstrap() { + return 'discoverable-capability-vat ready'; + }, + getCalculator() { + return calculator; + }, + }); +} diff --git a/packages/kernel-test/tsconfig.json b/packages/kernel-test/tsconfig.json index a1a286edd..bc2bca875 100644 --- a/packages/kernel-test/tsconfig.json +++ b/packages/kernel-test/tsconfig.json @@ -8,6 +8,7 @@ "types": ["vitest"] }, "references": [ + { "path": "../kernel-agents" }, { "path": "../kernel-store" }, { "path": "../kernel-utils" }, { "path": "../nodejs" }, diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 84ccb85d1..46810c29e 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -39,6 +39,16 @@ "default": "./dist/exo.cjs" } }, + "./discoverable": { + "import": { + "types": "./dist/discoverable.d.mts", + "default": "./dist/discoverable.mjs" + }, + "require": { + "types": "./dist/discoverable.d.cts", + "default": "./dist/discoverable.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/packages/kernel-utils/src/discoverable.test.ts b/packages/kernel-utils/src/discoverable.test.ts new file mode 100644 index 000000000..32969b470 --- /dev/null +++ b/packages/kernel-utils/src/discoverable.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { makeDiscoverableExo } from './discoverable.ts'; +import type { MethodSchema } from './schema.ts'; + +const makeExoMock = vi.hoisted(() => + vi.fn((_name, _interfaceGuard, methods) => methods), +); + +vi.mock('@endo/exo', () => ({ + makeExo: makeExoMock, +})); + +describe('makeDiscoverableExo', () => { + const greetSchema: MethodSchema = { + description: 'Greets a person by name', + args: { + name: { + type: 'string', + description: 'The name of the person to greet', + }, + }, + returns: { + type: 'string', + description: 'A greeting message', + }, + }; + + const addSchema: MethodSchema = { + description: 'Adds two numbers together', + args: { + a: { + type: 'number', + description: 'First number', + }, + b: { + type: 'number', + description: 'Second number', + }, + }, + returns: { + type: 'number', + description: 'The sum of the two numbers', + }, + }; + + it('creates a discoverable exo with methods and schema', () => { + const methods = { + greet: (name: string) => `Hello, ${name}!`, + add: (a: number, b: number) => a + b, + }; + const schema = { greet: greetSchema, add: addSchema }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo).toBeDefined(); + expect(exo.greet).toBeDefined(); + expect(exo.add).toBeDefined(); + expect(exo.describe).toBeDefined(); + }); + + it('returns full schema when describe is called', () => { + const methods = { greet: (name: string) => `Hello, ${name}!` }; + const schema = { greet: greetSchema }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.describe()).toStrictEqual(schema); + }); + + it('preserves method functionality', () => { + const methods = { + greet: (name: string) => `Hello, ${name}!`, + add: (a: number, b: number) => a + b, + }; + const schema = { greet: greetSchema, add: addSchema }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.greet('Alice')).toBe('Hello, Alice!'); + expect(exo.add(5, 3)).toBe(8); + }); + + it('handles methods with no arguments', () => { + const methods = { getValue: () => 42 }; + const schema: Record = { + getValue: { + description: 'Returns a constant value', + args: {}, + returns: { type: 'number', description: 'The constant value' }, + }, + }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.getValue()).toBe(42); + expect(exo.describe()).toStrictEqual({ + getValue: schema.getValue, + }); + }); + + it('handles methods with no return value', () => { + let called = false; + const methods = { + doSomething: () => { + called = true; + }, + }; + const schema: Record = { + doSomething: { + description: 'Performs an action', + args: {}, + }, + }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + exo.doSomething(); + expect(called).toBe(true); + expect(exo.describe()).toStrictEqual({ + doSomething: schema.doSomething, + }); + }); + + it('throws if describe is already a method', () => { + const methods = { + describe: () => 'original describe', + greet: (name: string) => `Hello, ${name}!`, + }; + const schema: Record = { + describe: { + description: 'Original describe method', + args: {}, + returns: { type: 'string', description: 'Original description' }, + }, + greet: greetSchema, + }; + + expect(() => { + makeDiscoverableExo('TestExo', methods, schema); + }).toThrow('The `describe` method name is reserved for discoverable exos.'); + }); + + it('re-throws errors from makeExo that are not about describe key', () => { + const testError = new Error('Some other error from makeExo'); + makeExoMock.mockImplementation(() => { + throw testError; + }); + + const methods = { greet: (name: string) => `Hello, ${name}!` }; + const schema = { greet: greetSchema }; + + expect(() => { + makeDiscoverableExo('TestExo', methods, schema); + }).toThrow('Some other error from makeExo'); + }); +}); diff --git a/packages/kernel-utils/src/discoverable.ts b/packages/kernel-utils/src/discoverable.ts new file mode 100644 index 000000000..65a1c07f2 --- /dev/null +++ b/packages/kernel-utils/src/discoverable.ts @@ -0,0 +1,81 @@ +import { makeExo } from '@endo/exo'; +import type { Methods } from '@endo/exo'; +import type { InterfaceGuard } from '@endo/patterns'; + +import { makeDefaultInterface } from './exo.ts'; +import { mergeDisjointRecords } from './merge-disjoint-records.ts'; +import type { MethodSchema } from './schema.ts'; + +/** + * A discoverable exo object that extends a base exo interface with a `describe` method + * for runtime introspection of method schemas. + */ +export type DiscoverableExo< + Interface extends Methods = Record unknown>, + Schema extends Record = Record< + keyof Interface, + MethodSchema + >, +> = ReturnType< + typeof makeExo< + Interface & { + /** + * Describe the methods of the discoverable. + * + * @returns A schema of the methods. + */ + describe: () => Schema; + } + > +>; + +/** + * Shorthand for creating a discoverable `@endo/exo` remotable with default guards set to 'passable'. + * The keys of the schema must match the keys of the methods. By convention, the schema is exhaustive. + * In other words, the schema is a complete description of the interface. In practice, it may be incomplete. + * + * @param name - The name of the discoverable. + * @param methods - The methods of the discoverable. + * @param schema - The schema of the discoverable, with method schemas including descriptions, arguments, and return types. + * @param interfaceGuard - The interface guard of the discoverable. + * @returns A discoverable exo. + */ +export const makeDiscoverableExo = < + Interface extends Methods, + Schema extends Record = Record< + keyof Interface, + MethodSchema + >, +>( + name: string, + methods: Interface, + schema: Schema, + interfaceGuard: InterfaceGuard = makeDefaultInterface(name), +): DiscoverableExo => { + try { + // @ts-expect-error We're intentionally not specifying method-specific interface guards. + return makeExo( + name, + interfaceGuard, + // @ts-expect-error We're intentionally not specifying method-specific interface guards. + mergeDisjointRecords(methods, { + /** + * Describe the methods of the discoverable. + * + * @returns A schema of the methods. + */ + describe: () => schema, + }), + ); + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Duplicate keys in records: describe') + ) { + throw new Error( + 'The `describe` method name is reserved for discoverable exos.', + ); + } + throw error; + } +}; diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 19c68e852..2b84f0ea6 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -23,6 +23,7 @@ describe('index', () => { 'makeCounter', 'makeDefaultExo', 'makeDefaultInterface', + 'makeDiscoverableExo', 'mergeDisjointRecords', 'retry', 'retryWithBackoff', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 307088ffc..ee6d40151 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -1,4 +1,7 @@ export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; +export { makeDiscoverableExo } from './discoverable.ts'; +export type { DiscoverableExo } from './discoverable.ts'; +export type { JsonSchema, MethodSchema } from './schema.ts'; export { fetchValidatedJson } from './fetchValidatedJson.ts'; export { abortableDelay, delay, makeCounter } from './misc.ts'; export { stringify } from './stringify.ts'; diff --git a/packages/kernel-utils/src/schema.ts b/packages/kernel-utils/src/schema.ts new file mode 100644 index 000000000..2da1c06c3 --- /dev/null +++ b/packages/kernel-utils/src/schema.ts @@ -0,0 +1,57 @@ +/** + * JSON Schema type for describing values. Supports primitives, arrays, and objects + * with recursive definitions. + */ +export type JsonSchema = + | PrimitiveJsonSchema + | ArrayJsonSchema + | ObjectJsonSchema; + +/** + * Primitive JSON Schema types (string, number, boolean). + */ +type PrimitiveJsonSchema = { + type: 'string' | 'number' | 'boolean'; + description?: string; +}; + +/** + * Array JSON Schema with recursive item type. + */ +type ArrayJsonSchema = { + type: 'array'; + description?: string; + items: JsonSchema; +}; + +/** + * Object JSON Schema with recursive property definitions. + */ +type ObjectJsonSchema = { + type: 'object'; + description?: string; + properties: { + [key: string]: JsonSchema; + }; + required?: string[]; + additionalProperties?: boolean; +}; + +/** + * Schema describing a method, including its purpose, arguments, and return value. + */ +export type MethodSchema = { + /** + * Description of the method's purpose and behavior. + */ + description: string; + /** + * Arguments of the method, keyed by argument name. + * Each argument includes its type and description. + */ + args: Record; + /** + * Return value schema, including type and description. + */ + returns?: JsonSchema; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 607ab8890..852d14fc6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -80,10 +80,10 @@ export default defineConfig({ lines: 2.97, }, 'packages/kernel-agents/**': { - statements: 94.15, - functions: 94.64, - branches: 91.39, - lines: 94.15, + statements: 93.3, + functions: 93.75, + branches: 91.37, + lines: 93.3, }, 'packages/kernel-browser-runtime/**': { statements: 87.52, @@ -130,7 +130,7 @@ export default defineConfig({ 'packages/kernel-ui/**': { statements: 97.57, functions: 97.29, - branches: 93.26, + branches: 93.25, lines: 97.57, }, 'packages/kernel-utils/**': { diff --git a/yarn.lock b/yarn.lock index 9ea4118a4..ec194a979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3270,6 +3270,7 @@ __metadata: resolution: "@ocap/kernel-agents@workspace:packages/kernel-agents" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0"