Skip to content

The agent loop

All paths are under src/TerraFin/.

TerraFin has its own agent: the chat panel in the TerraFin web app, served by the runtime in agent/. This page follows one turn of that agent, from the user's message to the reply. It does not apply when you call TerraFin's skills from your own agent. Components are described on Agent harness.

The loop lives in agent/runtime/loop.py (TerraFinHostedAgentLoop.submit_user_message). A turn appends the user's message, then repeats one step (call the model, run the tools it asked for) until the model answers in plain text, recovery ends the turn, or a limit is hit. The limits are constructor arguments on the loop class (max_steps 8, max_tool_calls 24, max_messages_per_session 200, max_parallel_tool_calls 1) and on RecoveryPolicy in agent/runtime/recovery.py.

Flow

Diagram: a turn
flowchart TD
    route["<b>HTTP route</b><br/>check model, relink view context,<br/>claim the turn"]
    route -- no model --> e503(["503"])
    route -- turn running --> e409(["409"])
    route --> submit["<b>submit_user_message</b><br/>check message cap, deliver finished<br/>background tasks, run before_turn hooks,<br/>append user message"]
    submit --> step["<b>Step</b>, up to max_steps<br/>check the stop flag, list tools,<br/>collect pinned context"]
    step --> last{"Last step?<br/>max_steps or stopped"}
    last -- no --> model["Call the model"]
    last -- yes --> wrap["Call the model with tools off<br/>and the wrap-up note, unless it<br/>continues a cut-off reply"]
    model --> calls{"Tool calls?"}
    calls -- "no, cut off at the<br/>output limit (up to 2 times)" --> cont["Append a continue note"] --> step
    calls -- "no, with text<br/>(a 3rd cut adds<br/>turnEnding length)" --> done(["Return the answer"])
    calls -- no, no text --> empty(["Raise: empty reply"])
    calls -- yes --> batch["Run the tool batch<br/>(next chart)"]
    batch -- recovery says stop --> fb1(["Fallback message"])
    batch -- fatal error or cap --> fail1(["Raise"])
    batch -- all answered, or stopped --> step
    wrap --> text{"Text?"}
    text -- yes --> ending(["Return the answer, with turnEnding<br/>unless it finished a<br/>continued reply"])
    text -- "no, tools asked" --> stopped{"Stopped?"}
    text -- "no, no tools" --> empty
    stopped -- yes --> stoptext(["Stopped reply"])
    stopped -- no --> any{"Any retryable<br/>error?"}
    any -- yes --> fb2(["Fallback message"])
    any -- no --> fail2(["Raise"])

    classDef ok stroke:#2e7d32,stroke-width:2px
    classDef bad stroke:#c62828,stroke-width:2px
    classDef soft stroke:#ef6c00,stroke-width:2px
    class done ok
    class e503,e409,empty,fail1,fail2 bad
    class fb1,fb2,ending,stoptext soft
Diagram: each tool call in the batch
flowchart TD
    stop{"Stop asked?"}
    stop -- yes --> unrun["Answer this call and every<br/>later one as not run"] --> judge
    stop -- no --> cap{"Over the<br/>tool-call cap?"}
    cap -- yes --> close["Close open calls"] --> fail(["Raise"])
    cap -- no --> cut{"Reply cut off at<br/>the output limit?"}
    cut -- yes --> trunc["tool_arguments_truncated<br/>result"]
    cut -- no --> dup{"Same call a<br/>3rd time?"}
    dup -- yes --> guard["Loop-guard result"]
    dup -- no --> exec["Execute"]
    exec -- fatal_error --> close
    exec -- success or<br/>retryable_error --> append["Append tool result,<br/>run after_tool hooks"]
    guard --> append
    trunc --> append
    append --> more{"More calls?"}
    more -- yes --> stop
    more -- no --> judge{"Retryable error and<br/>recovery says stop?"}
    judge -- yes --> fb(["Fallback message"])
    judge -- "no, stopped" --> halted(["Batch ends.<br/>The next step is the last."])
    judge -- "no, not stopped" --> next(["Next step"])

    classDef ok stroke:#2e7d32,stroke-width:2px
    classDef bad stroke:#c62828,stroke-width:2px
    classDef soft stroke:#ef6c00,stroke-width:2px
    class next ok
    class fail bad
    class fb,halted soft

Before the loop

The HTTP route for a new message (interface/agent/data_routes.py) runs first. It fails with 503 if no model provider is configured. It points the session at the page the user is looking at, so tools that read the view see the live page. It then claims the turn with a per-session lock (agent/runtime/inflight.py). A second turn on the same chat gets 409 with code hosted_agent_turn_in_flight. The claim is released when the turn ends, whether it succeeded or not.

submit_user_message then does four things in order:

  1. Message cap. It loads the conversation and checks the session message cap. Messages a hook re-adds each turn do not count.
  2. Finished background tasks. Each task that finished since the last turn is delivered as one internal note. See Background tasks.
  3. Turn hooks. It drops the messages hooks added last turn, then runs every hook's before_turn (agent/runtime/hooks.py). The default list is empty.
  4. User message. It appends the user's message.

One step

1. Stop check, tools and prompt

Each step first reads the session's stop flag. A stop asked for during this turn makes this step the last. A model call already running is never aborted.

The tool list is rebuilt every step from the session's agent definition (agent/tools/adapter.py).

Pinned context. Before every model call, each hook's pinned_context returns text for that one request. The provider adds it to the system text (TerraFinTurnOptions.pinned_context in agent/contracts/model_client.py). It is never written to the transcript.

Context budget. The prompt is built by ContextBudgetManager (agent/runtime/context_budget.py). It has four levels, full, default, aggressive and minimal:

  • full sends the whole normalized transcript, uncut.
  • Each later level keeps fewer older messages and cuts them shorter.
  • The current turn, from the user's message on, is always sent.

The manager picks the first level that fits the budget:

  • When the session's model metadata carries a context_window, the budget is that window minus a reserve (15%, at least 16,000 tokens). The size of a full prompt is then measured from the provider's own token count on the last full reply, plus an estimate of the messages after it.
  • Otherwise the budget is a fixed estimate of 52,000 tokens, and the size is estimated from characters.

If the provider still rejects the prompt as too long, the loop retries at the tighter levels only. If every level is rejected, the turn fails and asks the user to start a new chat. Any other model error fails the turn at once.

Spill by pointer. Below full, a long tool result is cut to its head and tail and ends with a pointer: [full result: call_id=..., N chars; read_tool_result(call_id, offset, limit)]. The model calls read_tool_result to read the exact text in slices. A slice it asked for is sent whole at every level but minimal. The stored transcript keeps every result in full.

The model client (agent/models/runtime.py) reads the model pinned on the session and routes the call to that provider. Each reply records the provider's token usage and the level its prompt was sent at.

2. Model reply

  • Text is appended as an assistant message and becomes the turn's final message.
  • Tool calls are appended as one internal tool_use message before any call runs.
  • No tool calls ends the turn. The loop returns the final message, every message added this turn, the tool results and the step count. The final reply carries turnTokenUsage, the token usage of every model call in the turn.
  • No text and no tool calls fails the turn as an empty reply.

3. Tool batch

run_tool_batch (agent/runtime/tool_batch.py) runs and answers every call in the batch, in the order the model gave them. The guru worker uses the same runner.

Parallel runs. A run of consecutive read-only calls can share threads, up to max_parallel_tool_calls. It is 1 by default, so calls run one after another. Results are appended in call order either way.

For each call:

  1. Stop check. If the user asked to stop, this call and every later one get a tool_call_unresolved result saying it was not run. The batch ends, and the next step is the last. Recovery is still judged on the calls that ran. If it says stop, the turn ends with the fallback message, marked interrupted, and no last step runs.
  2. Tool-call cap. A count across the whole turn. Past the cap, the turn fails.
  3. Cut-off reply. If the reply that asked for the calls was cut at the output limit, its arguments may be truncated. No call runs. Each gets a retryable tool_arguments_truncated result asking for smaller arguments.
  4. Loop guard. A call is identified by tool name plus arguments. The third identical call in one turn is not run. It gets a tool_call_loop_guard result telling the model to change tool, change arguments, or answer. It does not count toward recovery.
  5. Execute. ToolExecutionEngine (agent/tools/execution.py) runs the tool through the adapter, which validates arguments and classifies failures.

After each result is appended, every hook's after_tool runs.

Outcome When What the loop does
success The tool returned, including loop-guard and approval-required results Appends the result
retryable_error The tool returned an error the model can fix, such as bad arguments or an unresolved ticker Appends the result with the error and a hint, and records it for recovery
fatal_error The tool raised: unknown tool, policy denial, upstream failure, any unclassified exception Fails the turn

Approval. A call that needs human approval returns a normal result with approvalRequired: true. The model can tell the user. After the user approves, the next identical call runs. Rules: Approvals and policy.

Closing open calls. If anything ends the batch early (a fatal error, the tool-call cap, an interrupt), every call in the batch that has no result gets a tool_call_unresolved result before the error propagates. A stored tool_use with no result would read as an interrupted turn.

4. Recovery

RecoveryTracker (agent/runtime/recovery.py) counts failing batches, once per batch however many of its calls failed. After every call in the batch has its result, the loop asks the tracker. Three failing batches in a turn, or the same error in two batches, stop the turn. The loop appends a fallback message asking the user to name a ticker or keep the relevant page open, and returns it as the final message. Otherwise the model sees the error results on the next step and can try again.

5. Turn endings

A reply on the last step carries turnEnding, unless it only finishes a continued reply. A reply still cut at the output limit after the loop stops continuing it, and the reply of a stopped turn, carry one too. A fallback message carries one only when the turn was stopped. The chat panel shows a line under that reply.

turnEnding When What the last step does
step_limit The step is the last of max_steps Tools are off, and a pinned wrap-up note asks for an answer from the results in hand: what is established, what is missing, what to fetch next. Tool calls in the reply are dropped unrun.
length The reply was cut at the output limit and not continued The loop continues a cut text reply up to 2 times with an internal note: "continue exactly where it stopped". The parts are joined in the returned answer. The transcript keeps each part. A reply still cut after 2 continuations, or cut on the last step, ends the turn with length. A last step that continues a cut reply has tools off and no wrap-up note.
interrupted The user pressed Stop during the turn The next step is the last: tools off, with the wrap-up note. If that step writes no text, the reply is "Stopped at your request before an answer was written."

Every provider reports a cut-off reply as stop reason length: OpenAI's max_output_tokens, Google's MAX_TOKENS, and Anthropic's max_tokens.

6. Out of steps

If the last step still asked for tools and wrote no text, the turn ends with the same fallback message when any retryable error happened, and fails otherwise.

Stop

POST /agent/api/runtime/sessions/{id}/interrupt records a stop for the session in the session store. The route records it only while a turn may be running, and answers interruptRequested: false otherwise.

The running turn reads the flag at two points:

  • before each step
  • before each tool call, or each parallel run of calls

Only a stop asked for after the turn started counts, so a stop left by an earlier turn never ends the next one.

Background tasks

A capability marked backgroundable also appears as a start_<capability>_task tool when the agent definition allows background tasks. The default agent does.

The tool creates a pending task on the session and returns its taskId right away. The turn does not wait. A dispatcher thread in agent/runtime/hosted.py claims pending tasks with a lease and runs them on a worker pool. The widget reads task status from the session's tasks route (/agent/api/runtime/sessions/{id}/tasks).

The task's own view. A handler that takes a task argument gets a TerraFinTaskContext (agent/runtime/tasks.py):

  • heartbeat(stage) renews the lease and sets progress.stage.
  • cancelled() says whether the user cancelled the task.
  • load_checkpoint() and save_checkpoint(payload) keep resume state in the session store, apart from progress.
  • call(name, ...) runs another capability for the task.
  • model_turn is the loop's complete_model_turn, so a task's model calls get the same prompt budget.

The dispatcher also renews the lease of every task it is still running. A task whose worker was lost is claimed again, up to 3 attempts, then fails with restart_limit.

Cancel. POST /agent/api/runtime/tasks/{id}/cancel sets the task's cancel event. A task not yet started never runs. A running handler stops at its next cancelled() check.

Delivery. A completed task leaves a pending completion on the session. The next turn appends it, before the user's message, as one internal user note:

  • The note carries the task's result as JSON.
  • A task that fails after losing its worker 3 times (restart_limit) gets a note that tells the model to say it failed.
  • A task whose handler raises is marked failed in the task list. No note is delivered.

Then every hook's after_completion runs.

What is saved

Two stores, wired in agent/service/hosted.py:

  • Transcript (agent/storage/transcript_store.py): one JSONL file per session. Every message is written as it is appended.
  • Session record (agent/storage/session_store.py): a SQLite row per session. It holds the tool-call history, the pinned model, tasks, approvals and the audit log. Task checkpoints and stop requests sit in their own tables.

Because each message is written when it is appended, a turn that fails leaves everything up to the failure in the transcript, with every tool call closed.