Skip to Content

ReAct

from loomflow import ReAct

ReAct is the default agent loop: the model reasons, calls tools, reads the results, and repeats until it can answer. It is what an Agent uses when you don’t pass architecture=, and the right choice for most tasks. It follows Yao et al. 2022, ReAct: Synergizing Reasoning and Acting in Language Models.

For the conceptual page see ReAct.


Class signature

class ReAct: name: str = "react" def __init__(self, *, max_turns: int | None = None) -> None: ... def declared_workers(self) -> dict[str, Agent]: ... async def run( self, session: AgentSession, deps: Dependencies, prompt: str, ) -> AsyncIterator[Event]: ...

Constructor parameters

max_turns

Typeint | None
DefaultNone

Per-architecture override of the iteration cap. When None, ReAct uses Dependencies.max_turns (the value from the parent Agent). Passing a value here caps this architecture instance without changing the agent-level default. This is useful for nested architectures, for example when a Supervisor inside Reflexion should stop sooner than the outer loop.

from loomflow import Agent, ReAct # Cap THIS architecture at 10 turns regardless of Agent's max_turns agent = Agent( "...", model="claude-opus-4-7", architecture=ReAct(max_turns=10), )

Hitting the cap stops the run cleanly with session.interrupted = True and session.interruption_reason = "max_turns_exceeded".


Methods

declared_workers

Returns {}. ReAct is single-agent.

run

Drives the loop. Each turn:

  1. Check the budget (and the rate limiter, if one is wired). Stop or warn as needed. With auto_compact_at_tokens set, compact the conversation if it is past the threshold (emits auto_compacted), so tool results piling up within one run are summarised before the next model call.
  2. Open loom.turn telemetry span.
  3. Open the model-call span (loom.model.complete for run(), loom.model.stream for stream(); exported to OpenTelemetry as chat {model}) and call the model through the runtime (runtime.step("model_call_<turn>", ...), or runtime.stream_step(...) when streaming).
  4. Aggregate text + tool_calls + usage from chunks. Emit each chunk as MODEL_CHUNK event.
  5. If no tool calls, append assistant message and break.
  6. Otherwise, dispatch tools in parallel inside an anyio.create_task_group. Each _run_single_tool opens its own loom.tool span (execute_tool {tool} in OpenTelemetry), runs hooks → permissions → sandboxed runtime.step("tool_call_<turn>_<slot>", tool_host.call, ...). Audit tool_call and tool_result per call.
  7. Append tool result messages to the conversation; loop again.

A failing tool doesn’t poison the others: each tool’s exception is captured in its own ToolResult(ok=False) and the loop continues with the partial set.


Replay-correctness contract

ReAct wraps every external call with runtime.step(...):

  • runtime.stream_step("model_call_<turn>", ...) for each model call.
  • runtime.step("tool_call_<turn>_<slot>", ...) for each tool dispatch.
  • runtime.step("persist_episode_<turns>", ...) for the final memory write.

With SqliteRuntime / PostgresRuntime, a crashed run resumes by re-using the cached results for completed steps and only re-executing the un-completed work. See Replay and resume.


Events emitted

EventWhen
BUDGET_WARNINGBudget reached soft_warning_at threshold.
BUDGET_EXCEEDEDBudget cap exceeded; loop terminates.
MODEL_CHUNKPer streamed chunk from the model.
TOOL_CALLBefore each tool dispatch.
TOOL_RESULTAfter each tool returns (ok or error).
ERRORUnrecoverable error inside the loop.

STARTED and COMPLETED are emitted by the Agent, not by ReAct.


Source

loomflow/architecture/react.py

ReAct is the default. Agent("...", model="...") with no architecture= kwarg uses ReAct() with no overrides, taking max_turns from the agent. The implementation exercises every protocol method; read it once to understand the loop end to end.

Last updated on