feat(ai): agent runtime tool-use loop with event streaming
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
58
lib/ai/agent/runtime.js
Normal file
58
lib/ai/agent/runtime.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Tool-use loop. callModel + registry injected (no network here).
|
||||||
|
// Emits: { type:'tool', tool, args, status } | { type:'delta', text }
|
||||||
|
// | { type:'draft', pending_change_id, summary }
|
||||||
|
// Returns: { text, toolTrace, draftIds, usage, stoppedOnGuard }
|
||||||
|
export async function runTurn({ callModel, registry, system, messages, ctx, onEvent, maxIterations = 6 }) {
|
||||||
|
const convo = [...messages];
|
||||||
|
const toolTrace = [];
|
||||||
|
const draftIds = [];
|
||||||
|
let usage = {};
|
||||||
|
let stoppedOnGuard = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
const res = await callModel({
|
||||||
|
system,
|
||||||
|
messages: convo,
|
||||||
|
tools: registry.toAnthropicTools(),
|
||||||
|
onTextDelta: (t) => onEvent?.({ type: 'delta', text: t })
|
||||||
|
});
|
||||||
|
usage = res.usage || usage;
|
||||||
|
|
||||||
|
if (!res.toolUses?.length) {
|
||||||
|
return { text: res.text, toolTrace, draftIds, usage, stoppedOnGuard };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the assistant turn (text + tool_use blocks) for the next round.
|
||||||
|
convo.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
...(res.text ? [{ type: 'text', text: res.text }] : []),
|
||||||
|
...res.toolUses.map(t => ({ type: 'tool_use', id: t.id, name: t.name, input: t.input }))
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolResults = [];
|
||||||
|
for (const call of res.toolUses) {
|
||||||
|
const tool = registry.getTool(call.name);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = tool ? await tool.handler(call.input, ctx) : { error: `unknown tool ${call.name}` };
|
||||||
|
} catch (e) {
|
||||||
|
result = { error: String(e?.message || e) };
|
||||||
|
}
|
||||||
|
const status = result?.error ? 'error' : 'done';
|
||||||
|
toolTrace.push({ tool: call.name, args: call.input, ok: !result?.error });
|
||||||
|
onEvent?.({ type: 'tool', tool: call.name, args: call.input, status });
|
||||||
|
if (result?.pending_change_id) {
|
||||||
|
draftIds.push(result.pending_change_id);
|
||||||
|
onEvent?.({ type: 'draft', pending_change_id: result.pending_change_id, summary: result.summary });
|
||||||
|
}
|
||||||
|
toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(result) });
|
||||||
|
}
|
||||||
|
convo.push({ role: 'user', content: toolResults });
|
||||||
|
|
||||||
|
if (i === maxIterations - 1) stoppedOnGuard = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: '', toolTrace, draftIds, usage, stoppedOnGuard };
|
||||||
|
}
|
||||||
68
tests/ai/agent/runtime.test.js
Normal file
68
tests/ai/agent/runtime.test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRegistry } from '../../../lib/ai/agent/registry.js';
|
||||||
|
import { runTurn } from '../../../lib/ai/agent/runtime.js';
|
||||||
|
|
||||||
|
function scriptedCallModel(steps) {
|
||||||
|
let i = 0;
|
||||||
|
return async ({ onTextDelta }) => {
|
||||||
|
const step = steps[i++];
|
||||||
|
if (step.text) for (const ch of step.text) onTextDelta?.(ch);
|
||||||
|
return { text: step.text || '', toolUses: step.toolUses || [], stopReason: step.toolUses ? 'tool_use' : 'end_turn', usage: { output_tokens: 1 } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runTurn', () => {
|
||||||
|
it('runs a tool then produces a final answer', async () => {
|
||||||
|
const reg = createRegistry();
|
||||||
|
reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} },
|
||||||
|
handler: async () => ({ results: [{ title: 'Hit' }] }) });
|
||||||
|
|
||||||
|
const callModel = scriptedCallModel([
|
||||||
|
{ toolUses: [{ id: 'tu1', name: 'search', input: { q: 'x' } }] },
|
||||||
|
{ text: 'Found it.' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const out = await runTurn({
|
||||||
|
callModel, registry: reg, system: 'sys',
|
||||||
|
messages: [{ role: 'user', content: 'find x' }],
|
||||||
|
ctx: { agent: { id: 'a' }, space_id: 's' },
|
||||||
|
onEvent: e => events.push(e)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out.text).toBe('Found it.');
|
||||||
|
expect(events.filter(e => e.type === 'tool').map(e => e.tool)).toEqual(['search']);
|
||||||
|
expect(events.some(e => e.type === 'delta' && e.text)).toBe(true);
|
||||||
|
expect(out.toolTrace[0]).toMatchObject({ tool: 'search' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a draft event when propose_change runs', async () => {
|
||||||
|
const reg = createRegistry();
|
||||||
|
reg.registerTool({ name: 'propose_change', description: 'p', input_schema: { type: 'object', properties: {} },
|
||||||
|
handler: async () => ({ pending_change_id: 'pc1', applied: false, summary: 'create task "X"' }) });
|
||||||
|
|
||||||
|
const callModel = scriptedCallModel([
|
||||||
|
{ toolUses: [{ id: 'tu1', name: 'propose_change', input: {} }] },
|
||||||
|
{ text: 'Drafted.' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const out = await runTurn({ callModel, registry: reg, system: 's',
|
||||||
|
messages: [{ role: 'user', content: 'make a task' }],
|
||||||
|
ctx: { agent: { id: 'a' } }, onEvent: e => events.push(e) });
|
||||||
|
|
||||||
|
expect(out.draftIds).toEqual(['pc1']);
|
||||||
|
expect(events.find(e => e.type === 'draft')).toMatchObject({ pending_change_id: 'pc1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the iteration guard', async () => {
|
||||||
|
const reg = createRegistry();
|
||||||
|
reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} },
|
||||||
|
handler: async () => ({ results: [] }) });
|
||||||
|
const always = async () => ({ text: '', toolUses: [{ id: 't', name: 'search', input: {} }], stopReason: 'tool_use', usage: {} });
|
||||||
|
const out = await runTurn({ callModel: always, registry: reg, system: 's',
|
||||||
|
messages: [{ role: 'user', content: 'loop' }], ctx: { agent: { id: 'a' } }, onEvent: () => {}, maxIterations: 3 });
|
||||||
|
expect(out.toolTrace.length).toBe(3);
|
||||||
|
expect(out.stoppedOnGuard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user