Agent loop должен быть коротким и наблюдаемым
Agent loop - сердце агента, и именно здесь вероятностная модель встречается с детерминированным runtime. На каждом шаге модель либо вызывает рабочие tools, либо выбирает один terminal tool - финал. Runtime валидирует вызовы, пишет события, исполняет разрешённые handlers и возвращает результаты провайдеру. Два слова здесь ключевые: короткий (есть жёсткий лимит шагов) и наблюдаемый (каждый ход попадает в журнал).
Схематично цикл выглядит так.
- policy_search
- order_read
- refund_preview
- approval
- stop
А в коде loop опирается на набор terminal tools и жёсткий предел шагов - чтобы "работай до результата" никогда не превратилось в бесконечный цикл.
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" };
}Отдельно стоит подчеркнуть порядок: валидация происходит ДО handler. Runtime сначала находит tool в registry, потом проверяет аргументы схемой, и только затем исполняет - неизвестный tool или неверные аргументы не доходят до домена.
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");
}
}Пройти по шагам и увидеть, как loop реагирует на вызовы, ошибки и terminal, помогает симулятор.
Симулятор agent loop
Шагов еще нет.
Loop есть, но у него нет края. Границу автономности задаёт не инструкция "остановись вовремя", а явный бюджет.