AgentRunner’s model–Tool loop

Corresponding source

SubjectSource
Input and output contractsnanobot/agent/runner.py::AgentRunSpec, AgentRunResult
Lifecycle-hook wrappernanobot/agent/runner.py::AgentRunner.run; nanobot/agent/hook.py
Model–Tool main loopnanobot/agent/runner.py::AgentRunner._run_core
Provider requestnanobot/agent/runner.py::AgentRunner._request_model; nanobot/providers/base.py::LLMResponse
Tool executionnanobot/agent/runner.py::AgentRunner._execute_tools, AgentRunner._run_tool

Learning objectives

After this lesson, you should be able to:

  • explain AgentRunSpec and AgentRunResult;
  • draw the main branches of each AgentRunner iteration;
  • describe how a Tool Call feeds into the next model request;
  • locate context governance, checkpoints, and injection.

Prerequisites

  • Understand the complete tool-calling feedback loop.
  • Read the AgentLoop state machine and ContextBuilder lessons.
  • Know the basic concepts of callbacks and async Tasks.

1. AgentRunner does not own product-layer concerns

Its class description says:

Run a tool-capable LLM loop without product-layer concerns.

It does not decide:

  • which Channel produced the message;
  • how the Session Key is generated;
  • who receives the OutboundMessage;
  • which protocol WebUI displays;
  • where Session files are stored.

AgentLoop and surrounding modules own those concerns.

2. AgentRunSpec is the execution contract

Its core fields fall into several groups:

GroupExample fields
Inputinitial_messages, tools, model
Generationtemperature, max_tokens, reasoning_effort
Limitsmax_iterations, max_tool_result_chars, context budget
Policyconcurrent_tools, fail_on_tool_error, retry mode
Contextworkspace, session_key
Callbacksprogress, stream, retry wait, checkpoint, injection
Long-running workgoal predicate, continuation message, max-iteration finalization

It reduces AgentLoop’s configuration and current Turn state to the input Runner needs.

3. AgentRunResult is the normalized output

It includes:

  • final_content
  • messages
  • tools_used
  • usage
  • stop_reason
  • error
  • tool_events
  • had_injections

AgentLoop consumes this normalized result without understanding every Provider branch inside Runner.

4. Main loop

The core shape of AgentRunner._run_core() is:

for iteration in range(spec.max_iterations):
    ...

Each iteration roughly follows:

flowchart TD
    A["Prepare messages_for_model"] --> B["Hook: before_iteration"]
    B --> C["Request Provider"]
    C --> D["Parse reasoning / usage"]
    D --> E{"should_execute_tools?"}
    E -->|Yes| F["Append assistant tool_calls"]
    F --> G["checkpoint: awaiting_tools"]
    G --> H["Execute Tools"]
    H --> I["Append role=tool results"]
    I --> J["checkpoint: tools_completed"]
    J --> K["Drain pending injections"]
    K --> A
    E -->|No| L{"Empty response / length / error?"}
    L -->|Recovery needed| M["retry / finalization / continue"]
    M --> A
    L -->|Final text| N["Create AgentRunResult"]

5. Govern context before every request

Runner does not send the accumulated messages unchanged. It creates messages_for_model by:

  1. removing orphaned tool results;
  2. backfilling replay placeholders for missing tool results;
  3. microcompacting older content;
  4. applying the tool-result budget;
  5. snipping history;
  6. repairing tool-message boundaries again if needed.

The key design point is:

Governance changes the temporary view sent to the model, not the original message boundary used to persist this Turn’s delta.

If governance fails, Runner records the exception and attempts a minimal repair instead of immediately discarding the entire Turn.

6. Three primary Provider-response paths

Final text

No executable Tool Call exists and content is nonempty, so Runner finalizes.

Tool Call

When response.should_execute_tools is true:

  1. construct the assistant tool-call message;
  2. save an awaiting_tools checkpoint;
  3. execute tools;
  4. append one role=tool message per call;
  5. save a tools_completed checkpoint;
  6. continue to the next iteration.

Error or incomplete response

Runner may enter:

  • empty-response retry;
  • finalization retry;
  • finish_reason=length recovery;
  • Provider error;
  • fallback;
  • maximum-iteration finalization.

7. should_execute_tools is stricter than has_tool_calls

A Provider can return tool calls with a finish reason such as refusal, content filter, or error. Runner executes calls only for these finish reasons:

  • tool_calls
  • function_call
  • stop

Otherwise, it logs a warning and ignores the calls.

This is a protocol-consistency and safety boundary. Do not reduce it to:

if response.tool_calls:
    execute()

8. Checkpoint locations

Runner calls back to AgentLoop at two key stages:

awaiting_tools
  the assistant Tool Call exists
  tool results are not complete
 
tools_completed
  this batch of tool results is complete
  the next model request has not begun

These checkpoints support interruption recovery. Runner emits structured checkpoint data; the AgentLoop callback persists it in Session metadata.

9. Injection locations

A message received by the same Session during the current Turn may enter a pending queue. Runner drains it at safe boundaries:

  • after tool execution;
  • after selected errors;
  • before the final response.

An injected message is appended as a new user message. If the preceding message is also a user message, content is merged to keep the role sequence valid.

This lets a user add information during a long Turn without starting a second competing Turn.

10. Hook responsibilities

Runner uses composed Hooks for:

  • before/after run;
  • before/after iteration;
  • before executing tools;
  • progress;
  • reasoning;
  • streaming;
  • usage;
  • file-edit events.

The core loop publishes general lifecycle signals. An entry point or product layer chooses how to present them.

11. Stop conditions

Common stop_reason values include:

  • completed
  • tool_error
  • error
  • empty_final_response
  • maximum-iteration outcomes
  • continuation- or goal-related outcomes

Verify concrete values against current _run_core() branches and test assertions. An external extension should not assume that an unfamiliar stop reason can never appear.

Observation task

Record each _run_core() iteration:

iteration
len(messages)
len(messages_for_model)
response.finish_reason
len(response.tool_calls)
tools_used
stop_reason

Send:

  1. a question requiring no tool;
  2. a request that must read a workspace file;
  3. a request designed to produce incomplete tool arguments.

Compare the three paths.

Source and tests

  • nanobot/agent/runner.py
    • AgentRunSpec
    • AgentRunResult
    • AgentRunner.run
    • AgentRunner._run_core
  • nanobot/providers/base.py::LLMResponse
  • tests/agent/test_runner_core.py
  • tests/agent/test_runner_governance.py
  • tests/agent/test_runner_injections.py
  • tests/agent/test_runner_persistence.py
  • tests/agent/test_runner_goal_continue.py

Self-check

  1. Why does Runner use a temporary messages_for_model instead of modifying the original list?
  2. Why does Runner produce a checkpoint while AgentLoop persists it?
  3. Why does a completed Tool Call lead to another iteration?
  4. Why can a new message not be inserted into messages at an arbitrary moment?
  5. How do has_tool_calls and should_execute_tools differ?

Next step

Read Tool registration, validation, and execution.