Bounded Agent Loops
A tool loop lets a model observe a result, choose another action, and continue. That is useful for debugging and multi-step work, but it also turns one bad assumption into a sequence of writes, retries, and growing context.
The important split is simple: the model proposes; the harness decides what may run and whether the result counts as success.
One task carries this principle through the page. Suppose you ask an agent to fix a failing unit test, test_parse_date. The harness gives it 20 steps and 15 minutes, and allows changes only under src/ and tests/.
Common loop shapes
Fixing test_parse_date is an implement–verify loop. The five shapes differ in when they stop: whichever loop a model runs in, the stop conditions come first.
What the harness must own
Budget and stopping
Set limits before the run: steps, wall time, model use, tool use, spending, and allowed files or services. If you pay for the API yourself, the spending cap is the one to set first: Claude Code’s unattended mode can cap a run at five dollars with --max-budget-usd 5.00 and limit its turns with --max-turns (CLI reference). The cap is checked against a client-side estimate and spending can overshoot it, so leave some headroom. Stop when a limit is reached, when the user cancels, when required evidence is unavailable, or when repeated failures show that the current strategy is not improving.
A repeated-call breaker handles that last case: when the same call fails N times in a row, the loop stops on its own. In the test example, suppose N is 3. The model runs pytest tests/test_dates.py unchanged at steps 1, 2 and 3 and gets the same error each time, so the breaker stops the loop after step 3 and hands the task back to a person instead of spending the remaining 17 steps on one command. The threshold should match the operation: retrying a file read is not the same as retrying a payment or deployment.
Tool validation
Validate tool names, argument schemas, paths, permissions, and side effects outside the model; argument arrays and path resolution are covered in Tool Contracts. If the model’s output was cut off by its length limit, run none of the tool calls in it: half a JSON object can happen to pass validation and still run the wrong operation. Pi marks every such call as failed and asks the model to submit it again (agent-loop.ts).
In the example, if the model proposes editing config/settings.py, the path check finds it outside src/ and tests/, denies the call, and records the reason in the state, so the model's next step has to take another route.
Verification
A model's final sentence is not a completion signal. The verifier may be a test suite, schema check, exact artifact comparison, browser assertion, or human review. It should test the intended behavior and be independent enough that the agent cannot make it green by deleting or weakening the check.
Suppose that at step 7 the model replies "fixed." The verifier reruns test_parse_date itself, the test is still red, and the completion is sent back: the loop continues with the failure output. If the model had changed the test instead, for example by deleting the assertion, an independent verifier should still catch it, because it checks the expected date-parsing behavior, not whether the test raised an error.
Durable state
Keep the complete transcript and raw tool output outside the active prompt, and keep a small task record alongside it; what that record must contain at minimum is covered in Long-Running Agents. For the test task it might read: goal, make test_parse_date pass, touching only src/ and tests/; changed src/dates.py; pytest still has 1 failure; do not try swapping the timezone library again; next, check two-digit years. A fresh context reads this record and carries on. The record is updated from observed results, such as test output, not from how confident the model sounds.
Cancellation and cleanup
Long-running tools need timeouts, process tracking, and cleanup. Cancelling the UI should cancel the underlying subprocess or background job as well. In the example, when the user clicks cancel, the harness must also end the running pytest subprocess; otherwise an apparently stopped agent can keep writing to disk.
A minimal control loop
The following is pseudocode; tool and model interfaces vary by harness.
for step in range(max_steps):
if cancelled() or budget.exhausted():
return STOPPED
context = select_working_context(state)
proposal = model.propose(context)
if proposal.is_final:
return verify_final(proposal, state)
checked = policy.validate(proposal.tool_call)
if not checked.allowed:
state.record_denial(checked.reason)
continue
result = tools.execute(checked.call, timeout=checked.timeout)
state.record(result)
if repeated_failure(result, state):
return NEEDS_REVIEW
return STOPPED # max_steps reached
Real implementations also need exception handling, idempotency rules, concurrency control, and redacted logs. The pseudocode marks where each control sits: policy.validate blocks the out-of-scope path, repeated_failure is the breaker, and verify_final reruns the test. These controls wrap the model call; they are not a prompt asking the model to be careful.
Signs of real progress
A shrinking failure set, a new discriminating test, a smaller justified diff, or a resolved ambiguity is progress. Rephrasing the same diagnosis, adding abstractions without evidence, or running longer is not. In the example, going from 3 failures to 1 is progress; saying "maybe it's the timezone" a third time without new test output is not.
The best loop is usually the least autonomous one that can finish the task reliably. Add parallel workers, planning layers, and self-critique only when a measured failure calls for them.