Skip to content
Jobless Busters
Runs testsAdvanced25–35 min

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?

  • 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

JavaScript
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

JavaScript
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

JavaScript
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

JavaScript
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

JavaScript
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

JavaScript
await assert.rejects(agentLoop('q', async () => new Promise(() => {}), {}, { timeoutMs: 15 }), /deadline/);

agent: context, output and cancellation have hard limits

JavaScript
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/);

Source: AI & Agents — practice tasks — An exercise written for the study programme, not a confirmed interview task.

JavaScript · runs in a Web Worker in your browser

Provided helpers (already defined, read-only)

Tab indents · Esc, then Tab, leaves the editor · ⌘/Ctrl + Enter runs the tests · Your code is saved in this browser.

Run the tests to check your solution. 7 predefined tests.

Stuck? Hints go from gentle to specific.

Give it a real try first — the solution is most useful after you've struggled a bit.