Skip to content

ToolLoop and ReAct

Both implement the classic agent loop: the model decides, tools act, results go back, repeat until the model answers.

class ToolLoop:
    def __init__(
        self,
        registry,
        *,
        system="",
        model=None,
        max_turns=10,
        params=None,
        tool_names=None,
        name=None,
        stream=False,
    ): ...

ToolLoop

ToolLoop uses the provider's native function calling. This is the default choice.

"""Quickstart: a tool-using agent in ~25 lines. Runs offline with a scripted model."""

import asyncio

from aaa.algos import ToolLoop
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Budget, Engine
from aaa.tools import ToolHandler, ToolRegistry, tool


@tool
def multiply(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b


def scripted_model(request):  # stands in for a real LLM (see "LLM gateways" to swap it)
    last = request.messages[-1]
    if last.role == "user":  # first turn: the model asks for the tool
        return tool_call("multiply", a=17, b=23)
    return text(f"17 x 23 = {last.content}")  # second turn: it answers using the tool result


async def main() -> None:
    registry = ToolRegistry([multiply])
    async with Engine() as engine:
        LLMHandler(FakeLLMClient(scripted_model)).attach(engine)  # who answers LLM requests
        ToolHandler(registry).attach(engine)  # who runs tool calls
        outcome = await engine.run(
            ToolLoop(registry),
            {"task": "What is 17 times 23?"},
            budget=Budget(max_steps=20, deadline_s=30),
        )
    print(outcome.kind.value, "-", outcome.value["text"])


asyncio.run(main())

Loop. Each turn sends the conversation and tool specs to the model.

  • If the reply contains tool calls: run them (concurrently), append their results, go again.
  • If the reply is plain text: that is the answer; the run succeeds with {"text": ..., "turns": n}.
  • If max_turns replies have asked for tools and there is still no answer: the run ends no_solution.
  • If the LLM call fails (aaa.llm.failed): the run ends failed with the error type and message.

Bad tool calls are conversation, not crashes. Malformed JSON arguments, a missing tool, a failing tool, a timeout or a denied approval all return to the model as an error message in the tool result, so it can correct itself. The number of such round trips is bounded by max_turns and your Budget.

Starting messages. {"task": "..."} produces [system?, user]. Pass your own transcript instead:

await engine.run(
    agent,
    {
        "messages": [
            {"role": "system", "content": "You are a careful analyst."},
            {"role": "user", "content": "Summarize ticket 481."},
        ]
    },
)
Parameters
Parameter Default
tools required The ToolRegistry; its specs are sent with every request.
system "" System prompt (ignored if you pass messages).
model None Model override for this algorithm.
max_turns 10 Maximum model replies that request tools before giving up.
params {} Passed to every request (temperature, max_tokens, stop, ...).
tool_names all Expose only these tools.
name class default Rename the instance (required to run two ToolLoops side by side in a cluster).
stream False Emit aaa.llm.delta events.

Long conversations stay cheap

Each continuing request stores only the new messages and a pointer to the previous request, and the loop's state is a handful of counters. A 400-turn run is ~1.3 MB of log, and cost per turn is constant. See Performance.

ReAct

ReAct is the text-protocol variant for models or gateways without native tool calling. It instructs the model to write

Thought: I need the population of Tokyo
Action: population
Action Input: {"city": "Tokyo"}

then stops generation (stop=["\nObservation:"]), runs the tool, and feeds back Observation: .... It ends when the model writes Final Answer: ....

  • The system prompt, including tool names, descriptions and argument schemas, is generated for you (override with system=).
  • If both an action and a final answer appear, whichever comes first wins.
  • If the reply has no protocol markers at all, it is treated as the final answer (models often just answer).
  • Action Input that is not a JSON object is returned as an Observation: Error ... so the model can retry. It does not call a tool.
  • One tool call per turn (the protocol has no parallel form).
ReAct(registry, max_turns=8, params={"temperature": 0})

Prefer ToolLoop whenever native tool calling is available: it is more reliable, supports parallel calls and does not depend on the model following a text format.

Tuning checklist

  • Set params={"temperature": 0} for repeatable behavior.
  • Give tools clear docstrings. The model chooses tools from the descriptions.
  • Keep max_turns small (5-10) and let the Budget be the real backstop.
  • If the model keeps calling tools it does not need, shorten the registry with tool_names.