Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions packages/kernel-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@
"default": "./dist/strategies/json-agent.cjs"
}
},
"./chat": {
"import": {
"types": "./dist/strategies/chat-agent.d.mts",
"default": "./dist/strategies/chat-agent.mjs"
},
"require": {
"types": "./dist/strategies/chat-agent.d.cts",
"default": "./dist/strategies/chat-agent.cjs"
}
},
"./agent": {
"import": {
"types": "./dist/agent.d.mts",
Expand Down Expand Up @@ -186,6 +196,7 @@
"@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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';

import { validateCapabilityArgs } from './validate-capability-args.ts';

describe('validateCapabilityArgs', () => {
it('accepts values matching primitive arg schemas', () => {
expect(() =>
validateCapabilityArgs(
{ a: 1, b: 2 },
{
description: 'add',
args: {
a: { type: 'number' },
b: { type: 'number' },
},
},
),
).not.toThrow();
});

it('throws when a required argument is missing', () => {
expect(() =>
validateCapabilityArgs(
{ a: 1 },
{
description: 'add',
args: {
a: { type: 'number' },
b: { type: 'number' },
},
},
),
).toThrow(/At path: b -- Expected a number/u);
});

it('throws when a value does not match the schema', () => {
expect(() =>
validateCapabilityArgs(
{ a: 'not-a-number' },
{
description: 'x',
args: { a: { type: 'number' } },
},
),
).toThrow(/Expected a number/u);
});

it('does nothing when there are no declared arguments', () => {
expect(() =>
validateCapabilityArgs(
{ extra: 1 },
{
description: 'ping',
args: {},
},
),
).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { methodArgsToStruct } from '@metamask/kernel-utils/json-schema-to-struct';
import { assert } from '@metamask/superstruct';

import type { CapabilitySchema } from '../types.ts';

/**
* Assert `values` match the capability's declared argument schemas using Superstruct.
*
* @param values - Parsed tool arguments (a plain object).
* @param capabilitySchema - {@link CapabilitySchema} for this capability.
*/
export function validateCapabilityArgs(
values: Record<string, unknown>,
capabilitySchema: CapabilitySchema<string>,
): void {
assert(values, methodArgsToStruct(capabilitySchema.args));
}
240 changes: 240 additions & 0 deletions packages/kernel-agents/src/strategies/chat-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import '@ocap/repo-tools/test-utils/mock-endoify';

import type {
ChatMessage,
ChatResult,
ToolCall,
} from '@ocap/kernel-language-model-service';
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';

const makeToolCall = (
id: string,
name: string,
args: Record<string, unknown>,
): ToolCall => ({
id,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
});

const makeTextResponse = (content: string): ChatResult => ({
id: '0',
model: 'test',
choices: [
{
message: { role: 'assistant', content },
index: 0,
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});

const makeToolCallResponse = (
id: string,
toolCalls: ToolCall[],
): ChatResult => ({
id,
model: 'test',
choices: [
{
message: { role: 'assistant', content: '', tool_calls: toolCalls },
index: 0,
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});

const noCapabilities = {};

describe('makeChatAgent', () => {
it('returns plain text response when model does not invoke a tool', async () => {
const chat: BoundChat = async () => makeTextResponse('Hello, world!');
const agent = makeChatAgent({ chat, capabilities: noCapabilities });

const result = await agent.task('say hello');
expect(result).toBe('Hello, world!');
});

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' },
});

let call = 0;
const chat: BoundChat = async () => {
call += 1;
if (call === 1) {
return makeToolCallResponse('0', [
makeToolCall('c1', 'add', { a: 3, b: 4 }),
]);
}
return makeTextResponse('7');
};

const agent = makeChatAgent({ chat, capabilities: { add: addCap } });

const result = await agent.task('add 3 and 4');
expect(add).toHaveBeenCalledWith({ a: 3, b: 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' },
});

let call = 0;
const chat: BoundChat = async ({ messages }) => {
recorded.push([...messages]);
call += 1;
if (call === 1) {
return makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]);
}
return makeTextResponse('done');
};

const agent = makeChatAgent({ chat, capabilities: { ping } });
await agent.task('ping');

// Second turn must include the tool result message
const secondTurn = recorded[1] ?? [];
expect(
secondTurn.some(
(message) => message.role === 'tool' && message.tool_call_id === 'c1',
),
).toBe(true);
expect(secondTurn.some((message) => message.content === '"pong"')).toBe(
true,
);
});

it('injects error message for unknown tool and continues', async () => {
const recorded: ChatMessage[][] = [];
let call = 0;
const chat: BoundChat = async ({ messages }) => {
recorded.push([...messages]);
call += 1;
if (call === 1) {
return makeToolCallResponse('0', [
makeToolCall('c1', 'nonexistent', {}),
]);
}
return makeTextResponse('recovered');
};

const agent = makeChatAgent({ chat, capabilities: noCapabilities });
const result = await agent.task('do something');

expect(result).toBe('recovered');
const secondTurn = recorded[1] ?? [];
expect(
secondTurn.some(
(message) =>
message.role === 'tool' &&
message.content.includes('Unknown capability'),
),
).toBe(true);
});

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

const agent = makeChatAgent({ chat, capabilities: { ping } });

await expect(
agent.task('go', undefined, { invocationBudget: 3 }),
).rejects.toThrow('Invocation budget exceeded');
});

it('applies judgment to final answer', async () => {
const chat: BoundChat = async () => makeTextResponse('hello');
const agent = makeChatAgent({ chat, capabilities: noCapabilities });

const isNumber = (result: unknown): result is number =>
typeof result === 'number';
await expect(agent.task('go', isNumber)).rejects.toThrow('Invalid result');
});

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 chat: BoundChat = async ({ tools }) => {
recordedTools.push(tools);
return makeTextResponse('done');
};

const agent = makeChatAgent({ chat, capabilities: { ping } });
await agent.task('go');

expect(recordedTools[0]).toStrictEqual([
{
type: 'function',
function: {
name: 'ping',
description: 'Ping the server',
parameters: { type: 'object', properties: {}, required: [] },
},
},
]);
});

it('passes undefined tools when there are no capabilities', async () => {
let recordedTools: unknown = 'not-set';
const chat: BoundChat = async ({ tools }) => {
recordedTools = tools;
return makeTextResponse('done');
};

const agent = makeChatAgent({ chat, capabilities: noCapabilities });
await agent.task('go');

expect(recordedTools).toBeUndefined();
});

it('accumulates experiences across tasks', async () => {
let call = 0;
const responses = ['hello', 'world'];
const chat: BoundChat = async () => {
const response = makeTextResponse(responses[call] ?? '');
call += 1;
return response;
};
const agent = makeChatAgent({ chat, capabilities: noCapabilities });

await agent.task('first');
await agent.task('second');

const exps = [];
for await (const exp of agent.experiences) {
exps.push(exp);
}
expect(exps).toHaveLength(2);
expect(exps[0]?.objective.intent).toBe('first');
expect(exps[1]?.objective.intent).toBe('second');
});
});
Loading
Loading