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
9 changes: 9 additions & 0 deletions packages/kernel-agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ 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))
- A capability's arguments are now validated solely by its exo's interface guard (the membrane); the chat strategy no longer re-validates arguments before invoking a capability ([#960](https://github.com/MetaMask/ocap-kernel/pull/960))

### Removed

- **BREAKING:** Remove the `capability()` authoring helper and the `validateCapabilityArgs` validator. Capabilities are authored as pattern-guarded discoverable exos (via the `described*()` combinators in `@metamask/kernel-utils`) and discovered into capability records, so there is no membraneless authoring path and the membrane is the sole argument enforcer ([#960](https://github.com/MetaMask/ocap-kernel/pull/960))

[Unreleased]: https://github.com/MetaMask/ocap-kernel/
1 change: 0 additions & 1 deletion packages/kernel-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@
"@metamask/kernel-errors": "workspace:^",
"@metamask/kernel-utils": "workspace:^",
"@metamask/logger": "workspace:^",
"@metamask/superstruct": "^3.2.1",
"@ocap/kernel-language-model-service": "workspace:^",
"partial-json": "^0.1.7",
"ses": "^1.14.0"
Expand Down
34 changes: 24 additions & 10 deletions packages/kernel-agents/src/capabilities/capability.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { S } from '@metamask/kernel-utils';
import { describe, it, expect } from 'vitest';

import { capability } from './capability.ts';
import { extractCapabilities, extractCapabilitySchemas } from './capability.ts';
import { makeCapability } from '../../test/make-capability.ts';

describe('capability', () => {
it('creates a capability with func and schema', () => {
const testCapability = capability(async () => Promise.resolve('test'), {
description: 'a test capability',
args: {},
});
expect(testCapability.func).toBeInstanceOf(Function);
expect(testCapability.schema).toStrictEqual({
description: 'a test capability',
describe('capability extraction', () => {
const makeRecord = () => ({
ping: makeCapability(
'Server',
'ping',
async () => 'pong',
S.method('Ping', [], S.string()),
),
});

it('extractCapabilities returns the functions keyed by name', async () => {
const funcs = extractCapabilities(makeRecord());
expect(Object.keys(funcs)).toStrictEqual(['ping']);
expect(await funcs.ping(undefined as never)).toBe('pong');
});

it('extractCapabilitySchemas returns the schemas keyed by name', () => {
const schemas = extractCapabilitySchemas(makeRecord());
expect(schemas.ping).toStrictEqual({
description: 'Ping',
args: {},
returns: { type: 'string' },
});
});
});
22 changes: 3 additions & 19 deletions packages/kernel-agents/src/capabilities/capability.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
import type { ExtractRecordKeys } from '../types/capability.ts';
import type {
CapabilityRecord,
CapabilitySpec,
CapabilitySchema,
Capability,
} from '../types.ts';
import type { MethodSchema } from '@metamask/kernel-utils';

/**
* Create a capability specification.
*
* @param func - The function to create a capability specification for
* @param schema - The schema for the capability
* @returns A capability specification
*/
export const capability = <Args extends Record<string, unknown>, Return = null>(
func: Capability<Args, Return>,
schema: CapabilitySchema<ExtractRecordKeys<Args>>,
): CapabilitySpec<Args, Return> => ({ func, schema });
import type { CapabilityRecord, CapabilitySpec } from '../types.ts';

type SchemaEntry = [string, { schema: CapabilitySchema<string> }];
type SchemaEntry = [string, { schema: MethodSchema }];
/**
* Extract only the serializable schemas from the capabilities
*
Expand Down
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
Loading