Skip to content

Commit 6c6abee

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 from the exo via a new synchronous `discoverLocal`, so 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 b8ac175 commit 6c6abee

6 files changed

Lines changed: 218 additions & 138 deletions

File tree

packages/kernel-agents/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `discoverLocal`, a synchronous variant of `discover` for converting a local, in-realm discoverable exo into a capability record without an `await`
13+
14+
### Changed
15+
16+
- 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
17+
1018
[Unreleased]: https://github.com/MetaMask/ocap-kernel/

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

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,50 @@ import type { DiscoverableExo, MethodSchema } from '@metamask/kernel-utils';
55
import type { CapabilityRecord, CapabilitySpec } from '../types.ts';
66

77
/**
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.
8+
* Invoke a discoverable exo's method with positional arguments. The async
9+
* variant ({@link discover}) sends over an eventual-send boundary; the local
10+
* variant ({@link discoverLocal}) calls the in-realm exo directly. Either way
11+
* the exo's interface guard enforces the argument shape.
12+
*/
13+
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;
14+
15+
/**
16+
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
17+
* capability's object arguments to positional arguments for the exo method.
18+
*
19+
* IMPORTANT: this relies on each `schema.args` having keys in the same order as
20+
* the method's parameters. Schemas authored with the `described*()` combinators
21+
* (`@metamask/kernel-utils`) satisfy this by construction, since their `args`
22+
* record is built in declared positional order.
23+
*
24+
* @param description - The exo's method schemas, keyed by method name.
25+
* @param invoke - How to invoke a method with positional arguments.
26+
* @returns The capability record.
27+
*/
28+
const capabilitiesFrom = (
29+
description: Record<string, MethodSchema>,
30+
invoke: Invoke,
31+
): CapabilityRecord =>
32+
Object.fromEntries(
33+
Object.entries(description).map(([name, schema]) => {
34+
const argNames = Object.keys(schema.args);
35+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
36+
const func = async (args: Record<string, unknown>) =>
37+
invoke(
38+
name,
39+
argNames.map((argName) => args[argName]),
40+
);
41+
return [name, { func, schema }] as [
42+
string,
43+
CapabilitySpec<never, unknown>,
44+
];
45+
}),
46+
);
47+
48+
/**
49+
* Discover the capabilities of a (possibly remote) discoverable exo. Fetches the
50+
* schema over an eventual-send boundary and creates capabilities that invoke the
51+
* exo's methods the same way.
1052
*
1153
* @param exo - The discoverable exo to convert to a capability record.
1254
* @returns A promise for a capability record.
@@ -19,35 +61,29 @@ export const discover = async (
1961
string,
2062
MethodSchema
2163
>;
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-
}),
64+
return capabilitiesFrom(description, async (method, positionalArgs) =>
65+
// @ts-expect-error - E type doesn't remember method names
66+
E(exo)[method](...positionalArgs),
5067
);
68+
};
5169

52-
return capabilities;
70+
/**
71+
* Discover the capabilities of a local, in-realm discoverable exo synchronously.
72+
* Used by capability modules that build their own exo at load time and want to
73+
* expose it as a capability record without an `await`. The exo still enforces
74+
* its interface guard on every (direct) call.
75+
*
76+
* @param exo - The local discoverable exo to convert to a capability record.
77+
* @returns A capability record.
78+
*/
79+
export const discoverLocal = <Exo extends object>(
80+
exo: Exo,
81+
): CapabilityRecord => {
82+
const methods = exo as unknown as {
83+
[GET_DESCRIPTION]: () => Record<string, MethodSchema>;
84+
} & Record<string, ((...args: unknown[]) => unknown) | undefined>;
85+
const description = methods[GET_DESCRIPTION]();
86+
return capabilitiesFrom(description, (method, positionalArgs) =>
87+
methods[method]?.(...positionalArgs),
88+
);
5389
};

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

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { S, makeDiscoverableExo } from '@metamask/kernel-utils';
2+
3+
import { discoverLocal } from './discover.ts';
4+
import type { CapabilitySpec } from '../types.ts';
15
import { ifDefined } from '../utils.ts';
2-
import { capability } from './capability.ts';
36

47
/**
58
* A factory function to make a task's `end` capability, which stores the first
@@ -10,36 +13,49 @@ import { capability } from './capability.ts';
1013
*/
1114
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
1215
export const makeEnd = <Result>() => {
16+
// Captured, mutable state for the first final result. Intentionally NOT
17+
// hardened: the exo method below closes over and mutates it.
1318
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-
},
26-
{
27-
description: 'Return a final response to the user.',
28-
args: {
29-
final: {
30-
required: true,
31-
type: 'string',
32-
description:
19+
20+
const { interfaceGuard, schemas } = S.interface('End', {
21+
end: S.method(
22+
'Return a final response to the user.',
23+
[
24+
S.arg(
25+
'final',
26+
S.string(
3327
'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-
},
28+
),
29+
),
30+
S.arg('attachments', S.record('Attachments to the final response.'), {
31+
optional: true,
32+
}),
33+
],
34+
S.nothing(),
35+
),
36+
});
37+
38+
const exo = makeDiscoverableExo(
39+
'End',
40+
{
41+
async end(
42+
final: Result,
43+
attachments?: Record<string, unknown>,
44+
): Promise<void> {
45+
if (!Object.hasOwn(result, 'final')) {
46+
Object.assign(result, { final, ...ifDefined({ attachments }) });
47+
}
4048
},
4149
},
50+
schemas,
51+
interfaceGuard,
4252
);
53+
54+
const { end } = discoverLocal(exo) as Record<
55+
'end',
56+
CapabilitySpec<never, unknown>
57+
>;
58+
4359
return [end, () => 'final' in result, () => result.final as Result] as const;
4460
};
4561

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,13 @@
1-
import { capability } from './capability.ts';
1+
import { S, makeDiscoverableExo } from '@metamask/kernel-utils';
2+
3+
import { discoverLocal } from './discover.ts';
4+
import type { CapabilitySpec } from '../types.ts';
25

36
type SearchResult = {
47
source: string;
58
published: string;
69
snippet: string;
710
};
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-
);
4111

4212
const moonPhases = [
4313
'new moon',
@@ -51,20 +21,53 @@ const moonPhases = [
5121
] as const;
5222
type MoonPhase = (typeof moonPhases)[number];
5323

54-
export const getMoonPhase = capability(
55-
async (): Promise<MoonPhase> =>
56-
moonPhases[Math.floor(Math.random() * moonPhases.length)] as MoonPhase,
24+
const { interfaceGuard, schemas } = S.interface('Examples', {
25+
search: S.method(
26+
'Search the web for information.',
27+
[S.arg('query', S.string('The query to search for'))],
28+
S.arrayOf(
29+
S.object({
30+
source: S.string('The source of the information.'),
31+
published: S.string('The date the information was published.'),
32+
snippet: S.string('The snippet of information.'),
33+
}),
34+
),
35+
),
36+
// TODO: Add enum support to the capability schema so the moon phases can be
37+
// advertised as the allowed return values.
38+
getMoonPhase: S.method(
39+
'Get the current phase of the moon.',
40+
[],
41+
S.string('The current phase of the moon.'),
42+
),
43+
});
44+
45+
const examplesExo = makeDiscoverableExo(
46+
'Examples',
5747
{
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.',
48+
async search(query: string): Promise<SearchResult[]> {
49+
return [
50+
{
51+
source: 'https://www.google.com',
52+
published: '2025-01-01',
53+
snippet: `No information found for ${query}`,
54+
},
55+
];
56+
},
57+
async getMoonPhase(): Promise<MoonPhase> {
58+
return moonPhases[
59+
Math.floor(Math.random() * moonPhases.length)
60+
] as MoonPhase;
6661
},
6762
},
63+
schemas,
64+
interfaceGuard,
6865
);
6966

70-
export const exampleCapabilities = { search, getMoonPhase };
67+
const capabilities = discoverLocal(examplesExo) as Record<
68+
'search' | 'getMoonPhase',
69+
CapabilitySpec<never, unknown>
70+
>;
71+
72+
export const { search, getMoonPhase } = capabilities;
73+
export const exampleCapabilities = capabilities;

0 commit comments

Comments
 (0)