Skip to content

Commit e914f86

Browse files
grypezclaude
andcommitted
feat(kernel-agents): use standard tool-calling interface in makeChatAgent
Replace the custom JSON-in-system-prompt approach with the standard chat completions tool-calling interface: capabilities are passed as `tools`, responses arrive via `tool_calls`, and results are returned as `role: "tool"` messages. Add a `glm-4.7-flash` / llama.cpp e2e test and a `LMS_PROVIDER` constant so the suite can be aimed at either Ollama or llama.cpp without OOM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ef6e64 commit e914f86

10 files changed

Lines changed: 363 additions & 203 deletions

File tree

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

Lines changed: 159 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,67 @@
11
import '@ocap/repo-tools/test-utils/mock-endoify';
22

3+
import type {
4+
ChatMessage,
5+
ChatResult,
6+
ToolCall,
7+
} from '@ocap/kernel-language-model-service';
38
import { describe, expect, it, vi } from 'vitest';
49

510
import { makeChatAgent } from './chat-agent.ts';
611
import type { BoundChat } from './chat-agent.ts';
712
import { capability } from '../capabilities/capability.ts';
813

9-
const makeChat = (responses: string[]): BoundChat => {
10-
let call = 0;
11-
return async () => {
12-
const index = call;
13-
call += 1;
14-
return {
15-
id: String(index),
16-
model: 'test',
17-
choices: [
18-
{
19-
message: {
20-
role: 'assistant' as const,
21-
content: responses[index] ?? '',
22-
},
23-
index: 0,
24-
finish_reason: 'stop',
25-
},
26-
],
27-
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
28-
};
29-
};
30-
};
14+
const makeToolCall = (
15+
id: string,
16+
name: string,
17+
args: Record<string, unknown>,
18+
): ToolCall => ({
19+
id,
20+
type: 'function',
21+
function: { name, arguments: JSON.stringify(args) },
22+
});
23+
24+
const makeTextResponse = (content: string): ChatResult => ({
25+
id: '0',
26+
model: 'test',
27+
choices: [
28+
{
29+
message: { role: 'assistant', content },
30+
index: 0,
31+
finish_reason: 'stop',
32+
},
33+
],
34+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
35+
});
36+
37+
const makeToolCallResponse = (
38+
id: string,
39+
toolCalls: ToolCall[],
40+
): ChatResult => ({
41+
id,
42+
model: 'test',
43+
choices: [
44+
{
45+
message: { role: 'assistant', content: '', tool_calls: toolCalls },
46+
index: 0,
47+
finish_reason: 'tool_calls',
48+
},
49+
],
50+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
51+
});
3152

3253
const noCapabilities = {};
3354

3455
describe('makeChatAgent', () => {
35-
it('returns plain text response when model does not invoke a capability', async () => {
36-
const chat = makeChat(['Hello, world!']);
56+
it('returns plain text response when model does not invoke a tool', async () => {
57+
const chat: BoundChat = async () => makeTextResponse('Hello, world!');
3758
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
3859

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

43-
it('returns result when model invokes end capability', async () => {
44-
const chat = makeChat([
45-
'{"name": "end", "args": {"final": "the answer is 42"}}',
46-
]);
47-
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
48-
49-
const result = await agent.task('what is the answer?');
50-
expect(result).toBe('the answer is 42');
51-
});
52-
53-
it('dispatches a user capability and continues to end', async () => {
64+
it('dispatches a tool call and returns final text answer', async () => {
5465
const add = vi.fn(async ({ a, b }: { a: number; b: number }) => a + b);
5566
const addCap = capability(add, {
5667
description: 'Add two numbers',
@@ -61,100 +72,158 @@ describe('makeChatAgent', () => {
6172
returns: { type: 'number' },
6273
});
6374

64-
const chat = makeChat([
65-
'{"name": "add", "args": {"a": 3, "b": 4}}',
66-
'{"name": "end", "args": {"final": "7"}}',
67-
]);
68-
const agent = makeChatAgent({
69-
chat,
70-
capabilities: { add: addCap },
71-
});
75+
let call = 0;
76+
const chat: BoundChat = async () => {
77+
call += 1;
78+
if (call === 1) {
79+
return makeToolCallResponse('0', [
80+
makeToolCall('c1', 'add', { a: 3, b: 4 }),
81+
]);
82+
}
83+
return makeTextResponse('7');
84+
};
85+
86+
const agent = makeChatAgent({ chat, capabilities: { add: addCap } });
7287

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

78-
it('injects tool result into messages before next turn', async () => {
79-
const messages: string[][] = [];
80-
const chat: BoundChat = async (chatMsgs) => {
81-
messages.push(chatMsgs.map((chatMsg) => chatMsg.content));
82-
const turn = messages.length - 1;
83-
return {
84-
id: String(turn),
85-
model: 'test',
86-
choices: [
87-
{
88-
message: {
89-
role: 'assistant' as const,
90-
content:
91-
turn === 0
92-
? '{"name": "ping", "args": {}}'
93-
: '{"name": "end", "args": {"final": "done"}}',
94-
},
95-
index: 0,
96-
finish_reason: 'stop',
97-
},
98-
],
99-
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
100-
};
101-
};
102-
93+
it('injects tool result message before next turn', async () => {
94+
const recorded: ChatMessage[][] = [];
10395
const ping = capability(async () => 'pong', {
10496
description: 'Ping',
10597
args: {},
10698
returns: { type: 'string' },
10799
});
100+
101+
let call = 0;
102+
const chat: BoundChat = async ({ messages }) => {
103+
recorded.push([...messages]);
104+
call += 1;
105+
if (call === 1) {
106+
return makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]);
107+
}
108+
return makeTextResponse('done');
109+
};
110+
108111
const agent = makeChatAgent({ chat, capabilities: { ping } });
109112
await agent.task('ping');
110113

111-
// Second turn messages should include the tool result
114+
// Second turn must include the tool result message
115+
const secondTurn = recorded[1] ?? [];
112116
expect(
113-
messages[1]?.some((content) => content.includes('[Result of ping]')),
117+
secondTurn.some(
118+
(message) => message.role === 'tool' && message.tool_call_id === 'c1',
119+
),
114120
).toBe(true);
121+
expect(secondTurn.some((message) => message.content === '"pong"')).toBe(
122+
true,
123+
);
115124
});
116125

117-
it('appends error message for unknown capability and continues', async () => {
118-
const chat = makeChat([
119-
'{"name": "nonexistent", "args": {}}',
120-
'{"name": "end", "args": {"final": "recovered"}}',
121-
]);
122-
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
126+
it('injects error message for unknown tool and continues', async () => {
127+
const recorded: ChatMessage[][] = [];
128+
let call = 0;
129+
const chat: BoundChat = async ({ messages }) => {
130+
recorded.push([...messages]);
131+
call += 1;
132+
if (call === 1) {
133+
return makeToolCallResponse('0', [
134+
makeToolCall('c1', 'nonexistent', {}),
135+
]);
136+
}
137+
return makeTextResponse('recovered');
138+
};
123139

140+
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
124141
const result = await agent.task('do something');
142+
125143
expect(result).toBe('recovered');
144+
const secondTurn = recorded[1] ?? [];
145+
expect(
146+
secondTurn.some(
147+
(message) =>
148+
message.role === 'tool' &&
149+
message.content.includes('Unknown capability'),
150+
),
151+
).toBe(true);
126152
});
127153

128154
it('throws when invocation budget is exceeded', async () => {
129-
// Always invokes a capability but never ends
130155
const ping = capability(async () => 'pong', {
131156
description: 'Ping',
132157
args: {},
133158
});
134-
const neverEnd = makeChat(
135-
Array.from({ length: 20 }, () => '{"name": "ping", "args": {}}'),
136-
);
137-
const agent = makeChatAgent({
138-
chat: neverEnd,
139-
capabilities: { ping },
140-
});
159+
const chat: BoundChat = async () =>
160+
makeToolCallResponse('0', [makeToolCall('c1', 'ping', {})]);
161+
162+
const agent = makeChatAgent({ chat, capabilities: { ping } });
141163

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

147-
it('applies judgment to end result', async () => {
148-
const chat = makeChat(['{"name": "end", "args": {"final": 99}}']);
169+
it('applies judgment to final answer', async () => {
170+
const chat: BoundChat = async () => makeTextResponse('hello');
171+
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
172+
173+
const isNumber = (result: unknown): result is number =>
174+
typeof result === 'number';
175+
await expect(agent.task('go', isNumber)).rejects.toThrow('Invalid result');
176+
});
177+
178+
it('passes tools to the chat function', async () => {
179+
const recordedTools: unknown[] = [];
180+
const ping = capability(async () => 'pong', {
181+
description: 'Ping the server',
182+
args: {},
183+
returns: { type: 'string' },
184+
});
185+
186+
const chat: BoundChat = async ({ tools }) => {
187+
recordedTools.push(tools);
188+
return makeTextResponse('done');
189+
};
190+
191+
const agent = makeChatAgent({ chat, capabilities: { ping } });
192+
await agent.task('go');
193+
194+
expect(recordedTools[0]).toStrictEqual([
195+
{
196+
type: 'function',
197+
function: {
198+
name: 'ping',
199+
description: 'Ping the server',
200+
parameters: { type: 'object', properties: {}, required: [] },
201+
},
202+
},
203+
]);
204+
});
205+
206+
it('passes undefined tools when there are no capabilities', async () => {
207+
let recordedTools: unknown = 'not-set';
208+
const chat: BoundChat = async ({ tools }) => {
209+
recordedTools = tools;
210+
return makeTextResponse('done');
211+
};
212+
149213
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
214+
await agent.task('go');
150215

151-
const isString = (result: unknown): result is string =>
152-
typeof result === 'string';
153-
await expect(agent.task('go', isString)).rejects.toThrow('Invalid result');
216+
expect(recordedTools).toBeUndefined();
154217
});
155218

156219
it('accumulates experiences across tasks', async () => {
157-
const chat = makeChat(['hello', 'world']);
220+
let call = 0;
221+
const responses = ['hello', 'world'];
222+
const chat: BoundChat = async () => {
223+
const response = makeTextResponse(responses[call] ?? '');
224+
call += 1;
225+
return response;
226+
};
158227
const agent = makeChatAgent({ chat, capabilities: noCapabilities });
159228

160229
await agent.task('first');

0 commit comments

Comments
 (0)