How jig Does `done()`
How jig Implements done()
Turning an agent loop into a bounded, typed runner
[The Hard Part Is done()] argues that the hard part of an agent is not the loop. It is building a done() you can trust: forcing an open-ended emitter to resolve into a typed, bounded result. This is the implementation.
Within the runner’s boundary, run_agent behaves like a total function: every accepted run resolves to an AgentResult, never an unbounded loop, a silent None, or an agent-level exception that escapes to the caller.
Both scope words earn their place. Accepted: a malformed config is rejected before the loop starts, not resolved into a result. Boundary: process death, OOM, cancellation, or a socket that hangs inside a provider or tool adapter all live below the runner, and are not its to promise. The claim is the narrow, useful one: every run the runner starts, it finishes, with a typed value or a typed reason it stopped.
async def run_agent[T](config: AgentConfig[T], input: str) -> AgentResult[T]
It returns an AgentResult[T], where T is the caller’s type, and exactly one of parsed / error is set:
@dataclass
class AgentResult[T]:
output: str # raw final text, always set
parsed: T | None # the validated result, on success
error: AgentError | None # one of six typed failures, on giving up
... # plus trace_id, usage, scores, duration_ms
In the minimal agent framework jig T is yours, not jig’s. The runner is agnostic about the result type: you hand it a schema, it hands back a validated instance of that schema as parsed. The exactly-one invariant is not a guard on the struct, it is held by the runner, which builds the result at the end with either the parsed value or the typed error, never both and never neither. The control flow is what makes it true, and that is the point of a bounded runner: there is no path that returns an incoherent result because there is no path that does anything except terminate one of these ways. (ok and fail in the pseudocode below are the two builders.)
Schema-as-tool is a standard pattern. Treating the validated call as the loop’s only halting condition is the part worth naming. The rest of this is how the runner makes it hold.
The injection
Start with the happy path, because the whole design is built to protect it.
submit_output is the synthetic tool the runner adds whenever the config carries an output schema. Its parameters are that schema:
SUBMIT_OUTPUT_TOOL = "submit_output"
def _build_submit_output_tool(schema: type[BaseModel]) -> ToolDefinition:
return ToolDefinition(
name=SUBMIT_OUTPUT_TOOL,
description=(
"Submit your final answer. Call this exactly once "
"when you have your result."
),
parameters=schema.model_json_schema(),
)
That is the entire mechanism. The model’s JSON schema becomes a tool’s parameter spec. The model “returns” by calling it. The runner validates the arguments, and that validation is the halting condition. Termination, structured output, and validation are the same event.
When it works, that is the whole story: the model calls submit_output, the arguments validate, the runner returns parsed. Everything below is what happens when that event does not occur cleanly.
The loop as a state machine
Each iteration makes one model call and classifies the response into exactly one transition. loop ↺ goes around again — each carries a budget that becomes the named error if exhausted; stop ✓ and stop ✗ terminate.
the model returns …
submit_output, args valid ........... stop ✓ parsed
submit_output, args invalid ......... loop ↺ max_parse_retries → AgentSchemaValidationError
submit_output + other tool calls .... loop ↺ max_parse_retries → AgentAmbiguousTurnError
other tool calls only ............... loop ↺ run tools, feed results back
plain text, schema set .............. loop ↺ max_parse_retries → AgentSchemaNotCalledError
plain text, no schema ............... stop ✓ raw text (compat mode)
the provider call …
transient / unknown error ........... loop ↺ max_llm_retries → AgentMaxLLMRetriesError
4xx in {400,401,403,404,422} ........ stop ✗ AgentLLMPermanentError
the outer cap …
round-trips reach max_llm_calls ..... stop ✗ AgentMaxLLMCallsError
max_parse_retries counts correction attempts after the first malformed turn. max_llm_retries counts consecutive provider failures and gives up on the Nth. They are intentionally separate budgets, because parse repair and provider instability are different failure modes.
The outer max_llm_calls cap counts every round-trip, success or failure, so a model that keeps emitting after being told “max reached” still cannot loop forever.
The shape, with counters and feedback elided — jig implements the real loop:
# Illustrative skeleton. jig has the real loop; counters, feedback,
# tracing, and memory are elided here.
while True:
if llm_calls >= max_llm_calls: # outer cap
return fail(AgentMaxLLMCallsError(...))
response = await llm.complete(messages, tools) # provider error → retry, or fast-fail on 4xx
submit, others = partition(response.tool_calls, name="submit_output")
if submit and others: # ambiguous turn → retry (max_parse_retries)
feed_back(...); continue
if submit:
try: return ok(schema.model_validate(submit[0].arguments)) # the only happy exit
except ValidationError as ve: feed_back(ve); continue # retry (max_parse_retries)
if not response.tool_calls: # plain text → nudge to submit, or return text
feed_back(...); continue
run_tools(response.tool_calls) # bounded by max_tool_calls; errors return as data
This is the shape, not a drop-in. The exits figure, the diagram, and the prose below describe the runner’s actual behavior; the jig source is the authoritative version of everything sketched here.
One branch is a side door. With no schema, the loop falls back to terminating on no tool calls and returns the raw text. Plain-text agents are the compatibility mode. They still stop, but they do not get the typed termination contract this article is about. Everything else here assumes a schema.
call model either terminates or carries a budget that drains to a typed error. The two direct exits — validation, and the 4xx / outer-cap fast-fail — are the only ways out that don't pass through a counter; every retry path loops back through one and converges on the same typed-error exit when its budget is spent.The exits
Seven terminal states, three kinds: one success, four that loop on a budget before giving up, two that fail immediately. The chips are the category tags traces roll up by.
parsedmax_parse_retries · one counter, three modesschema_validation_failedambiguous_tool_turnschema_not_calledmax_llm_retriesmax_llm_retriesllm_permanent_errormax_llm_callscategory tag set on the matching AgentError subclass; the second line is what the runner does on that condition.Each error is an AgentError subclass carrying a stable category tag. It is set on AgentResult.error, not raised: callers branch on the type, and traces roll up by category instead of by string-matching a log line. (Defaults: max_llm_calls=50, max_tool_calls=10, max_llm_retries=3, max_parse_retries=2.)
Where tool failures go, and why they do not terminate
Notice what is absent from those exits: tool failures.
A tool that raises is caught at execution. tools.execute captures the exception and returns it as result.error rather than letting it propagate. The error is fed back to the model as the tool result, [tool error: ...], and the loop continues. The model decides what to do with it: retry with different arguments, route around it, or give up and submit. The runner does not classify tool HTTP statuses, does not retry tools on its own, and does not terminate on a tool error.
The only bound the runner puts on tool execution is max_tool_calls. Cross it and the runner stops executing further calls and injects “provide your final answer” rather than killing the run. Tool-call exhaustion is not a terminal error by itself: it turns further tool calls into feedback and lets the outer max_llm_calls budget end the run if the model refuses to submit. That is also why there is no AgentMaxToolCallsError; the model can always recover by submitting.
That bound is on count, not duration. Tool adapters still need their own timeouts: max_tool_calls caps how many tool results the agent may consume; it does not make a blocking tool safe by itself. A hung HTTP request inside a tool is one of the boundary cases from the top: below the runner, the adapter’s job, not something run_agent can promise away.
That is the deliberate split:
- Model-boundary failures are the runner’s job. It owns the transient-versus-permanent decision, retry within budget versus fast-fail, because that decision is uniform across every agent.
- Tool failures are the model’s job. They come back as data, because how to react to a failed search, or a 404 from your own API, is domain policy the runner has no business hard-coding.
So the “500 versus 401” distinction is specifically about the provider call, the model endpoint. A tool that 500s and a tool that 401s both come back as tool results. It is up to the agent.
Mechanism, policy, and with_
The runner provides mechanism: the loop, the gate, the bounded retries, the budgets, one happy exit, and six typed failure exits. It provides no policy. Policy is the config you hand it:
config = AgentConfig[Analysis](
name="analyst",
llm=from_model("claude-sonnet-4-5"),
tools=registry,
output_schema=Analysis,
grader=strict_grader,
max_llm_calls=50,
# tracer, feedback, and memory are injected too
)
result = await run_agent(config, input="...")
You do not subclass the runner to change behavior. AgentConfig is frozen; you derive variants:
stricter = config.with_(grader=stricter_grader)
cheaper = config.with_(llm=from_model("gpt-5-mini"))
Same loop, different policy. with_ preserves the generic parameter, so a typed config stays typed across variants. The runner is the thin middle; every config is a different thing bolted to the same seam.
One field from local to a fleet
Model names are prefix-routed: claude- to Anthropic, gpt- to OpenAI, dispatch/ to a GPU fleet.
base = AgentConfig[Analysis](..., llm=from_model("claude-sonnet-4-5"))
remote = base.with_(llm=from_model("dispatch/qwen-72b"))
The runner never learns it happened: same run_agent, the same exits, same result shape. The worker behind dispatch/ is a stateless coprocessor: prompt in, completion out, no loop, no memory, no agency. A coprocessor, not a colleague. The agency stays in the one runner; the GPUs compute, they do not decide.
The receipt
Frameworks are cheap, so here is the part that is checkable.
The same run_agent drives a strategy-discovery pipeline (generate, optimize, validate, promote) fanned across a fleet. The receipt is not a return number. It is the termination behavior over a run: the overwhelming majority of strategies terminate in an automatic, typed rejection, a thin slice is promoted for human review, and the count that hangs or lands in an unhandled state is zero.
The strategies might be garbage; that is a different question. What the number shows is narrower: every run resolved to a validated value or a typed error, batch after batch, with nobody watching convergence.
That is the infrastructure test: not whether the demo works, but whether the machinery keeps resolving messy model behavior into bounded, inspectable outcomes after the novelty wears off.
Seven hundred lines to make the loop total within its boundary. That is what implementing done() buys: a loop you can stop watching.
jig is a reference implementation of this runner — minimal and modular, geared toward experiments rather than as a heavyweight production dependency: five interfaces and one run_agent, with everything behind them swappable. The prefix routing here is one field; with_ derives a typed variant without subclassing; the same loop drives a single local call or a strategy search fanned across a fleet. It’s a substrate to read, fork, and instrument, not a black box to import and forget. So the skeleton above is a reference for the shape, and the runner’s guarantee only holds once the pieces it deliberately delegates are in place: a real integration still owns its tool timeouts, its provider adapter’s status and retryable classification, and whatever acceptance grading it layers on the typed result. The runner hands you the bounded loop; the guardrails that sit above and below it are yours. Source: github.com/rankonelabs/jig.
The next piece in the series puts it to work — a single agent built on this runner, end to end, with those guardrails wired in.