The Agent Loop Must Be Short and Observable
The agent loop is the heart of the agent, and this is exactly where the probabilistic model meets the deterministic runtime. At each step the model either calls working tools or picks one terminal tool - a final. The runtime validates the calls, writes events, executes the allowed handlers, and returns the results to the provider. Two words here are key: short (there's a hard step limit) and observable (every move lands in the journal).
Schematically the cycle looks like this.
- policy_search
- order_read
- refund_preview
- approval
- stop
And in code the loop rests on a set of terminal tools and a hard step limit - so that "work until you're done" never turns into an infinite loop.
const TERMINAL_TOOLS = new Set([
"answer_user",
"ask_clarification",
"request_refund_approval",
"request_handoff"
]);
export async function runAgent(
provider: ProviderAdapter,
input: AgentInput,
context: TrustedToolContext
): Promise<AgentOutcome> {
let turn = await provider.start(input);
for (let step = 1; step <= 6; step += 1) {
await events.record({ type: "model_turn", step, traceId: context.traceId });
if (turn.kind === "text") {
return { status: "blocked", reason: "terminal_tool_missing" };
}
const terminal = turn.calls.filter(function isTerminal(call) {
return TERMINAL_TOOLS.has(call.name);
});
if (terminal.length === 1 && turn.calls.length === 1) {
return mapTerminalCall(terminal[0], context);
}
if (terminal.length > 0) {
return { status: "blocked", reason: "ambiguous_terminal_calls" };
}
const results = await executeAllowedCalls(turn.calls, context);
turn = await provider.resume(turn.state, results);
}
return { status: "blocked", reason: "max_steps_exceeded" };
}It's worth stressing the order separately: validation happens BEFORE the handler. The runtime first finds the tool in the registry, then checks the arguments against the schema, and only then executes - an unknown tool or invalid arguments never reach the domain.
async function executeOne(
call: ToolCall,
context: TrustedToolContext
): Promise<ToolResult> {
const entry = toolRegistry.get(call.name);
if (!entry) return failed(call, "unknown_tool");
const parsed = entry.input.safeParse(call.arguments);
if (!parsed.success) return failed(call, "invalid_arguments");
try {
const output = await entry.execute(parsed.data, context);
return { callId: call.id, name: call.name, output, isError: false };
} catch (error) {
await events.recordToolError(call, error, context.traceId);
return failed(call, "tool_execution_failed");
}
}To step through and see how the loop reacts to calls, errors, and a terminal, use the simulator.
Agent loop simulator
No steps yet.
There's a loop, but it has no edge. The boundary of autonomy is set not by an "stop in time" instruction but by an explicit budget.