Skip to content
Merged
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 @@ -11,4 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- 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))

### Removed

- **BREAKING:** Remove the `capability()` authoring helper from `@ocap/kernel-agents/capabilities/capability`. Author capabilities as pattern-guarded discoverable exos (via the `described*()` combinators in `@metamask/kernel-utils`) and convert them with `discover`, so the exo's interface guard 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
35 changes: 25 additions & 10 deletions packages/kernel-agents/src/capabilities/capability.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { S } from '@metamask/kernel-utils';
import { describe, it, expect } from 'vitest';

import { capability } from './capability.ts';
import { extractCapabilities, extractCapabilitySchemas } from './capability.ts';
import { makeMethodCapability } from '../../test/make-method-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: makeMethodCapability(
'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: {},
required: [],
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
18 changes: 18 additions & 0 deletions packages/kernel-agents/src/capabilities/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ describe('makeInternalCapabilities', () => {
expect(await rejects(capabilities.add.func({} as never))).toBe(true);
});

it('normalizes the rejection into a real error naming the expected signature', async () => {
// Whatever the guard rejects with (an opaque value under the test shim), the
// membrane rethrows a real `Error` carrying the method signature so callers
// can surface an actionable message to the model.
let caught: unknown;
try {
await capabilities.count.func({ word: 12345 } as never);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(Error);
expect(
(caught as Error).message.startsWith(
'Error calling count(word: string): ',
),
).toBe(true);
});

it('throws at construction when an implementation has no matching schema', () => {
expect(() =>
makeInternalCapabilities(
Expand Down
41 changes: 35 additions & 6 deletions packages/kernel-agents/src/capabilities/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ import type { CapabilityRecord, CapabilitySpec } from '../types.ts';
*/
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;

/**
* Render a method's expected call signature from its schema — e.g.
* `add(a: number, b: number)` — for use in invocation-error messages.
*
* @param name - The method name.
* @param schema - The method schema whose `args` describe the parameters.
* @returns The formatted signature.
*/
const formatSignature = (name: string, schema: MethodSchema): string => {
const params = Object.entries(schema.args)
.map(([arg, argSchema]) => `${arg}: ${argSchema.type}`)
.join(', ');
return `${name}(${params})`;
};

/**
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
* capability's object arguments to positional arguments for the exo method.
Expand All @@ -36,12 +51,26 @@ const capabilitiesFrom = (
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]),
);
const func = async (args: Record<string, unknown>): Promise<unknown> => {
try {
return await invoke(
name,
argNames.map((argName) => args[argName]),
);
} catch (error) {
// The exo's interface guard is the sole argument enforcer, so a shape
// mismatch rejects here before the implementation runs — but that is
// indistinguishable from an error thrown by the implementation, so
// the signature is reported as context, not a diagnosed cause.
// Wrapping also guarantees a real `Error` even when the guard rejects
// with an opaque value (e.g. under the test shim), so every caller
// gets the method signature to surface to the model.
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Error calling ${formatSignature(name, schema)}: ${detail}`,
);
}
};
return [name, { func, schema }] as [
string,
CapabilitySpec<never, unknown>,
Expand Down

This file was deleted.

This file was deleted.

104 changes: 79 additions & 25 deletions packages/kernel-agents/src/strategies/chat-agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@ocap/repo-tools/test-utils/mock-endoify';

import { S } from '@metamask/kernel-utils';
import type {
ChatMessage,
ChatResult,
Expand All @@ -9,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest';

import { makeChatAgent } from './chat-agent.ts';
import type { BoundChat } from './chat-agent.ts';
import { capability } from '../capabilities/capability.ts';
import { makeMethodCapability } from '../../test/make-method-capability.ts';

const makeToolCall = (
id: string,
Expand Down Expand Up @@ -62,15 +63,17 @@ describe('makeChatAgent', () => {
});

it('dispatches a tool call and returns final text answer', async () => {
const add = vi.fn(async ({ a, b }: { a: number; b: number }) => a + b);
const addCap = capability(add, {
description: 'Add two numbers',
args: {
a: { type: 'number' },
b: { type: 'number' },
},
returns: { type: 'number' },
});
const add = vi.fn(async (a: number, b: number) => a + b);
const addCap = makeMethodCapability(
'Math',
'add',
add,
S.method(
'Add two numbers',
[S.arg('a', S.number()), S.arg('b', S.number())],
S.number(),
),
);

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

const result = await agent.task('add 3 and 4');
expect(add).toHaveBeenCalledWith({ a: 3, b: 4 });
expect(add).toHaveBeenCalledWith(3, 4);
expect(result).toBe('7');
});

it('injects tool result message before next turn', async () => {
const recorded: ChatMessage[][] = [];
const ping = capability(async () => 'pong', {
description: 'Ping',
args: {},
returns: { type: 'string' },
});
const ping = makeMethodCapability(
'Server',
'ping',
async () => 'pong',
S.method('Send a ping', [], S.string()),
);

let call = 0;
const chat: BoundChat = async ({ messages }) => {
Expand Down Expand Up @@ -151,11 +155,60 @@ describe('makeChatAgent', () => {
).toBe(true);
});

it('injects a tool error for an invalid-argument tool call and continues', async () => {
const add = vi.fn(async (a: number, b: number) => a + b);
const addCap = makeMethodCapability(
'Math',
'add',
add,
S.method(
'Add two numbers',
[S.arg('a', S.number()), S.arg('b', S.number())],
S.number(),
),
);

const recorded: ChatMessage[][] = [];
let call = 0;
const chat: BoundChat = async ({ messages }) => {
recorded.push([...messages]);
call += 1;
if (call === 1) {
// `b` is missing, so the exo's interface guard rejects the call.
return makeToolCallResponse('0', [makeToolCall('c1', 'add', { a: 3 })]);
}
return makeTextResponse('recovered');
};

const agent = makeChatAgent({ chat, capabilities: { add: addCap } });
const result = await agent.task('add 3 and ?');

// The guard rejection surfaces as a tool error rather than crashing the
// task, and the implementation never runs with the bad arguments.
expect(result).toBe('recovered');
expect(add).not.toHaveBeenCalled();
const secondTurn = recorded[1] ?? [];
// The membrane normalizes the rejection into a real error carrying the
// expected signature, so the message is actionable even when the guard
// itself rejects with an opaque value (as it does under the test shim).
expect(
secondTurn.some(
(message) =>
message.role === 'tool' &&
message.content.startsWith(
'Error calling add(a: number, b: number):',
),
),
).toBe(true);
});

it('throws when invocation budget is exceeded', async () => {
const ping = capability(async () => 'pong', {
description: 'Ping',
args: {},
});
const ping = makeMethodCapability(
'Server',
'ping',
async () => 'pong',
S.method('Send a ping', [], S.string()),
);
const chat: BoundChat = async () =>
makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]);

Expand All @@ -177,11 +230,12 @@ describe('makeChatAgent', () => {

it('passes tools to the chat function', async () => {
const recordedTools: unknown[] = [];
const ping = capability(async () => 'pong', {
description: 'Ping the server',
args: {},
returns: { type: 'string' },
});
const ping = makeMethodCapability(
'Server',
'ping',
async () => 'pong',
S.method('Ping the server', [], S.string()),
);

const chat: BoundChat = async ({ tools }) => {
recordedTools.push(tools);
Expand Down
Loading
Loading