AgentRunner’s model–Tool loop
Corresponding source
| Subject | Source |
|---|---|
| Input and output contracts | nanobot/agent/runner.py::AgentRunSpec, AgentRunResult |
| Lifecycle-hook wrapper | nanobot/agent/runner.py::AgentRunner.run; nanobot/agent/hook.py |
| Model–Tool main loop | nanobot/agent/runner.py::AgentRunner._run_core |
| Provider request | nanobot/agent/runner.py::AgentRunner._request_model; nanobot/providers/base.py::LLMResponse |
| Tool execution | nanobot/agent/runner.py::AgentRunner._execute_tools, AgentRunner._run_tool |
Learning objectives
After this lesson, you should be able to:
- explain
AgentRunSpecandAgentRunResult; - 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:
| Group | Example fields |
|---|---|
| Input | initial_messages, tools, model |
| Generation | temperature, max_tokens, reasoning_effort |
| Limits | max_iterations, max_tool_result_chars, context budget |
| Policy | concurrent_tools, fail_on_tool_error, retry mode |
| Context | workspace, session_key |
| Callbacks | progress, stream, retry wait, checkpoint, injection |
| Long-running work | goal 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_contentmessagestools_usedusagestop_reasonerrortool_eventshad_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:
- removing orphaned tool results;
- backfilling replay placeholders for missing tool results;
- microcompacting older content;
- applying the tool-result budget;
- snipping history;
- 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:
- construct the assistant tool-call message;
- save an
awaiting_toolscheckpoint; - execute tools;
- append one
role=toolmessage per call; - save a
tools_completedcheckpoint; - continue to the next iteration.
Error or incomplete response
Runner may enter:
- empty-response retry;
- finalization retry;
finish_reason=lengthrecovery;- 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_callsfunction_callstop
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 begunThese 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:
completedtool_errorerrorempty_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_reasonSend:
- a question requiring no tool;
- a request that must read a workspace file;
- a request designed to produce incomplete tool arguments.
Compare the three paths.
Source and tests
nanobot/agent/runner.pyAgentRunSpecAgentRunResultAgentRunner.runAgentRunner._run_core
nanobot/providers/base.py::LLMResponsetests/agent/test_runner_core.pytests/agent/test_runner_governance.pytests/agent/test_runner_injections.pytests/agent/test_runner_persistence.pytests/agent/test_runner_goal_continue.py
Self-check
- Why does Runner use a temporary
messages_for_modelinstead of modifying the original list? - Why does Runner produce a checkpoint while AgentLoop persists it?
- Why does a completed Tool Call lead to another iteration?
- Why can a new message not be inserted into
messagesat an arbitrary moment? - How do
has_tool_callsandshould_execute_toolsdiffer?