Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kernel-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
49 changes: 49 additions & 0 deletions packages/kernel-agents/src/capabilities/discover.ts
Original file line number Diff line number Diff line change
@@ -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<CapabilityRecord> => {
// @ts-expect-error - E type doesn't remember method names
const description = (await E(exo).describe()) as Record<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<string, unknown>) => {
// 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<never, unknown>,
];
}),
);

return capabilities;
};
2 changes: 1 addition & 1 deletion packages/kernel-agents/src/capabilities/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions packages/kernel-agents/src/capabilities/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
},
);
Expand All @@ -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.' },
Expand Down
2 changes: 1 addition & 1 deletion packages/kernel-agents/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
);
});
});
1 change: 1 addition & 0 deletions packages/kernel-agents/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type { CapabilityRecord } from './types.ts';
export { discover } from './capabilities/discover.ts';
2 changes: 1 addition & 1 deletion packages/kernel-agents/src/types/capability.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JsonSchema } from './json-schema.ts';
import type { JsonSchema } from '@metamask/kernel-utils';

export type Capability<Args extends Record<string, unknown>, Return = null> = (
args: Args,
Expand Down
25 changes: 0 additions & 25 deletions packages/kernel-agents/src/types/json-schema.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/kernel-agents/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{ "path": "../kernel-language-model-service" },
{ "path": "../kernel-utils" },
{ "path": "../logger" },
{ "path": "../ocap-kernel" },
{ "path": "../repo-tools" }
],
"include": [
Expand Down
79 changes: 79 additions & 0 deletions packages/kernel-test/src/vats/discoverable-capability-vat.js
Original file line number Diff line number Diff line change
@@ -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;
},
});
}
1 change: 1 addition & 0 deletions packages/kernel-test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"types": ["vitest"]
},
"references": [
{ "path": "../kernel-agents" },
{ "path": "../kernel-store" },
{ "path": "../kernel-utils" },
{ "path": "../nodejs" },
Expand Down
10 changes: 10 additions & 0 deletions packages/kernel-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions packages/kernel-utils/src/discoverable.test.ts
Original file line number Diff line number Diff line change
@@ -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<keyof typeof methods, MethodSchema> = {
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<keyof typeof methods, MethodSchema> = {
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<keyof typeof methods, MethodSchema> = {
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');
});
});
Loading
Loading