diff --git a/lib/ai/agent/runtime.js b/lib/ai/agent/runtime.js new file mode 100644 index 0000000..15058fa --- /dev/null +++ b/lib/ai/agent/runtime.js @@ -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 }; +} diff --git a/tests/ai/agent/runtime.test.js b/tests/ai/agent/runtime.test.js new file mode 100644 index 0000000..f0dcc8c --- /dev/null +++ b/tests/ai/agent/runtime.test.js @@ -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); + }); +});