The loop
# grade this stage (from your course directory)
npx theinference test --stage the-while-loop --watchOne turn is a step. The loop is the agent. Wrap the step in a while: each turn, if the model stopped on tool_use, you run every tool it asked for, feed the results back, and let it go again — until it stops on end_turn with a final answer.
That's the whole heartbeat of Claude Code, and you're building it now. No real tools yet — a single echo tool stands in — so you can watch the mechanics work without the noise: model asks to echo, you echo, model sees the result and says it's done.
loop:
resp = model(messages) ── push assistant message
if resp.stop_reason != "tool_use": ── done → return final text
break
for each tool_use block:
out = execute(name, input)
collect { tool_result, tool_use_id, content }
push { role: "user", content: [...tool_results] } ── feed results back
repeat
What to build
Drive the loop
In src/loop.ts, implement runLoop(messages, model, execute, cap). Each turn: take a step (call the model, append the assistant message). If its stop_reason is tool_use, run each tool_use block through execute, build a matching tool_result for every one, and append them as a single user message — then loop. When the model stops on anything other than tool_use, return the final assistant text, the trajectory of tools you ran, and the stop_reason.
Graded by program.ts loop: it runs your loop against the echo-tool cassette and prints { trajectory, finalText, stopReason }.
import type { Message, Model, ToolOutput, TrajectoryEntry, StopReason } from './types.ts'
export type Execute = (name: string, input: Record<string, unknown>) => Promise<ToolOutput> | ToolOutput
export async function runLoop(messages: Message[], model: Model, execute: Execute, cap = 25) {
// for each turn up to cap:
// resp = await model(messages); push the assistant message
// if resp.stop_reason !== 'tool_use': return { finalText, trajectory, stopReason, turns }
// for each tool_use block: out = await execute(name, input); record a tool_result (keyed by tool_use_id)
// push { role: 'user', content: [...tool_results] }
}
Each turn writes two entries to the transcript: the assistant message (which holds the model's tool_use blocks) and a user message holding your tool_result blocks. The model needs to see its own prior request next to your answer to it — and each tool_result must carry the tool_use_id of the call it answers. Drop the assistant message, or mismatch an id, and the next turn is talking to a model that doesn't remember asking.
A single assistant turn can contain several tool_use blocks. Run them all, then collect their tool_results into one user message — not one user message per tool. Batching them in a single turn is exactly the API contract, and it keeps your trajectory aligned with the scripted one the grader expects.
Process mode: your_program.sh loop runs program.ts loop on the case fixture. The cassette scripts an echo call followed by an end_turn, so a correct loop advances to the end and prints { trajectory, finalText, stopReason }. The grader compares that JSON to fixtures/expected.json — {"trajectory":[{"tool":"echo","input":{"text":"hello"}}],"finalText":"done","stopReason":"end_turn"} — exactly. A trajectory that stalls (loop never runs the tool) or overruns (loop never stops) both show up as a mismatch.