For pseudocode conventions, see the README.

run_loop (src/agent_loop/)

Purpose: The shared inner logic for both agent_loop and agent_loop_continue. Handles the outer follow-up loop and the inner turn-by-tool loop. Preconditions: Context contains at least one user message. Postconditions: new_messages contains all messages produced; loop has exited cleanly or on limit/cancel/error.

FUNCTION run_loop(
  context: AgentContext,         // mutable
  new_messages: Vec<AgentMessage>,  // mutable accumulator
  config: AgentLoopConfig,
  tx: EventChannel<AgentEvent>,
  cancel: CancellationToken
) -> Usage  // accumulated usage across all turns

  first_turn ← true
  turn_number ← 0
  loop_usage ← Usage.default()
  tracker ← ExecutionTracker.new(config.execution_limits)  // optional

  // Drain any pending steering messages before starting
  pending ← config.get_steering_messages()  // or []

  // ── Outer loop: re-enters if follow-up messages arrive ──────────────────
  WHILE true
    IF cancel.is_cancelled THEN RETURN loop_usage END IF

    steering_after_tools ← null

    // ── Inner loop: runs once per turn (LLM call + tools) ─────────────────
    WHILE true
      IF cancel.is_cancelled THEN RETURN loop_usage END IF

      // Determine TurnTrigger for TurnStart event.
      // NOTE: context.continuation_kind is Option<ContinuationKind> on AgentContext.
      // None means Initial (first loop); Some(x) means a continuation.
      // The pseudocode below abstracts this as direct ContinuationKind values.
      //
      // Priority on the first turn:
      //   1. Branch continuation   → TurnTrigger::Branch   (explicit branch signal)
      //   2. Any other continuation (Default/Rerun/Compaction) → TurnTrigger::Continuation
      //      (the continuation itself is the follow-up, not a fresh user turn)
      //   3. Initial (origin agent_loop call) → config.first_turn_trigger
      //      (User for Agent::prompt, SubAgent for sub-agent callers)
      // Subsequent turns always use TurnTrigger::Continuation.
      IF first_turn THEN
        turn_trigger ←
          IF context.continuation_kind == Branch(..) THEN TurnTrigger::Branch
          ELSE IF context.continuation_kind != Initial   THEN TurnTrigger::Continuation
          ELSE config.first_turn_trigger
        first_turn ← false
      ELSE
        turn_trigger ← TurnTrigger::Continuation
      END IF

      EMIT TurnStart { turn_index: turn_number, triggered_by: turn_trigger }

      // Inject any pending (steering/follow-up) messages
      FOR EACH msg IN pending
        EMIT MessageStart(msg)
        EMIT MessageEnd(msg)
        context.messages.append(msg)
        new_messages.append(msg)
        context.user_context.append(msg)    // steering goes to user stream (never pruned)
      END FOR
      pending ← []

      // Check execution limits
      IF tracker.check_limits() is Some(reason) THEN
        limit_msg ← User message "[Agent stopped: {reason}]"
        EMIT MessageStart(limit_msg)
        EMIT MessageEnd(limit_msg)
        context.messages.append(limit_msg)
        new_messages.append(limit_msg)
        RETURN loop_usage
      END IF

      // Before-turn callback — abort if returns false
      IF config.before_turn is defined THEN
        IF NOT config.before_turn(context.messages, turn_number) THEN
          RETURN loop_usage
        END IF
      END IF
      turn_number ← turn_number + 1

      // Compact context if configured (strategies live in context_config.compaction)
      IF config.context_config is defined THEN
        ctx_config ← config.context_config
        IF tokens_exceed_threshold(context, ctx_config) THEN
          IF config.before_compaction_start defined THEN
            IF NOT before_compaction_start(estimated_tokens, message_count) THEN
              SKIP compaction this cycle
            END IF
          END IF
          EMIT CompactionStarted { ... }
          strategy ← ctx_config.compaction.in_memory_strategy OR DefaultCompaction
          context.messages ← strategy.compact(context.messages, ctx_config)
          EMIT CompactionEnded { ... }
          IF config.after_compaction_end defined THEN
            after_compaction_end(msgs_before, msgs_after, tokens_before, tokens_after)
          END IF
        END IF
      END IF


      // ── LLM call ────────────────────────────────────────────────────────
      message ← AWAIT stream_assistant_response(context, config, tx, cancel)

      agent_msg ← message as AgentMessage
      context.messages.append(agent_msg)
      new_messages.append(agent_msg)
      context.inrun_context.append(Live(agent_msg))   // track in inrun stream (model-generated)

      // Accumulate usage for after_loop hook
      loop_usage ← loop_usage + message.usage

      // Handle error/abort stop reasons
      IF message.stop_reason == Error OR message.stop_reason == Aborted THEN
        IF message.stop_reason == Error AND config.on_error is defined THEN
          config.on_error(message.error_message OR "Unknown error")
        END IF
        IF config.after_turn is defined THEN
          config.after_turn(context.messages, message.usage)
        END IF
        EMIT TurnEnd(agent_msg, tool_results=[])
        RETURN loop_usage
      END IF

      // Extract tool calls from assistant content
      tool_calls ← [
        (id, name, arguments)
        FOR EACH content IN message.content
        IF content is ToolCall
      ]

      tool_results ← []

      IF tool_calls is non-empty THEN
        execution ← AWAIT execute_tool_calls(
          context.tools, tool_calls, tx, cancel,
          config.get_steering_messages, config.tool_execution
        )
        tool_results ← execution.tool_results
        steering_after_tools ← execution.steering_messages

        FOR EACH result IN tool_results
          am ← result as AgentMessage
          context.messages.append(am)
          new_messages.append(am)
          context.inrun_context.append(Live(am))   // track in inrun stream
        END FOR

        // Apply pending prun requests after tool execution (PrunTool stores requests during execute)
        IF config.prun_pending is defined THEN
          requests ← LOCK(config.prun_pending).drain()
          FOR EACH request IN requests
            apply_prun(context, request, tx)  // walks inrun_context backward, prunes Live entries
          END FOR
        END IF
      END IF

      // Record turn for limit tracking
      tracker.record_turn(message.usage.input + message.usage.output)

      // After-turn callback
      IF config.after_turn is defined THEN
        config.after_turn(context.messages, message.usage)
      END IF

      EMIT TurnEnd(agent_msg, tool_results)

      // Check for steering that arrived during tool execution
      IF steering_after_tools is non-empty THEN
        pending ← steering_after_tools
        CONTINUE inner loop
      END IF

      pending ← config.get_steering_messages()

      // Exit inner loop if no tool calls and no pending messages
      IF tool_calls is empty AND pending is empty THEN
        BREAK inner loop
      END IF

    END WHILE  // inner loop

    // Check for follow-up work
    follow_ups ← config.get_follow_up_messages()
    IF follow_ups is non-empty THEN
      pending ← follow_ups
      CONTINUE outer loop
    END IF

    BREAK outer loop

  END WHILE  // outer loop

  RETURN loop_usage

END FUNCTION