From 74a517465e4a9f4f988027929cbcaea0237e4cad Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:35:17 -0500 Subject: [PATCH 01/20] feat(kernel-utils): makeDiscoverableExo --- .../kernel-utils/src/discoverable.test.ts | 254 ++++++++++++++++++ packages/kernel-utils/src/discoverable.ts | 81 ++++++ packages/kernel-utils/src/index.ts | 1 + packages/kernel-utils/src/schema.ts | 57 ++++ 4 files changed, 393 insertions(+) create mode 100644 packages/kernel-utils/src/discoverable.test.ts create mode 100644 packages/kernel-utils/src/discoverable.ts create mode 100644 packages/kernel-utils/src/schema.ts diff --git a/packages/kernel-utils/src/discoverable.test.ts b/packages/kernel-utils/src/discoverable.test.ts new file mode 100644 index 000000000..5d8c5aec5 --- /dev/null +++ b/packages/kernel-utils/src/discoverable.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import { makeDiscoverableExo } from './discoverable.ts'; +import type { MethodSchema } from './schema.ts'; + +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', + }, + }; + + const subtractSchema: MethodSchema = { + description: 'Subtracts two numbers', + args: { + a: { + type: 'number', + description: 'First number', + }, + b: { + type: 'number', + description: 'Second number', + }, + }, + returns: { + type: 'number', + description: 'The difference 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 with no arguments', () => { + const methods = { greet: (name: string) => `Hello, ${name}!` }; + const schema = { greet: greetSchema }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.describe()).toStrictEqual(schema); + }); + + it.each([ + { methodNames: ['greet'], expected: { greet: greetSchema } }, + { + methodNames: ['greet', 'add'], + expected: { greet: greetSchema, add: addSchema }, + }, + { + methodNames: ['greet', 'add', 'subtract'], + expected: { + greet: greetSchema, + add: addSchema, + subtract: subtractSchema, + }, + }, + ])( + 'returns partial schema when describe is called with method names $methodNames', + ({ methodNames, expected }) => { + const methods = { + greet: (name: string) => `Hello, ${name}!`, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + }; + const schema = { + greet: greetSchema, + add: addSchema, + subtract: subtractSchema, + }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect( + exo.describe(...(methodNames as (keyof typeof methods)[])), + ).toStrictEqual(expected); + }, + ); + + 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('getValue')).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('doSomething')).toStrictEqual({ + doSomething: schema.doSomething, + }); + }); + + it('handles complex nested schemas', () => { + const methods = { + processData: (data: { name: string; age: number }) => ({ + result: 'processed', + data, + }), + }; + const schema: Record = { + processData: { + description: 'Processes user data', + args: { + data: { + type: 'object', + description: 'User data object', + properties: { + name: { type: 'string', description: 'User name' }, + age: { type: 'number', description: 'User age' }, + }, + required: ['name', 'age'], + }, + }, + returns: { + type: 'object', + description: 'Processed result', + properties: { + result: { type: 'string', description: 'Processing status' }, + data: { + type: 'object', + description: 'Original data', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + }, + }, + }, + }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + const result = exo.processData({ name: 'Alice', age: 30 }); + + expect(result).toStrictEqual({ + result: 'processed', + data: { name: 'Alice', age: 30 }, + }); + expect(exo.describe('processData')).toStrictEqual({ + processData: schema.processData, + }); + }); + + it('handles array schemas', () => { + const methods = { + sum: (numbers: number[]) => numbers.reduce((a, b) => a + b, 0), + }; + const schema: Record = { + sum: { + description: 'Sums an array of numbers', + args: { + numbers: { + type: 'array', + description: 'Array of numbers to sum', + items: { type: 'number', description: 'A number' }, + }, + }, + returns: { type: 'number', description: 'The sum of all numbers' }, + }, + }; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.sum([1, 2, 3, 4])).toBe(10); + expect(exo.describe('sum')).toStrictEqual({ sum: schema.sum }); + }); + + it('handles empty methods object', () => { + const methods = {}; + const schema = {} as Record; + + const exo = makeDiscoverableExo('TestExo', methods, schema); + + expect(exo.describe()).toStrictEqual({}); + }); +}); diff --git a/packages/kernel-utils/src/discoverable.ts b/packages/kernel-utils/src/discoverable.ts new file mode 100644 index 000000000..25fe48223 --- /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 type { MethodSchema } from './schema.ts'; + +// The path names for describing methods are: +// - `describe('')` -> get the entire method schema +// - `describe('.args')` -> get the types and descriptions for the arguments +// - `describe('.args.')` -> get the type and description for the argument +// - `describe('.returns')` -> get the type and description for the return value +// - `describe('.returns.')` -> get the type and description for a property of the return value +// - `describe()` -> get the entire schema for the discoverable exo + +/** + * A discoverable exo object that extends a base exo interface with a `describe` method + * for runtime introspection of method schemas. + */ +type DiscoverableExo< + Interface extends Methods, + Schema extends Record = Record< + keyof Interface, + MethodSchema + >, +> = ReturnType> & { + /** + * Describe the methods of the discoverable. + * + * @param methodNames - The names of the methods to describe. If omitted, returns the entire schema. + * @returns A schema of the methods. If method names are provided, returns a partial schema. + */ + describe: { + (): Schema; + ( + ...methodNames: (keyof Interface)[] + ): Partial>; + }; +}; + +/** + * 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 => + // @ts-expect-error We're intentionally not specifying method-specific interface guards. + makeExo(name, interfaceGuard, { + ...methods, + /** + * Describe the methods of the discoverable. + * + * @param methodNames - The names of the methods to describe. + * @returns A partial schema of the methods. + */ + describe: (...methodNames: (keyof Interface)[]) => { + if (methodNames.length === 0) { + return schema; + } + return Object.fromEntries( + methodNames.map((methodName) => [methodName, schema[methodName]]), + ) as Partial>; + }, + }); diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 307088ffc..30921d6d5 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -1,4 +1,5 @@ export { makeDefaultInterface, makeDefaultExo } from './exo.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; +}; From d55f66d8ae9b2092a659725fb838c7f69e0da938 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:35:51 -0500 Subject: [PATCH 02/20] refactor(kernel-agents): Use schema from kernel-utils --- .../src/capabilities/examples.ts | 2 +- .../kernel-agents/src/capabilities/math.ts | 4 +-- .../kernel-agents/src/types/capability.ts | 2 +- .../kernel-agents/src/types/json-schema.ts | 25 ------------------- 4 files changed, 4 insertions(+), 29 deletions(-) delete mode 100644 packages/kernel-agents/src/types/json-schema.ts 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/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; -}; From 72714ae3ce358220777f280dcbc3c86be65aa5d1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:39:29 -0500 Subject: [PATCH 03/20] thresholds --- vitest.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 607ab8890..36f90fc14 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -81,8 +81,8 @@ export default defineConfig({ }, 'packages/kernel-agents/**': { statements: 94.15, - functions: 94.64, - branches: 91.39, + functions: 94.59, + branches: 91.37, lines: 94.15, }, 'packages/kernel-browser-runtime/**': { From 923860ccdd8e3d71cd017c3508a8492e07a6ce5b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:39:20 -0500 Subject: [PATCH 04/20] export makeDiscoverableExo from kernel-utils/exo; WARN dep cycle --- packages/kernel-utils/src/exo.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kernel-utils/src/exo.ts b/packages/kernel-utils/src/exo.ts index 542b15912..0f5d3b7dd 100644 --- a/packages/kernel-utils/src/exo.ts +++ b/packages/kernel-utils/src/exo.ts @@ -3,6 +3,8 @@ import type { Methods } from '@endo/exo'; import { M } from '@endo/patterns'; import type { InterfaceGuard } from '@endo/patterns'; +import { makeDiscoverableExo } from './discoverable.ts'; + /** * Shorthand for creating a named `@endo/patterns.InterfaceGuard` with default guards * set to 'passable'. @@ -28,3 +30,5 @@ export const makeDefaultExo = ( ): ReturnType> => // @ts-expect-error We're intentionally not specifying method-specific interface guards. makeExo(name, interfaceGuard, methods); + +export { makeDiscoverableExo }; From 6e80bee2b583c50d29073859cd69887adcf2e33d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:39:56 -0500 Subject: [PATCH 05/20] chore: internal dependency updates --- packages/kernel-test/package.json | 1 + packages/kernel-test/tsconfig.json | 1 + yarn.lock | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index b1e12195d..f2d5ded88 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -59,6 +59,7 @@ "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "@metamask/utils": "^11.4.2", + "@ocap/kernel-agents": "workspace:^", "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", 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/yarn.lock b/yarn.lock index 9ea4118a4..6b4956a1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3265,7 +3265,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/kernel-agents@workspace:packages/kernel-agents": +"@ocap/kernel-agents@workspace:^, @ocap/kernel-agents@workspace:packages/kernel-agents": version: 0.0.0-use.local resolution: "@ocap/kernel-agents@workspace:packages/kernel-agents" dependencies: @@ -3413,6 +3413,7 @@ __metadata: "@metamask/streams": "workspace:^" "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" + "@ocap/kernel-agents": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" From a5669a17d5bb73037ffaa08ac01083344620716b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:46:14 -0500 Subject: [PATCH 06/20] feat(kernel-test): Add discoverable exo e2e test --- .../kernel-test/src/discoverable-exo.test.ts | 203 ++++++++++++++++++ .../src/vats/discoverable-capability-vat.js | 81 +++++++ 2 files changed, 284 insertions(+) create mode 100644 packages/kernel-test/src/discoverable-exo.test.ts create mode 100644 packages/kernel-test/src/vats/discoverable-capability-vat.js diff --git a/packages/kernel-test/src/discoverable-exo.test.ts b/packages/kernel-test/src/discoverable-exo.test.ts new file mode 100644 index 000000000..dc544c5f9 --- /dev/null +++ b/packages/kernel-test/src/discoverable-exo.test.ts @@ -0,0 +1,203 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; + +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { Logger, consoleTransport } from '@metamask/logger'; +import { Kernel, kunser } from '@metamask/ocap-kernel'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; +import type { CapabilityRecord } from '@ocap/kernel-agents'; +import { makeJsonAgent } from '@ocap/kernel-agents/json'; +import { OllamaNodejsService } from '@ocap/kernel-language-model-service/ollama/nodejs'; +import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +// Import from source files since they're not exported + +import { getBundleSpec, makeKernel, runTestVats } from './utils.ts'; +import { capability } from '../../kernel-agents/src/capabilities/capability.ts'; +import { DEFAULT_MODEL } from '../../kernel-agents/test/constants.ts'; + +const logger = new Logger({ + tags: ['test'], + transports: [consoleTransport], +}); + +describe('discoverable exo capabilities', () => { + let kernel: Kernel; + let calculatorRef: string; + + beforeAll(() => { + fetchMock.disableMocks(); + }); + + afterAll(() => { + fetchMock.enableMocks(); + }); + + beforeEach(async () => { + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeKernel(kernelDatabase, true, logger); + + const testSubcluster: ClusterConfig = { + bootstrap: 'discoverableTest', + forceReset: true, + vats: { + discoverableTest: { + bundleSpec: getBundleSpec('discoverable-capability-vat'), + parameters: {}, + }, + }, + }; + + await runTestVats(kernel, testSubcluster); + await waitUntilQuiescent(100); + + // The first vat root object is ko3 due to kernel service objects + const vatRootRef = 'ko3'; + const calculatorResult = await kernel.queueMessage( + vatRootRef, + 'getCalculator', + [], + ); + // Exo objects are returned in the slots array + calculatorRef = calculatorResult.slots[0] as string; + }); + + it('converts discoverable exo methods to agent capabilities', async () => { + // Get the schema from the discoverable exo + const describeResult = await kernel.queueMessage( + calculatorRef, + 'describe', + [], + ); + const schema = kunser(describeResult) as Record< + string, + { + description: string; + args: Record; + returns?: { type: string; description: string }; + } + >; + + // Convert each method to a capability + // For methods with multiple args, we need to extract them from the args object + const capabilities: CapabilityRecord = Object.fromEntries( + Object.entries(schema).map(([methodName, methodSchema]) => { + const argNames = Object.keys(methodSchema.args); + return [ + methodName, + capability( + async (args: Record) => { + // Extract arguments in the order they appear in the schema + const methodArgs = argNames.map((argName) => args[argName]); + const result = await kernel.queueMessage( + calculatorRef, + methodName, + methodArgs, + ); + return kunser(result); + }, + { + description: methodSchema.description, + args: Object.fromEntries( + Object.entries(methodSchema.args).map( + ([argName, argSchema]) => [ + argName, + { + type: argSchema.type as 'string' | 'number' | 'boolean', + description: argSchema.description, + }, + ], + ), + ), + ...(methodSchema.returns + ? { + returns: { + type: methodSchema.returns.type as + | 'string' + | 'number' + | 'boolean', + description: methodSchema.returns.description, + }, + } + : {}), + }, + ), + ]; + }), + ); + + // Create agent with the capabilities + const languageModelService = new OllamaNodejsService({ + endowments: { fetch }, + }); + const languageModel = await languageModelService.makeInstance({ + model: DEFAULT_MODEL, + }); + const agent = makeJsonAgent({ + languageModel, + capabilities, + logger, + }); + + // Test that the agent can use the capabilities + const result = await agent.task( + 'Add 5 and 3, then multiply the result by 2', + undefined, + { invocationBudget: 5 }, + ); + + expect(result).toBeDefined(); + // The result should be (5 + 3) * 2 = 16 + expect(String(result)).toContain('16'); + }); + + it('can discover schema from discoverable exo', async () => { + // Get the full schema + const describeResult = await kernel.queueMessage( + calculatorRef, + 'describe', + [], + ); + const fullSchema = kunser(describeResult); + + expect(fullSchema).toBeDefined(); + expect(fullSchema).toHaveProperty('add'); + expect(fullSchema).toHaveProperty('multiply'); + expect(fullSchema).toHaveProperty('greet'); + + // Get partial schema for specific methods + const partialResult = await kernel.queueMessage(calculatorRef, 'describe', [ + 'add', + 'multiply', + ]); + const partialSchema = kunser(partialResult); + + expect(partialSchema).toHaveProperty('add'); + expect(partialSchema).toHaveProperty('multiply'); + expect(partialSchema).not.toHaveProperty('greet'); + }); + + it('can invoke discoverable exo methods directly', async () => { + // Test direct method invocation + const addResult = await kernel.queueMessage(calculatorRef, 'add', [5, 3]); + const sum = kunser(addResult); + expect(sum).toBe(8); + + const multiplyResult = await kernel.queueMessage( + calculatorRef, + 'multiply', + [4, 7], + ); + const product = kunser(multiplyResult); + expect(product).toBe(28); + + const greetResult = await kernel.queueMessage(calculatorRef, 'greet', [ + 'Alice', + ]); + const greeting = kunser(greetResult); + expect(greeting).toBe('Hello, Alice!'); + }); +}); 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..1e2d0482d --- /dev/null +++ b/packages/kernel-test/src/vats/discoverable-capability-vat.js @@ -0,0 +1,81 @@ +import { + makeDefaultExo, + makeDiscoverableExo, +} 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; + }, + }); +} From 86fcefcad40812c38d4ec37c368fda4ab0f652cc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:26:39 -0500 Subject: [PATCH 07/20] feat(kernel-agents): Implement discoverable parsing --- packages/kernel-agents/package.json | 1 + .../src/capabilities/discoverable.ts | 57 +++++++++++++++++++ packages/kernel-agents/src/index.ts | 1 + packages/kernel-agents/tsconfig.json | 1 + yarn.lock | 1 + 5 files changed, 61 insertions(+) create mode 100644 packages/kernel-agents/src/capabilities/discoverable.ts diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index a7d0b299e..defd6851e 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -105,6 +105,7 @@ "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@ocap/kernel-language-model-service": "workspace:^", "ses": "^1.14.0", "tree-sitter": "^0.25.0", diff --git a/packages/kernel-agents/src/capabilities/discoverable.ts b/packages/kernel-agents/src/capabilities/discoverable.ts new file mode 100644 index 000000000..25af62a5f --- /dev/null +++ b/packages/kernel-agents/src/capabilities/discoverable.ts @@ -0,0 +1,57 @@ +import type { MethodSchema } from '@metamask/kernel-utils'; +import type { Kernel } from '@metamask/ocap-kernel'; +import { kunser } from '@metamask/ocap-kernel'; + +import { capability } from './capability.ts'; +import type { CapabilityRecord } from '../types.ts'; + +/** + * Convert a discoverable exo's schema to agent capabilities. + * This function fetches the schema from the discoverable exo and creates + * capabilities that can be used by kernel agents. + * + * @param kernel - The kernel instance to use for messaging. + * @param discoverableExoRef - The KRef to the discoverable exo. + * @returns A promise that resolves to a record of capabilities. + */ +export const discoverableExoToCapabilities = async ( + kernel: Kernel, + discoverableExoRef: string, +): Promise => { + // Get the schema from the discoverable exo + const describeResult = await kernel.queueMessage( + discoverableExoRef, + 'describe', + [], + ); + const schema = kunser(describeResult) as Record; + + // Convert each method to a capability + const capabilities: CapabilityRecord = Object.fromEntries( + Object.entries(schema).map(([methodName, methodSchema]) => { + const argNames = Object.keys(methodSchema.args); + return [ + methodName, + capability( + async (args: Record) => { + // Extract arguments in the order they appear in the schema + const methodArgs = argNames.map((argName) => args[argName]); + const result = await kernel.queueMessage( + discoverableExoRef, + methodName, + methodArgs, + ); + return kunser(result); + }, + { + description: methodSchema.description, + args: methodSchema.args, + ...(methodSchema.returns ? { returns: methodSchema.returns } : {}), + }, + ), + ]; + }), + ); + + return capabilities; +}; diff --git a/packages/kernel-agents/src/index.ts b/packages/kernel-agents/src/index.ts index d2493229a..6f52fd586 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 { discoverableExoToCapabilities } from './capabilities/discoverable.ts'; 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/yarn.lock b/yarn.lock index 6b4956a1b..6c83ac98b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3277,6 +3277,7 @@ __metadata: "@metamask/kernel-errors": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" From 6749bd34eed8dff9eca69eac614def4bb7a13a9c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:27:13 -0500 Subject: [PATCH 08/20] feat(kernel-test): Use kernel-agents discoverable parser --- .../kernel-test/src/discoverable-exo.test.ts | 68 ++----------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/packages/kernel-test/src/discoverable-exo.test.ts b/packages/kernel-test/src/discoverable-exo.test.ts index dc544c5f9..5d155c19a 100644 --- a/packages/kernel-test/src/discoverable-exo.test.ts +++ b/packages/kernel-test/src/discoverable-exo.test.ts @@ -5,16 +5,13 @@ import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Logger, consoleTransport } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import type { CapabilityRecord } from '@ocap/kernel-agents'; +import { discoverableExoToCapabilities } from '@ocap/kernel-agents'; import { makeJsonAgent } from '@ocap/kernel-agents/json'; import { OllamaNodejsService } from '@ocap/kernel-language-model-service/ollama/nodejs'; import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -// Import from source files since they're not exported - import { getBundleSpec, makeKernel, runTestVats } from './utils.ts'; -import { capability } from '../../kernel-agents/src/capabilities/capability.ts'; import { DEFAULT_MODEL } from '../../kernel-agents/test/constants.ts'; const logger = new Logger({ @@ -66,67 +63,10 @@ describe('discoverable exo capabilities', () => { }); it('converts discoverable exo methods to agent capabilities', async () => { - // Get the schema from the discoverable exo - const describeResult = await kernel.queueMessage( + // Convert discoverable exo to capabilities + const capabilities = await discoverableExoToCapabilities( + kernel, calculatorRef, - 'describe', - [], - ); - const schema = kunser(describeResult) as Record< - string, - { - description: string; - args: Record; - returns?: { type: string; description: string }; - } - >; - - // Convert each method to a capability - // For methods with multiple args, we need to extract them from the args object - const capabilities: CapabilityRecord = Object.fromEntries( - Object.entries(schema).map(([methodName, methodSchema]) => { - const argNames = Object.keys(methodSchema.args); - return [ - methodName, - capability( - async (args: Record) => { - // Extract arguments in the order they appear in the schema - const methodArgs = argNames.map((argName) => args[argName]); - const result = await kernel.queueMessage( - calculatorRef, - methodName, - methodArgs, - ); - return kunser(result); - }, - { - description: methodSchema.description, - args: Object.fromEntries( - Object.entries(methodSchema.args).map( - ([argName, argSchema]) => [ - argName, - { - type: argSchema.type as 'string' | 'number' | 'boolean', - description: argSchema.description, - }, - ], - ), - ), - ...(methodSchema.returns - ? { - returns: { - type: methodSchema.returns.type as - | 'string' - | 'number' - | 'boolean', - description: methodSchema.returns.description, - }, - } - : {}), - }, - ), - ]; - }), ); // Create agent with the capabilities From 56793a3ec155977d0ed22e8bba0220cece7accc3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:46:03 -0500 Subject: [PATCH 09/20] fix: resolve circular dependency --- .../src/vats/discoverable-capability-vat.js | 6 ++---- packages/kernel-utils/package.json | 10 ++++++++++ packages/kernel-utils/src/exo.ts | 4 ---- packages/kernel-utils/src/index.ts | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/kernel-test/src/vats/discoverable-capability-vat.js b/packages/kernel-test/src/vats/discoverable-capability-vat.js index 1e2d0482d..ab62724c0 100644 --- a/packages/kernel-test/src/vats/discoverable-capability-vat.js +++ b/packages/kernel-test/src/vats/discoverable-capability-vat.js @@ -1,7 +1,5 @@ -import { - makeDefaultExo, - makeDiscoverableExo, -} from '@metamask/kernel-utils/exo'; +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. 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/exo.ts b/packages/kernel-utils/src/exo.ts index 0f5d3b7dd..542b15912 100644 --- a/packages/kernel-utils/src/exo.ts +++ b/packages/kernel-utils/src/exo.ts @@ -3,8 +3,6 @@ import type { Methods } from '@endo/exo'; import { M } from '@endo/patterns'; import type { InterfaceGuard } from '@endo/patterns'; -import { makeDiscoverableExo } from './discoverable.ts'; - /** * Shorthand for creating a named `@endo/patterns.InterfaceGuard` with default guards * set to 'passable'. @@ -30,5 +28,3 @@ export const makeDefaultExo = ( ): ReturnType> => // @ts-expect-error We're intentionally not specifying method-specific interface guards. makeExo(name, interfaceGuard, methods); - -export { makeDiscoverableExo }; diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 30921d6d5..cfa089f65 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -1,4 +1,5 @@ export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; +export { makeDiscoverableExo } from './discoverable.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; export { fetchValidatedJson } from './fetchValidatedJson.ts'; export { abortableDelay, delay, makeCounter } from './misc.ts'; From 623f3a5b626c8e8bd2e63ad92d57580a197dfee1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:00:37 -0500 Subject: [PATCH 10/20] update export test --- packages/kernel-utils/src/index.test.ts | 1 + 1 file changed, 1 insertion(+) 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', From c1aff2442b5630d48c325f3b5d09229245733415 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:22:04 -0500 Subject: [PATCH 11/20] types(kernel-utils): Better discoverable def --- packages/kernel-utils/src/discoverable.ts | 4 ++-- packages/kernel-utils/src/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kernel-utils/src/discoverable.ts b/packages/kernel-utils/src/discoverable.ts index 25fe48223..9614d697f 100644 --- a/packages/kernel-utils/src/discoverable.ts +++ b/packages/kernel-utils/src/discoverable.ts @@ -17,8 +17,8 @@ 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. */ -type DiscoverableExo< - Interface extends Methods, +export type DiscoverableExo< + Interface extends Methods = Record unknown>, Schema extends Record = Record< keyof Interface, MethodSchema diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index cfa089f65..ee6d40151 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -1,5 +1,6 @@ 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'; From da88c3303e6e971f72ee41d8b2d5185a333d3995 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:28:14 -0500 Subject: [PATCH 12/20] feat!(kernel-utils): Simplify describe interface --- .../kernel-utils/src/discoverable.test.ts | 144 +----------------- packages/kernel-utils/src/discoverable.ts | 30 +--- 2 files changed, 7 insertions(+), 167 deletions(-) diff --git a/packages/kernel-utils/src/discoverable.test.ts b/packages/kernel-utils/src/discoverable.test.ts index 5d8c5aec5..9507013c9 100644 --- a/packages/kernel-utils/src/discoverable.test.ts +++ b/packages/kernel-utils/src/discoverable.test.ts @@ -36,24 +36,6 @@ describe('makeDiscoverableExo', () => { }, }; - const subtractSchema: MethodSchema = { - description: 'Subtracts two numbers', - args: { - a: { - type: 'number', - description: 'First number', - }, - b: { - type: 'number', - description: 'Second number', - }, - }, - returns: { - type: 'number', - description: 'The difference of the two numbers', - }, - }; - it('creates a discoverable exo with methods and schema', () => { const methods = { greet: (name: string) => `Hello, ${name}!`, @@ -69,7 +51,7 @@ describe('makeDiscoverableExo', () => { expect(exo.describe).toBeDefined(); }); - it('returns full schema when describe is called with no arguments', () => { + it('returns full schema when describe is called', () => { const methods = { greet: (name: string) => `Hello, ${name}!` }; const schema = { greet: greetSchema }; @@ -78,42 +60,6 @@ describe('makeDiscoverableExo', () => { expect(exo.describe()).toStrictEqual(schema); }); - it.each([ - { methodNames: ['greet'], expected: { greet: greetSchema } }, - { - methodNames: ['greet', 'add'], - expected: { greet: greetSchema, add: addSchema }, - }, - { - methodNames: ['greet', 'add', 'subtract'], - expected: { - greet: greetSchema, - add: addSchema, - subtract: subtractSchema, - }, - }, - ])( - 'returns partial schema when describe is called with method names $methodNames', - ({ methodNames, expected }) => { - const methods = { - greet: (name: string) => `Hello, ${name}!`, - add: (a: number, b: number) => a + b, - subtract: (a: number, b: number) => a - b, - }; - const schema = { - greet: greetSchema, - add: addSchema, - subtract: subtractSchema, - }; - - const exo = makeDiscoverableExo('TestExo', methods, schema); - - expect( - exo.describe(...(methodNames as (keyof typeof methods)[])), - ).toStrictEqual(expected); - }, - ); - it('preserves method functionality', () => { const methods = { greet: (name: string) => `Hello, ${name}!`, @@ -140,7 +86,7 @@ describe('makeDiscoverableExo', () => { const exo = makeDiscoverableExo('TestExo', methods, schema); expect(exo.getValue()).toBe(42); - expect(exo.describe('getValue')).toStrictEqual({ + expect(exo.describe()).toStrictEqual({ getValue: schema.getValue, }); }); @@ -163,92 +109,8 @@ describe('makeDiscoverableExo', () => { exo.doSomething(); expect(called).toBe(true); - expect(exo.describe('doSomething')).toStrictEqual({ + expect(exo.describe()).toStrictEqual({ doSomething: schema.doSomething, }); }); - - it('handles complex nested schemas', () => { - const methods = { - processData: (data: { name: string; age: number }) => ({ - result: 'processed', - data, - }), - }; - const schema: Record = { - processData: { - description: 'Processes user data', - args: { - data: { - type: 'object', - description: 'User data object', - properties: { - name: { type: 'string', description: 'User name' }, - age: { type: 'number', description: 'User age' }, - }, - required: ['name', 'age'], - }, - }, - returns: { - type: 'object', - description: 'Processed result', - properties: { - result: { type: 'string', description: 'Processing status' }, - data: { - type: 'object', - description: 'Original data', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - }, - }, - }, - }, - }, - }; - - const exo = makeDiscoverableExo('TestExo', methods, schema); - const result = exo.processData({ name: 'Alice', age: 30 }); - - expect(result).toStrictEqual({ - result: 'processed', - data: { name: 'Alice', age: 30 }, - }); - expect(exo.describe('processData')).toStrictEqual({ - processData: schema.processData, - }); - }); - - it('handles array schemas', () => { - const methods = { - sum: (numbers: number[]) => numbers.reduce((a, b) => a + b, 0), - }; - const schema: Record = { - sum: { - description: 'Sums an array of numbers', - args: { - numbers: { - type: 'array', - description: 'Array of numbers to sum', - items: { type: 'number', description: 'A number' }, - }, - }, - returns: { type: 'number', description: 'The sum of all numbers' }, - }, - }; - - const exo = makeDiscoverableExo('TestExo', methods, schema); - - expect(exo.sum([1, 2, 3, 4])).toBe(10); - expect(exo.describe('sum')).toStrictEqual({ sum: schema.sum }); - }); - - it('handles empty methods object', () => { - const methods = {}; - const schema = {} as Record; - - const exo = makeDiscoverableExo('TestExo', methods, schema); - - expect(exo.describe()).toStrictEqual({}); - }); }); diff --git a/packages/kernel-utils/src/discoverable.ts b/packages/kernel-utils/src/discoverable.ts index 9614d697f..13b1137e8 100644 --- a/packages/kernel-utils/src/discoverable.ts +++ b/packages/kernel-utils/src/discoverable.ts @@ -5,14 +5,6 @@ import type { InterfaceGuard } from '@endo/patterns'; import { makeDefaultInterface } from './exo.ts'; import type { MethodSchema } from './schema.ts'; -// The path names for describing methods are: -// - `describe('')` -> get the entire method schema -// - `describe('.args')` -> get the types and descriptions for the arguments -// - `describe('.args.')` -> get the type and description for the argument -// - `describe('.returns')` -> get the type and description for the return value -// - `describe('.returns.')` -> get the type and description for a property of the return value -// - `describe()` -> get the entire schema for the discoverable exo - /** * A discoverable exo object that extends a base exo interface with a `describe` method * for runtime introspection of method schemas. @@ -27,15 +19,9 @@ export type DiscoverableExo< /** * Describe the methods of the discoverable. * - * @param methodNames - The names of the methods to describe. If omitted, returns the entire schema. - * @returns A schema of the methods. If method names are provided, returns a partial schema. + * @returns A schema of the methods. */ - describe: { - (): Schema; - ( - ...methodNames: (keyof Interface)[] - ): Partial>; - }; + describe: () => Schema; }; /** @@ -67,15 +53,7 @@ export const makeDiscoverableExo = < /** * Describe the methods of the discoverable. * - * @param methodNames - The names of the methods to describe. - * @returns A partial schema of the methods. + * @returns A schema of the methods. */ - describe: (...methodNames: (keyof Interface)[]) => { - if (methodNames.length === 0) { - return schema; - } - return Object.fromEntries( - methodNames.map((methodName) => [methodName, schema[methodName]]), - ) as Partial>; - }, + describe: () => schema, }); From cc1a88e2afce08a1e7bb93d0f13e6b90e16b81bd Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Dec 2025 07:38:12 -0600 Subject: [PATCH 13/20] feat(kernel-agents): Add discover function --- packages/kernel-agents/package.json | 1 + .../src/capabilities/discover.ts | 31 ++++ .../src/capabilities/discoverable.ts | 57 ------- packages/kernel-agents/src/index.test.ts | 2 +- packages/kernel-agents/src/index.ts | 2 +- .../kernel-test/src/discoverable-exo.test.ts | 143 ------------------ yarn.lock | 1 + 7 files changed, 35 insertions(+), 202 deletions(-) create mode 100644 packages/kernel-agents/src/capabilities/discover.ts delete mode 100644 packages/kernel-agents/src/capabilities/discoverable.ts delete mode 100644 packages/kernel-test/src/discoverable-exo.test.ts diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index defd6851e..678515127 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..480906cc7 --- /dev/null +++ b/packages/kernel-agents/src/capabilities/discover.ts @@ -0,0 +1,31 @@ +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]) => + [ + name, + // @ts-expect-error - TODO: fix types + { func: async (...args: unknown[]) => E(exo)[name](...args), schema }, + ] as [string, CapabilitySpec], + ), + ); + + return capabilities; +}; diff --git a/packages/kernel-agents/src/capabilities/discoverable.ts b/packages/kernel-agents/src/capabilities/discoverable.ts deleted file mode 100644 index 25af62a5f..000000000 --- a/packages/kernel-agents/src/capabilities/discoverable.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { MethodSchema } from '@metamask/kernel-utils'; -import type { Kernel } from '@metamask/ocap-kernel'; -import { kunser } from '@metamask/ocap-kernel'; - -import { capability } from './capability.ts'; -import type { CapabilityRecord } from '../types.ts'; - -/** - * Convert a discoverable exo's schema to agent capabilities. - * This function fetches the schema from the discoverable exo and creates - * capabilities that can be used by kernel agents. - * - * @param kernel - The kernel instance to use for messaging. - * @param discoverableExoRef - The KRef to the discoverable exo. - * @returns A promise that resolves to a record of capabilities. - */ -export const discoverableExoToCapabilities = async ( - kernel: Kernel, - discoverableExoRef: string, -): Promise => { - // Get the schema from the discoverable exo - const describeResult = await kernel.queueMessage( - discoverableExoRef, - 'describe', - [], - ); - const schema = kunser(describeResult) as Record; - - // Convert each method to a capability - const capabilities: CapabilityRecord = Object.fromEntries( - Object.entries(schema).map(([methodName, methodSchema]) => { - const argNames = Object.keys(methodSchema.args); - return [ - methodName, - capability( - async (args: Record) => { - // Extract arguments in the order they appear in the schema - const methodArgs = argNames.map((argName) => args[argName]); - const result = await kernel.queueMessage( - discoverableExoRef, - methodName, - methodArgs, - ); - return kunser(result); - }, - { - description: methodSchema.description, - args: methodSchema.args, - ...(methodSchema.returns ? { returns: methodSchema.returns } : {}), - }, - ), - ]; - }), - ); - - return capabilities; -}; 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 6f52fd586..5860af6e6 100644 --- a/packages/kernel-agents/src/index.ts +++ b/packages/kernel-agents/src/index.ts @@ -1,2 +1,2 @@ export type { CapabilityRecord } from './types.ts'; -export { discoverableExoToCapabilities } from './capabilities/discoverable.ts'; +export { discover } from './capabilities/discover.ts'; diff --git a/packages/kernel-test/src/discoverable-exo.test.ts b/packages/kernel-test/src/discoverable-exo.test.ts deleted file mode 100644 index 5d155c19a..000000000 --- a/packages/kernel-test/src/discoverable-exo.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import '@ocap/repo-tools/test-utils/mock-endoify'; - -import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; -import { waitUntilQuiescent } from '@metamask/kernel-utils'; -import { Logger, consoleTransport } from '@metamask/logger'; -import { Kernel, kunser } from '@metamask/ocap-kernel'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { discoverableExoToCapabilities } from '@ocap/kernel-agents'; -import { makeJsonAgent } from '@ocap/kernel-agents/json'; -import { OllamaNodejsService } from '@ocap/kernel-language-model-service/ollama/nodejs'; -import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -import { getBundleSpec, makeKernel, runTestVats } from './utils.ts'; -import { DEFAULT_MODEL } from '../../kernel-agents/test/constants.ts'; - -const logger = new Logger({ - tags: ['test'], - transports: [consoleTransport], -}); - -describe('discoverable exo capabilities', () => { - let kernel: Kernel; - let calculatorRef: string; - - beforeAll(() => { - fetchMock.disableMocks(); - }); - - afterAll(() => { - fetchMock.enableMocks(); - }); - - beforeEach(async () => { - const kernelDatabase = await makeSQLKernelDatabase({ - dbFilename: ':memory:', - }); - kernel = await makeKernel(kernelDatabase, true, logger); - - const testSubcluster: ClusterConfig = { - bootstrap: 'discoverableTest', - forceReset: true, - vats: { - discoverableTest: { - bundleSpec: getBundleSpec('discoverable-capability-vat'), - parameters: {}, - }, - }, - }; - - await runTestVats(kernel, testSubcluster); - await waitUntilQuiescent(100); - - // The first vat root object is ko3 due to kernel service objects - const vatRootRef = 'ko3'; - const calculatorResult = await kernel.queueMessage( - vatRootRef, - 'getCalculator', - [], - ); - // Exo objects are returned in the slots array - calculatorRef = calculatorResult.slots[0] as string; - }); - - it('converts discoverable exo methods to agent capabilities', async () => { - // Convert discoverable exo to capabilities - const capabilities = await discoverableExoToCapabilities( - kernel, - calculatorRef, - ); - - // Create agent with the capabilities - const languageModelService = new OllamaNodejsService({ - endowments: { fetch }, - }); - const languageModel = await languageModelService.makeInstance({ - model: DEFAULT_MODEL, - }); - const agent = makeJsonAgent({ - languageModel, - capabilities, - logger, - }); - - // Test that the agent can use the capabilities - const result = await agent.task( - 'Add 5 and 3, then multiply the result by 2', - undefined, - { invocationBudget: 5 }, - ); - - expect(result).toBeDefined(); - // The result should be (5 + 3) * 2 = 16 - expect(String(result)).toContain('16'); - }); - - it('can discover schema from discoverable exo', async () => { - // Get the full schema - const describeResult = await kernel.queueMessage( - calculatorRef, - 'describe', - [], - ); - const fullSchema = kunser(describeResult); - - expect(fullSchema).toBeDefined(); - expect(fullSchema).toHaveProperty('add'); - expect(fullSchema).toHaveProperty('multiply'); - expect(fullSchema).toHaveProperty('greet'); - - // Get partial schema for specific methods - const partialResult = await kernel.queueMessage(calculatorRef, 'describe', [ - 'add', - 'multiply', - ]); - const partialSchema = kunser(partialResult); - - expect(partialSchema).toHaveProperty('add'); - expect(partialSchema).toHaveProperty('multiply'); - expect(partialSchema).not.toHaveProperty('greet'); - }); - - it('can invoke discoverable exo methods directly', async () => { - // Test direct method invocation - const addResult = await kernel.queueMessage(calculatorRef, 'add', [5, 3]); - const sum = kunser(addResult); - expect(sum).toBe(8); - - const multiplyResult = await kernel.queueMessage( - calculatorRef, - 'multiply', - [4, 7], - ); - const product = kunser(multiplyResult); - expect(product).toBe(28); - - const greetResult = await kernel.queueMessage(calculatorRef, 'greet', [ - 'Alice', - ]); - const greeting = kunser(greetResult); - expect(greeting).toBe('Hello, Alice!'); - }); -}); diff --git a/yarn.lock b/yarn.lock index 6c83ac98b..0331a7c37 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" From 42cac562dd9a0ae347d3d8b5538335a3481ce6cc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:17:02 -0600 Subject: [PATCH 14/20] thresholds --- vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 36f90fc14..d4a1c7545 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.59, + statements: 93.36, + functions: 93.75, branches: 91.37, - lines: 94.15, + lines: 93.36, }, 'packages/kernel-browser-runtime/**': { statements: 87.52, From 3614e44648d4e089e8c854c0cda61dae9e736995 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:25:46 -0600 Subject: [PATCH 15/20] chore: remove unused deps --- packages/kernel-agents/package.json | 1 - packages/kernel-test/package.json | 1 - yarn.lock | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index 678515127..ed642a7ea 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -106,7 +106,6 @@ "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", - "@metamask/ocap-kernel": "workspace:^", "@ocap/kernel-language-model-service": "workspace:^", "ses": "^1.14.0", "tree-sitter": "^0.25.0", diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index f2d5ded88..b1e12195d 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -59,7 +59,6 @@ "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "@metamask/utils": "^11.4.2", - "@ocap/kernel-agents": "workspace:^", "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 0331a7c37..ec194a979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3265,7 +3265,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/kernel-agents@workspace:^, @ocap/kernel-agents@workspace:packages/kernel-agents": +"@ocap/kernel-agents@workspace:packages/kernel-agents": version: 0.0.0-use.local resolution: "@ocap/kernel-agents@workspace:packages/kernel-agents" dependencies: @@ -3278,7 +3278,6 @@ __metadata: "@metamask/kernel-errors": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" - "@metamask/ocap-kernel": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" @@ -3415,7 +3414,6 @@ __metadata: "@metamask/streams": "workspace:^" "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" - "@ocap/kernel-agents": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" From 229447c3e1ae4fb2403d3cffb41032071d2c3cba Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:41:19 -0600 Subject: [PATCH 16/20] discover maps object params to positional params --- .../src/capabilities/discover.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/kernel-agents/src/capabilities/discover.ts b/packages/kernel-agents/src/capabilities/discover.ts index 480906cc7..a8e71e703 100644 --- a/packages/kernel-agents/src/capabilities/discover.ts +++ b/packages/kernel-agents/src/capabilities/discover.ts @@ -17,14 +17,32 @@ export const discover = async ( const description = (await E(exo).describe()) as Record; const capabilities: CapabilityRecord = Object.fromEntries( - Object.entries(description).map( - ([name, schema]) => - [ - name, - // @ts-expect-error - TODO: fix types - { func: async (...args: unknown[]) => E(exo)[name](...args), schema }, - ] as [string, CapabilitySpec], - ), + 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; From 7835fe7b7ce256c0ada3dcd5cd99b4655a4f8350 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:53:46 -0600 Subject: [PATCH 17/20] thresholds --- vitest.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index d4a1c7545..351e805e9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -80,10 +80,10 @@ export default defineConfig({ lines: 2.97, }, 'packages/kernel-agents/**': { - statements: 93.36, + statements: 93.3, functions: 93.75, branches: 91.37, - lines: 93.36, + lines: 93.3, }, 'packages/kernel-browser-runtime/**': { statements: 87.52, From edb590bdc114bb34cb6c6935e034718df6f685aa Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 18 Dec 2025 08:12:18 -0600 Subject: [PATCH 18/20] thresholds --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 351e805e9..852d14fc6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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/**': { From 7faba9735f16c691ab872a991bb3279f572bde47 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:03:53 -0600 Subject: [PATCH 19/20] types: internalize {describe:*} to Interface --- packages/kernel-utils/src/discoverable.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/kernel-utils/src/discoverable.ts b/packages/kernel-utils/src/discoverable.ts index 13b1137e8..fa78d1161 100644 --- a/packages/kernel-utils/src/discoverable.ts +++ b/packages/kernel-utils/src/discoverable.ts @@ -15,14 +15,18 @@ export type DiscoverableExo< keyof Interface, MethodSchema >, -> = ReturnType> & { - /** - * Describe the methods of the discoverable. - * - * @returns A schema of the methods. - */ - describe: () => Schema; -}; +> = 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'. From 0b33f7e7ed4bc2297063a3928beedcbcb98e7bef Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:32:07 -0600 Subject: [PATCH 20/20] reserve describe methodName for discoverableExo --- .../kernel-utils/src/discoverable.test.ts | 43 ++++++++++++++++++- packages/kernel-utils/src/discoverable.ts | 40 ++++++++++++----- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/kernel-utils/src/discoverable.test.ts b/packages/kernel-utils/src/discoverable.test.ts index 9507013c9..32969b470 100644 --- a/packages/kernel-utils/src/discoverable.test.ts +++ b/packages/kernel-utils/src/discoverable.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from 'vitest'; +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', @@ -113,4 +121,37 @@ describe('makeDiscoverableExo', () => { 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 index fa78d1161..65a1c07f2 100644 --- a/packages/kernel-utils/src/discoverable.ts +++ b/packages/kernel-utils/src/discoverable.ts @@ -3,6 +3,7 @@ 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'; /** @@ -50,14 +51,31 @@ export const makeDiscoverableExo = < methods: Interface, schema: Schema, interfaceGuard: InterfaceGuard = makeDefaultInterface(name), -): DiscoverableExo => - // @ts-expect-error We're intentionally not specifying method-specific interface guards. - makeExo(name, interfaceGuard, { - ...methods, - /** - * Describe the methods of the discoverable. - * - * @returns A schema of the methods. - */ - describe: () => schema, - }); +): 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; + } +};