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:
root
2026-06-01 18:30:46 +10:00
parent 09a27e8495
commit d593234904
2 changed files with 126 additions and 0 deletions

58
lib/ai/agent/runtime.js Normal file
View 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 };
}

View 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);
});
});