Skip to content

Commit 1812ca4

Browse files
grypezclaude
andcommitted
feat(kernel-agents): author built-in capabilities as pattern-guarded exos
Rewrite the `math`, `end`, and `examples` capabilities as discoverable exos built with the `described*()` combinators, so each capability's argument shape is enforced by the exo's interface guard at invocation rather than only advertised in the prompt. A mistyped argument now fails with a guard rejection at the membrane instead of surfacing deep inside the capability. Each module derives its `{ func, schema }` capability specs via a new synchronous `makeInternalCapabilities` constructor, which builds the pattern-guarded exo and projects a capability record from the just-authored schemas — without round-tripping through `GET_DESCRIPTION`. The exo is kept private as the in-realm enforcement membrane; internal capabilities are guarded closures, not passable exos (to cross a boundary, publish an exo and `discover` it). All existing consumers (example transcripts, e2e tests, the REPL evaluator, prepare-attempt) keep the same spec shape and `makeEnd` stays synchronous. `end`'s closed-over result object is intentionally left un-hardened so the exo method can mutate it. `getMoonPhase` loses its (already unsupported, `@ts-expect-error`'d) `enum` return hint; `end`'s off-spec per-argument `required` flags are gone, with `final` required and `attachments` optional expressed by the guard. Install the endoify mock as a package-wide vitest setup so capability modules, which now build exos at import, have a `harden` global before they load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6bc1fee commit 1812ca4

6 files changed

Lines changed: 213 additions & 140 deletions

File tree

packages/kernel-agents/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- 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))
13+
1014
[Unreleased]: https://github.com/MetaMask/ocap-kernel/
Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,58 @@
11
import { E } from '@endo/eventual-send';
2-
import { GET_DESCRIPTION } from '@metamask/kernel-utils';
3-
import type { DiscoverableExo, MethodSchema } from '@metamask/kernel-utils';
2+
import { GET_DESCRIPTION, makeDiscoverableExo } from '@metamask/kernel-utils';
3+
import type {
4+
DescribedInterface,
5+
DiscoverableExo,
6+
MethodSchema,
7+
} from '@metamask/kernel-utils';
48

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

711
/**
8-
* Discover the capabilities of a discoverable exo. Intended for use from inside a vat.
9-
* This function fetches the schema from the discoverable exo and creates capabilities that can be used by kernel agents.
12+
* Invoke a discoverable exo's method with positional arguments. The async
13+
* variant ({@link discover}) sends over an eventual-send boundary; the local
14+
* variant ({@link makeInternalCapabilities}) calls the in-realm exo directly.
15+
* Either way the exo's interface guard enforces the argument shape.
16+
*/
17+
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;
18+
19+
/**
20+
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
21+
* capability's object arguments to positional arguments for the exo method.
22+
*
23+
* IMPORTANT: this relies on each `schema.args` having keys in the same order as
24+
* the method's parameters. Schemas authored with the `described*()` combinators
25+
* (`@metamask/kernel-utils`) satisfy this by construction, since their `args`
26+
* record is built in declared positional order.
27+
*
28+
* @param description - The exo's method schemas, keyed by method name.
29+
* @param invoke - How to invoke a method with positional arguments.
30+
* @returns The capability record.
31+
*/
32+
const capabilitiesFrom = (
33+
description: Record<string, MethodSchema>,
34+
invoke: Invoke,
35+
): CapabilityRecord =>
36+
Object.fromEntries(
37+
Object.entries(description).map(([name, schema]) => {
38+
const argNames = Object.keys(schema.args);
39+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
40+
const func = async (args: Record<string, unknown>) =>
41+
invoke(
42+
name,
43+
argNames.map((argName) => args[argName]),
44+
);
45+
return [name, { func, schema }] as [
46+
string,
47+
CapabilitySpec<never, unknown>,
48+
];
49+
}),
50+
);
51+
52+
/**
53+
* Discover the capabilities of a (possibly remote) discoverable exo. Fetches the
54+
* schema over an eventual-send boundary and creates capabilities that invoke the
55+
* exo's methods the same way.
1056
*
1157
* @param exo - The discoverable exo to convert to a capability record.
1258
* @returns A promise for a capability record.
@@ -19,35 +65,49 @@ export const discover = async (
1965
string,
2066
MethodSchema
2167
>;
22-
23-
const capabilities: CapabilityRecord = Object.fromEntries(
24-
Object.entries(description).map(([name, schema]) => {
25-
// Get argument names in order from the schema.
26-
// IMPORTANT: This relies on the schema's args object having keys in the same
27-
// order as the method's parameters. The schema must be defined with argument
28-
// names matching the method parameter order (e.g., for method `add(a, b)`,
29-
// the schema must have `args: { a: ..., b: ... }` in that order).
30-
// JavaScript objects preserve insertion order for string keys, so Object.keys()
31-
// will return keys in the order they were defined in the schema.
32-
const argNames = Object.keys(schema.args);
33-
34-
// Create a capability function that accepts an args object
35-
// and maps it to positional arguments for the exo method
36-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
37-
const func = async (args: Record<string, unknown>) => {
38-
// Map object arguments to positional arguments in schema order.
39-
// The order of argNames matches the method parameter order by convention.
40-
const positionalArgs = argNames.map((argName) => args[argName]);
41-
// @ts-expect-error - E type doesn't remember method names
42-
return E(exo)[name](...positionalArgs);
43-
};
44-
45-
return [name, { func, schema }] as [
46-
string,
47-
CapabilitySpec<never, unknown>,
48-
];
49-
}),
68+
return capabilitiesFrom(description, async (method, positionalArgs) =>
69+
// @ts-expect-error - E type doesn't remember method names
70+
E(exo)[method](...positionalArgs),
5071
);
72+
};
5173

52-
return capabilities;
74+
/**
75+
* Construct an in-realm capability record from a guard+schema description and
76+
* the method implementations, building (and then keeping private) the
77+
* pattern-guarded exo that enforces the argument shape on every call.
78+
*
79+
* Unlike {@link discover}, this never crosses an eventual-send boundary and
80+
* never reads `GET_DESCRIPTION`: the schemas are the ones just authored with the
81+
* `described*()` combinators (`@metamask/kernel-utils`), so there is no
82+
* round-trip through the exo to recover what the caller already holds. The exo
83+
* is used purely as the in-realm enforcement membrane and is not surfaced —
84+
* internal capabilities are guarded closures, not passable exos. To expose a
85+
* capability across a boundary, publish a {@link DiscoverableExo} and
86+
* {@link discover} it instead.
87+
*
88+
* @param name - The exo/interface name.
89+
* @param methods - The method implementations, keyed by method name.
90+
* @param described - The interface guard and per-method schemas, e.g. from
91+
* `S.interface(...)`.
92+
* @returns A capability record keyed by the method names.
93+
*/
94+
export const makeInternalCapabilities = <Method extends string>(
95+
name: string,
96+
methods: Record<Method, (...args: never[]) => Promise<unknown>>,
97+
described: DescribedInterface,
98+
): CapabilityRecord<Method> => {
99+
const { interfaceGuard, schemas } = described;
100+
const exo = makeDiscoverableExo(
101+
name,
102+
methods as Record<string, (...args: unknown[]) => unknown>,
103+
schemas,
104+
interfaceGuard,
105+
);
106+
const dispatch = exo as unknown as Record<
107+
string,
108+
(...args: unknown[]) => unknown
109+
>;
110+
return capabilitiesFrom(schemas, (method, positionalArgs) =>
111+
dispatch[method]?.(...positionalArgs),
112+
) as CapabilityRecord<Method>;
53113
};

packages/kernel-agents/src/capabilities/end.ts

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { S } from '@metamask/kernel-utils';
2+
3+
import { makeInternalCapabilities } from './discover.ts';
14
import { ifDefined } from '../utils.ts';
2-
import { capability } from './capability.ts';
35

46
/**
57
* A factory function to make a task's `end` capability, which stores the first
@@ -10,36 +12,41 @@ import { capability } from './capability.ts';
1012
*/
1113
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
1214
export const makeEnd = <Result>() => {
15+
// Captured, mutable state for the first final result. Intentionally NOT
16+
// hardened: the exo method below closes over and mutates it.
1317
const result: { final?: Result; attachments?: Record<string, unknown> } = {};
14-
const end = capability(
15-
async ({
16-
final,
17-
attachments,
18-
}: {
19-
final: Result;
20-
attachments?: Record<string, unknown>;
21-
}): Promise<void> => {
22-
if (!Object.hasOwn(result, 'final')) {
23-
Object.assign(result, { final, ...ifDefined({ attachments }) });
24-
}
25-
},
18+
19+
const { end } = makeInternalCapabilities(
20+
'End',
2621
{
27-
description: 'Return a final response to the user.',
28-
args: {
29-
final: {
30-
required: true,
31-
type: 'string',
32-
description:
33-
'A concise final response that restates the requested information.',
34-
},
35-
attachments: {
36-
required: false,
37-
type: 'object',
38-
description: 'Attachments to the final response.',
39-
},
22+
async end(
23+
final: Result,
24+
attachments?: Record<string, unknown>,
25+
): Promise<void> {
26+
if (!Object.hasOwn(result, 'final')) {
27+
Object.assign(result, { final, ...ifDefined({ attachments }) });
28+
}
4029
},
4130
},
31+
S.interface('End', {
32+
end: S.method(
33+
'Return a final response to the user.',
34+
[
35+
S.arg(
36+
'final',
37+
S.string(
38+
'A concise final response that restates the requested information.',
39+
),
40+
),
41+
S.arg('attachments', S.record('Attachments to the final response.'), {
42+
optional: true,
43+
}),
44+
],
45+
S.nothing(),
46+
),
47+
}),
4248
);
49+
4350
return [end, () => 'final' in result, () => result.final as Result] as const;
4451
};
4552

Lines changed: 40 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,12 @@
1-
import { capability } from './capability.ts';
1+
import { S } from '@metamask/kernel-utils';
2+
3+
import { makeInternalCapabilities } from './discover.ts';
24

35
type SearchResult = {
46
source: string;
57
published: string;
68
snippet: string;
79
};
8-
export const search = capability(
9-
async ({ query }: { query: string }): Promise<SearchResult[]> => [
10-
{
11-
source: 'https://www.google.com',
12-
published: '2025-01-01',
13-
snippet: `No information found for ${query}`,
14-
},
15-
],
16-
{
17-
description: 'Search the web for information.',
18-
args: { query: { type: 'string', description: 'The query to search for' } },
19-
returns: {
20-
type: 'array',
21-
items: {
22-
type: 'object',
23-
properties: {
24-
source: {
25-
type: 'string',
26-
description: 'The source of the information.',
27-
},
28-
published: {
29-
type: 'string',
30-
description: 'The date the information was published.',
31-
},
32-
snippet: {
33-
type: 'string',
34-
description: 'The snippet of information.',
35-
},
36-
},
37-
},
38-
},
39-
},
40-
);
4110

4211
const moonPhases = [
4312
'new moon',
@@ -51,20 +20,45 @@ const moonPhases = [
5120
] as const;
5221
type MoonPhase = (typeof moonPhases)[number];
5322

54-
export const getMoonPhase = capability(
55-
async (): Promise<MoonPhase> =>
56-
moonPhases[Math.floor(Math.random() * moonPhases.length)] as MoonPhase,
23+
const capabilities = makeInternalCapabilities(
24+
'Examples',
5725
{
58-
description: 'Get the current phase of the moon.',
59-
args: {},
60-
returns: {
61-
type: 'string',
62-
// TODO: Add enum support to the capability schema
63-
// @ts-expect-error - enum is not supported by the capability schema
64-
enum: moonPhases,
65-
description: 'The current phase of the moon.',
26+
async search(query: string): Promise<SearchResult[]> {
27+
return [
28+
{
29+
source: 'https://www.google.com',
30+
published: '2025-01-01',
31+
snippet: `No information found for ${query}`,
32+
},
33+
];
34+
},
35+
async getMoonPhase(): Promise<MoonPhase> {
36+
return moonPhases[
37+
Math.floor(Math.random() * moonPhases.length)
38+
] as MoonPhase;
6639
},
6740
},
41+
S.interface('Examples', {
42+
search: S.method(
43+
'Search the web for information.',
44+
[S.arg('query', S.string('The query to search for'))],
45+
S.arrayOf(
46+
S.object({
47+
source: S.string('The source of the information.'),
48+
published: S.string('The date the information was published.'),
49+
snippet: S.string('The snippet of information.'),
50+
}),
51+
),
52+
),
53+
// TODO: Add enum support to the capability schema so the moon phases can be
54+
// advertised as the allowed return values.
55+
getMoonPhase: S.method(
56+
'Get the current phase of the moon.',
57+
[],
58+
S.string('The current phase of the moon.'),
59+
),
60+
}),
6861
);
6962

70-
export const exampleCapabilities = { search, getMoonPhase };
63+
export const { search, getMoonPhase } = capabilities;
64+
export const exampleCapabilities = capabilities;

0 commit comments

Comments
 (0)