A minimal agent loop
Task: a fake model returns final text or a list of tool calls. Handle the calls, check the arguments and send back the results. Limit the time, the steps and the number of tool calls. To start with, the tools only read data.
Tests: controlling the process and trust boundaries. The model proposes; the application verifies and executes.
Contract: agentLoop(input, model, tools, options). model(messages, signal) returns {kind: 'final', text} or {kind: 'tools', calls: [{id, name, args}]}. tools is an object { [name]: { validate(args), run(args, signal) } }. A tool's result goes back into the history as {role: 'tool', callId, content: JSON.stringify(result)}, and the function returns {text, history}.
Options: maxTurns (6), maxCalls (8), timeoutMs (5000), maxChars (50,000), signal. Errors have the messages: unknown tool, duplicate call id, tool budget, turn budget, deadline, context budget, output budget. The deadline must work even when the model ignores the signal. parseLookupArgs is available alongside.
The character limit is a simplification of a context limit, not exact token counting. There's no durable state and no real cost accounting.
Follow-up: what changes once you add writes?
You will practise
- Build the model → tools → model loop
- Validate the tool name and arguments before running
- Enforce limits on steps, calls, time and context
- Tie each result to its call ID
The 7 tests
Tests ported from the source material's reference.test.ts (node:test). In the browser they run with the same set of assertions.
agent: a tool result with the right callId reaches the model
let n = 0;
const result = await agentLoop('question', async messages => {
if (n++ === 0) return { kind: 'tools', calls: [{ id: 'c1', name: 'lookup', args: { id: 'A' } }] };
assert.deepEqual(messages.at(-1), { role: 'tool', callId: 'c1', content: '{"id":"A"}' });
return { kind: 'final', text: 'done' };
}, { lookup: { validate: parseLookupArgs, run: async x => parseLookupArgs(x) } });
assert.equal(result.text, 'done');agent: a bad name, including an inherited one, doesn't run a tool
for (const name of ['deleteEverything', 'toString']) {
await assert.rejects(agentLoop('q', async () => ({ kind: 'tools', calls: [{ id: '1', name, args: {} }] }), {}), /unknown tool/);
}agent: an invalid schema doesn't call run
let ran = false;
await assert.rejects(agentLoop('q', async () => ({ kind: 'tools', calls: [{ id: '1', name: 'lookup', args: { id: 7 } }] }),
{ lookup: { validate: parseLookupArgs, run: async () => { ran = true; } } }), /args/);
assert.equal(ran, false);agent: tool-call limit and turn limit
const model = async () => ({ kind: 'tools', calls: [{ id: String(Math.random()), name: 'lookup', args: { id: 'A' } }] });
const tools = { lookup: { validate: parseLookupArgs, run: async () => ({}) } };
await assert.rejects(agentLoop('q', model, tools, { maxCalls: 0 }), /tool budget/);
await assert.rejects(agentLoop('q', model, tools, { maxTurns: 1 }), /turn budget/);agent: a repeated call ID is rejected
await assert.rejects(agentLoop('q', async () => ({ kind: 'tools', calls: [{ id: 'same', name: 'lookup', args: { id: 'A' } }] }),
{ lookup: { validate: parseLookupArgs, run: async () => ({}) } }), /duplicate call id/);agent: the deadline works even with an adapter that ignores the signal
await assert.rejects(agentLoop('q', async () => new Promise(() => {}), {}, { timeoutMs: 15 }), /deadline/);agent: context, output and cancellation have hard limits
await assert.rejects(agentLoop('too long', async () => ({ kind: 'final', text: 'a' }), {}, { maxChars: 2 }), /context budget/);
await assert.rejects(agentLoop('q', async () => ({ kind: 'final', text: 'too long' }), {}, { maxChars: 2 }), /output budget/);
const c = new AbortController(); c.abort(new Error('stop'));
await assert.rejects(agentLoop('q', async () => ({ kind: 'final', text: 'ok' }), {}, { signal: c.signal }), /stop/);Related questions
Source: AI & Agents — practice tasks — An exercise written for the study programme, not a confirmed interview task.