Skip to content

Commit 99b92e4

Browse files
grypezclaude
andcommitted
refactor(kernel-agents): make the exo membrane the sole capability arg enforcer
Now that every capability is a pattern-guarded discoverable exo, retire the membraneless authoring and validation paths: - Remove the `capability()` authoring helper and the `validateCapabilityArgs` validator (and its now-dead module). A capability's arguments are enforced only by its exo's interface guard; the chat strategy no longer re-validates before invoking, relying on the guard rejection it already catches. - Add a `test/make-capability.ts` helper that builds a guarded, discovered capability from the `described*()` combinators, and migrate the chat and JSON evaluator tests (and the capability test, repurposed to cover the surviving `extract*` helpers) onto it. - Drop the unused `@metamask/superstruct` dependency. BREAKING: `capability` and `validateCapabilityArgs` are no longer exported. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6c6abee commit 99b92e4

11 files changed

Lines changed: 112 additions & 134 deletions

File tree

packages/kernel-agents/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Changed
1515

1616
- 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+
- 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
18+
19+
### Removed
20+
21+
- **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
1722

1823
[Unreleased]: https://github.com/MetaMask/ocap-kernel/

packages/kernel-agents/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@
196196
"@metamask/kernel-errors": "workspace:^",
197197
"@metamask/kernel-utils": "workspace:^",
198198
"@metamask/logger": "workspace:^",
199-
"@metamask/superstruct": "^3.2.1",
200199
"@ocap/kernel-language-model-service": "workspace:^",
201200
"partial-json": "^0.1.7",
202201
"ses": "^1.14.0"
Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1+
import { S } from '@metamask/kernel-utils';
12
import { describe, it, expect } from 'vitest';
23

3-
import { capability } from './capability.ts';
4+
import { extractCapabilities, extractCapabilitySchemas } from './capability.ts';
5+
import { makeCapability } from '../../test/make-capability.ts';
46

5-
describe('capability', () => {
6-
it('creates a capability with func and schema', () => {
7-
const testCapability = capability(async () => Promise.resolve('test'), {
8-
description: 'a test capability',
9-
args: {},
10-
});
11-
expect(testCapability.func).toBeInstanceOf(Function);
12-
expect(testCapability.schema).toStrictEqual({
13-
description: 'a test capability',
7+
describe('capability extraction', () => {
8+
const makeRecord = () => ({
9+
ping: makeCapability(
10+
'Server',
11+
'ping',
12+
async () => 'pong',
13+
S.method('Ping', [], S.string()),
14+
),
15+
});
16+
17+
it('extractCapabilities returns the functions keyed by name', async () => {
18+
const funcs = extractCapabilities(makeRecord());
19+
expect(Object.keys(funcs)).toStrictEqual(['ping']);
20+
expect(await funcs.ping(undefined as never)).toBe('pong');
21+
});
22+
23+
it('extractCapabilitySchemas returns the schemas keyed by name', () => {
24+
const schemas = extractCapabilitySchemas(makeRecord());
25+
expect(schemas.ping).toStrictEqual({
26+
description: 'Ping',
1427
args: {},
28+
returns: { type: 'string' },
1529
});
1630
});
1731
});

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

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
1-
import type { ExtractRecordKeys } from '../types/capability.ts';
21
import type {
32
CapabilityRecord,
43
CapabilitySpec,
54
CapabilitySchema,
6-
Capability,
75
} from '../types.ts';
86

9-
/**
10-
* Create a capability specification.
11-
*
12-
* @param func - The function to create a capability specification for
13-
* @param schema - The schema for the capability
14-
* @returns A capability specification
15-
*/
16-
export const capability = <Args extends Record<string, unknown>, Return = null>(
17-
func: Capability<Args, Return>,
18-
schema: CapabilitySchema<ExtractRecordKeys<Args>>,
19-
): CapabilitySpec<Args, Return> => ({ func, schema });
20-
217
type SchemaEntry = [string, { schema: CapabilitySchema<string> }];
228
/**
239
* Extract only the serializable schemas from the capabilities

packages/kernel-agents/src/capabilities/validate-capability-args.test.ts

Lines changed: 0 additions & 59 deletions
This file was deleted.

packages/kernel-agents/src/capabilities/validate-capability-args.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

packages/kernel-agents/src/strategies/chat-agent.test.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import '@ocap/repo-tools/test-utils/mock-endoify';
22

3+
import { S } from '@metamask/kernel-utils';
34
import type {
45
ChatMessage,
56
ChatResult,
@@ -9,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest';
910

1011
import { makeChatAgent } from './chat-agent.ts';
1112
import type { BoundChat } from './chat-agent.ts';
12-
import { capability } from '../capabilities/capability.ts';
13+
import { makeCapability } from '../../test/make-capability.ts';
1314

1415
const makeToolCall = (
1516
id: string,
@@ -62,15 +63,17 @@ describe('makeChatAgent', () => {
6263
});
6364

6465
it('dispatches a tool call and returns final text answer', async () => {
65-
const add = vi.fn(async ({ a, b }: { a: number; b: number }) => a + b);
66-
const addCap = capability(add, {
67-
description: 'Add two numbers',
68-
args: {
69-
a: { type: 'number' },
70-
b: { type: 'number' },
71-
},
72-
returns: { type: 'number' },
73-
});
66+
const add = vi.fn(async (a: number, b: number) => a + b);
67+
const addCap = makeCapability(
68+
'Math',
69+
'add',
70+
add,
71+
S.method(
72+
'Add two numbers',
73+
[S.arg('a', S.number()), S.arg('b', S.number())],
74+
S.number(),
75+
),
76+
);
7477

7578
let call = 0;
7679
const chat: BoundChat = async () => {
@@ -86,17 +89,18 @@ describe('makeChatAgent', () => {
8689
const agent = makeChatAgent({ chat, capabilities: { add: addCap } });
8790

8891
const result = await agent.task('add 3 and 4');
89-
expect(add).toHaveBeenCalledWith({ a: 3, b: 4 });
92+
expect(add).toHaveBeenCalledWith(3, 4);
9093
expect(result).toBe('7');
9194
});
9295

9396
it('injects tool result message before next turn', async () => {
9497
const recorded: ChatMessage[][] = [];
95-
const ping = capability(async () => 'pong', {
96-
description: 'Ping',
97-
args: {},
98-
returns: { type: 'string' },
99-
});
98+
const ping = makeCapability(
99+
'Server',
100+
'ping',
101+
async () => 'pong',
102+
S.method('Ping', [], S.string()),
103+
);
100104

101105
let call = 0;
102106
const chat: BoundChat = async ({ messages }) => {
@@ -152,10 +156,12 @@ describe('makeChatAgent', () => {
152156
});
153157

154158
it('throws when invocation budget is exceeded', async () => {
155-
const ping = capability(async () => 'pong', {
156-
description: 'Ping',
157-
args: {},
158-
});
159+
const ping = makeCapability(
160+
'Server',
161+
'ping',
162+
async () => 'pong',
163+
S.method('Ping', [], S.string()),
164+
);
159165
const chat: BoundChat = async () =>
160166
makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]);
161167

@@ -177,11 +183,12 @@ describe('makeChatAgent', () => {
177183

178184
it('passes tools to the chat function', async () => {
179185
const recordedTools: unknown[] = [];
180-
const ping = capability(async () => 'pong', {
181-
description: 'Ping the server',
182-
args: {},
183-
returns: { type: 'string' },
184-
});
186+
const ping = makeCapability(
187+
'Server',
188+
'ping',
189+
async () => 'pong',
190+
S.method('Ping the server', [], S.string()),
191+
);
185192

186193
const chat: BoundChat = async ({ tools }) => {
187194
recordedTools.push(tools);

packages/kernel-agents/src/strategies/chat-agent.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {
77
import { parseToolArguments } from '@ocap/kernel-language-model-service/utils/parse-tool-arguments';
88

99
import { extractCapabilitySchemas } from '../capabilities/capability.ts';
10-
import { validateCapabilityArgs } from '../capabilities/validate-capability-args.ts';
1110
import type { Agent } from '../types/agent.ts';
1211
import { Message } from '../types/messages.ts';
1312
import type { CapabilityRecord, Experience } from '../types.ts';
@@ -178,7 +177,9 @@ export const makeChatAgent = ({
178177
let toolResult: unknown;
179178
try {
180179
const args = parseToolArguments(argsJson);
181-
validateCapabilityArgs(args, spec.schema);
180+
// The capability is backed by a pattern-guarded exo, so its
181+
// interface guard enforces the argument shape; a mismatch rejects
182+
// here and is reported as the tool error below.
182183
toolResult = await spec.func(args as never);
183184
} catch (error) {
184185
const errorContent = `Error calling ${name}: ${(error as Error).message}`;

packages/kernel-agents/src/strategies/json/evaluator.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import { S } from '@metamask/kernel-utils';
12
import { describe, it, expect } from 'vitest';
23

34
import { makeEvaluator } from './evaluator.ts';
45
import { AssistantMessage, CapabilityResultMessage } from './messages.ts';
5-
import { capability } from '../../capabilities/capability.ts';
6+
import { makeCapability } from '../../../test/make-capability.ts';
67

78
describe('invokeCapabilities', () => {
89
it("invokes the assistant's chosen capability", async () => {
9-
const testCapability = capability(async () => Promise.resolve('test'), {
10-
description: 'a test capability',
11-
args: {},
12-
});
10+
const testCapability = makeCapability(
11+
'Test',
12+
'testCapability',
13+
async () => Promise.resolve('test'),
14+
S.method('a test capability', [], S.string()),
15+
);
1316
const evaluator = makeEvaluator({ capabilities: { testCapability } });
1417
const result = await evaluator(
1518
[],
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { S, makeDiscoverableExo } from '@metamask/kernel-utils';
2+
import type { DescribedMethod } from '@metamask/kernel-utils';
3+
4+
import { discoverLocal } from '../src/capabilities/discover.ts';
5+
import type { CapabilitySpec } from '../src/types.ts';
6+
7+
/**
8+
* Build a single capability backed by a pattern-guarded discoverable exo, for
9+
* tests that need an ad-hoc capability. Mirrors how the built-in capabilities
10+
* are authored, so the exo's interface guard enforces the method's argument
11+
* shape — there is no membraneless authoring path.
12+
*
13+
* @param name - The exo/interface name.
14+
* @param method - The method (and capability) name.
15+
* @param impl - The method implementation (positional arguments).
16+
* @param described - The method's guard and schema (use the `described*()`
17+
* combinators from `@metamask/kernel-utils`).
18+
* @returns The capability spec.
19+
*/
20+
export const makeCapability = (
21+
name: string,
22+
method: string,
23+
impl: (...args: never[]) => unknown,
24+
described: DescribedMethod,
25+
): CapabilitySpec<never, unknown> => {
26+
const { interfaceGuard, schemas } = S.interface(name, {
27+
[method]: described,
28+
});
29+
const exo = makeDiscoverableExo(
30+
name,
31+
{ [method]: impl } as Record<string, (...args: unknown[]) => unknown>,
32+
schemas,
33+
interfaceGuard,
34+
);
35+
const record = discoverLocal(exo) as Record<
36+
string,
37+
CapabilitySpec<never, unknown>
38+
>;
39+
return record[method] as CapabilitySpec<never, unknown>;
40+
};

0 commit comments

Comments
 (0)