Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/kernel-agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- The built-in capabilities (`math`, `end`, `examples`) are now pattern-guarded discoverable exos authored with the `described*()` combinators, so their argument shapes are enforced by the exo's interface guard at invocation rather than only described in the prompt ([#959](https://github.com/MetaMask/ocap-kernel/pull/959))

[Unreleased]: https://github.com/MetaMask/ocap-kernel/
126 changes: 93 additions & 33 deletions packages/kernel-agents/src/capabilities/discover.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,58 @@
import { E } from '@endo/eventual-send';
import { GET_DESCRIPTION } from '@metamask/kernel-utils';
import type { DiscoverableExo, MethodSchema } from '@metamask/kernel-utils';
import { GET_DESCRIPTION, makeDiscoverableExo } from '@metamask/kernel-utils';
import type {
DescribedInterface,
DiscoverableExo,
MethodSchema,
} from '@metamask/kernel-utils';

import type { CapabilityRecord, CapabilitySpec } from '../types.ts';

/**
* Discover the capabilities of a discoverable exo. Intended for use from inside a vat.
* This function fetches the schema from the discoverable exo and creates capabilities that can be used by kernel agents.
* Invoke a discoverable exo's method with positional arguments. The async
* variant ({@link discover}) sends over an eventual-send boundary; the local
* variant ({@link makeInternalCapabilities}) calls the in-realm exo directly.
* Either way the exo's interface guard enforces the argument shape.
*/
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;

/**
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
* capability's object arguments to positional arguments for the exo method.
*
* IMPORTANT: this relies on each `schema.args` having keys in the same order as
* the method's parameters. Schemas authored with the `described*()` combinators
* (`@metamask/kernel-utils`) satisfy this by construction, since their `args`
* record is built in declared positional order.
*
* @param description - The exo's method schemas, keyed by method name.
* @param invoke - How to invoke a method with positional arguments.
* @returns The capability record.
*/
const capabilitiesFrom = (
description: Record<string, MethodSchema>,
invoke: Invoke,
): CapabilityRecord =>
Object.fromEntries(
Object.entries(description).map(([name, schema]) => {
const argNames = Object.keys(schema.args);
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const func = async (args: Record<string, unknown>) =>
invoke(
name,
argNames.map((argName) => args[argName]),
);
return [name, { func, schema }] as [
string,
CapabilitySpec<never, unknown>,
];
}),
);

/**
* Discover the capabilities of a (possibly remote) discoverable exo. Fetches the
* schema over an eventual-send boundary and creates capabilities that invoke the
* exo's methods the same way.
*
* @param exo - The discoverable exo to convert to a capability record.
* @returns A promise for a capability record.
Expand All @@ -19,35 +65,49 @@ export const discover = async (
string,
MethodSchema
>;

const capabilities: CapabilityRecord = Object.fromEntries(
Object.entries(description).map(([name, schema]) => {
// Get argument names in order from the schema.
// IMPORTANT: This relies on the schema's args object having keys in the same
// order as the method's parameters. The schema must be defined with argument
// names matching the method parameter order (e.g., for method `add(a, b)`,
// the schema must have `args: { a: ..., b: ... }` in that order).
// JavaScript objects preserve insertion order for string keys, so Object.keys()
// will return keys in the order they were defined in the schema.
const argNames = Object.keys(schema.args);

// Create a capability function that accepts an args object
// and maps it to positional arguments for the exo method
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const func = async (args: Record<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 capabilitiesFrom(description, async (method, positionalArgs) =>
// @ts-expect-error - E type doesn't remember method names
E(exo)[method](...positionalArgs),
);
};

return capabilities;
/**
* Construct an in-realm capability record from a guard+schema description and
* the method implementations, building (and then keeping private) the
* pattern-guarded exo that enforces the argument shape on every call.
*
* Unlike {@link discover}, this never crosses an eventual-send boundary and
* never reads `GET_DESCRIPTION`: the schemas are the ones just authored with the
* `described*()` combinators (`@metamask/kernel-utils`), so there is no
* round-trip through the exo to recover what the caller already holds. The exo
* is used purely as the in-realm enforcement membrane and is not surfaced —
* internal capabilities are guarded closures, not passable exos. To expose a
* capability across a boundary, publish a {@link DiscoverableExo} and
* {@link discover} it instead.
*
* @param name - The exo/interface name.
* @param methods - The method implementations, keyed by method name.
* @param described - The interface guard and per-method schemas, e.g. from
* `S.interface(...)`.
* @returns A capability record keyed by the method names.
*/
export const makeInternalCapabilities = <Method extends string>(
name: string,
methods: Record<Method, (...args: never[]) => Promise<unknown>>,
described: DescribedInterface,
): CapabilityRecord<Method> => {
const { interfaceGuard, schemas } = described;
const exo = makeDiscoverableExo(
name,
methods as Record<string, (...args: unknown[]) => unknown>,
schemas,
interfaceGuard,
);
const dispatch = exo as unknown as Record<
string,
(...args: unknown[]) => unknown
>;
return capabilitiesFrom(schemas, (method, positionalArgs) =>
dispatch[method]?.(...positionalArgs),
) as CapabilityRecord<Method>;
};
59 changes: 33 additions & 26 deletions packages/kernel-agents/src/capabilities/end.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { S } from '@metamask/kernel-utils';

import { makeInternalCapabilities } from './discover.ts';
import { ifDefined } from '../utils.ts';
import { capability } from './capability.ts';

/**
* A factory function to make a task's `end` capability, which stores the first
Expand All @@ -10,36 +12,41 @@ import { capability } from './capability.ts';
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const makeEnd = <Result>() => {
// Captured, mutable state for the first final result. Intentionally NOT
// hardened: the exo method below closes over and mutates it.
const result: { final?: Result; attachments?: Record<string, unknown> } = {};
const end = capability(
async ({
final,
attachments,
}: {
final: Result;
attachments?: Record<string, unknown>;
}): Promise<void> => {
if (!Object.hasOwn(result, 'final')) {
Object.assign(result, { final, ...ifDefined({ attachments }) });
}
},

const { end } = makeInternalCapabilities(
'End',
{
description: 'Return a final response to the user.',
args: {
final: {
required: true,
type: 'string',
description:
'A concise final response that restates the requested information.',
},
attachments: {
required: false,
type: 'object',
description: 'Attachments to the final response.',
},
async end(
final: Result,
attachments?: Record<string, unknown>,
): Promise<void> {
if (!Object.hasOwn(result, 'final')) {
Object.assign(result, { final, ...ifDefined({ attachments }) });
}
},
},
S.interface('End', {
end: S.method(
'Return a final response to the user.',
[
S.arg(
'final',
S.string(
'A concise final response that restates the requested information.',
),
),
S.arg('attachments', S.record('Attachments to the final response.'), {
optional: true,
}),
],
S.nothing(),
),
}),
);

return [end, () => 'final' in result, () => result.final as Result] as const;
};

Expand Down
86 changes: 40 additions & 46 deletions packages/kernel-agents/src/capabilities/examples.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,12 @@
import { capability } from './capability.ts';
import { S } from '@metamask/kernel-utils';

import { makeInternalCapabilities } from './discover.ts';

type SearchResult = {
source: string;
published: string;
snippet: string;
};
export const search = capability(
async ({ query }: { query: string }): Promise<SearchResult[]> => [
{
source: 'https://www.google.com',
published: '2025-01-01',
snippet: `No information found for ${query}`,
},
],
{
description: 'Search the web for information.',
args: { query: { type: 'string', description: 'The query to search for' } },
returns: {
type: 'array',
items: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'The source of the information.',
},
published: {
type: 'string',
description: 'The date the information was published.',
},
snippet: {
type: 'string',
description: 'The snippet of information.',
},
},
},
},
},
);

const moonPhases = [
'new moon',
Expand All @@ -51,20 +20,45 @@ const moonPhases = [
] as const;
type MoonPhase = (typeof moonPhases)[number];

export const getMoonPhase = capability(
async (): Promise<MoonPhase> =>
moonPhases[Math.floor(Math.random() * moonPhases.length)] as MoonPhase,
const capabilities = makeInternalCapabilities(
'Examples',
{
description: 'Get the current phase of the moon.',
args: {},
returns: {
type: 'string',
// TODO: Add enum support to the capability schema
// @ts-expect-error - enum is not supported by the capability schema
enum: moonPhases,
description: 'The current phase of the moon.',
async search(query: string): Promise<SearchResult[]> {
return [
{
source: 'https://www.google.com',
published: '2025-01-01',
snippet: `No information found for ${query}`,
},
];
},
async getMoonPhase(): Promise<MoonPhase> {
return moonPhases[
Math.floor(Math.random() * moonPhases.length)
] as MoonPhase;
},
},
S.interface('Examples', {
search: S.method(
'Search the web for information.',
[S.arg('query', S.string('The query to search for'))],
S.arrayOf(
S.object({
source: S.string('The source of the information.'),
published: S.string('The date the information was published.'),
snippet: S.string('The snippet of information.'),
}),
),
),
// TODO: Add enum support to the capability schema so the moon phases can be
// advertised as the allowed return values.
getMoonPhase: S.method(
'Get the current phase of the moon.',
[],
S.string('The current phase of the moon.'),
),
}),
);

export const exampleCapabilities = { search, getMoonPhase };
export const { search, getMoonPhase } = capabilities;
export const exampleCapabilities = capabilities;
Loading
Loading