Глава 9

Agent loop должен быть коротким и наблюдаемым

Agent loop - сердце агента, и именно здесь вероятностная модель встречается с детерминированным runtime. На каждом шаге модель либо вызывает рабочие tools, либо выбирает один terminal tool - финал. Runtime валидирует вызовы, пишет события, исполняет разрешённые handlers и возвращает результаты провайдеру. Два слова здесь ключевые: короткий (есть жёсткий лимит шагов) и наблюдаемый (каждый ход попадает в журнал).

Схематично цикл выглядит так.

  1. policy_search
  2. order_read
  3. refund_preview
  4. approval
  5. stop

А в коде loop опирается на набор terminal tools и жёсткий предел шагов - чтобы "работай до результата" никогда не превратилось в бесконечный цикл.

TypeScript
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 или неверные аргументы не доходят до домена.

TypeScript
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, помогает симулятор.

Интерактивная лаборатория 3

Симулятор agent loop

Сценарий: клиент спрашивает о возврате для заказа. Симуляция не вызывает внешние API.

Шагов еще нет.

Loop есть, но у него нет края. Границу автономности задаёт не инструкция "остановись вовремя", а явный бюджет.

Ссылки