ReAct Agent Design: Loop-based vs Message-based¶
This document compares two architectural approaches for implementing the ReAct (Reasoning and Acting) pattern in AI agent frameworks.
Background¶
What is ReAct?¶
ReAct is a prompting paradigm that interleaves reasoning traces with actions, enabling LLMs to solve complex tasks by:
- Thinking - Reasoning about the current state and what to do next
- Acting - Invoking tools to gather information or perform actions
- Observing - Processing tool results to inform the next thought
Question: What is the capital of the country where the Eiffel Tower is located?
Thought: I need to find where the Eiffel Tower is located.
Action: Search["Eiffel Tower location"]
Observation: The Eiffel Tower is located in Paris, France.
Thought: Now I need to find the capital of France.
Action: Search["capital of France"]
Observation: Paris is the capital of France.
Thought: I now know the final answer.
Final Answer: Paris
Reference: ReAct: Synergizing Reasoning and Acting in Language Models
Approach 1: Loop-based ReAct Agent¶
Architecture¶
The ReAct loop is implemented inside a single agent as an iterative process:
┌─────────────────────────────────────────────────────┐
│ ReActAgent │
├─────────────────────────────────────────────────────┤
│ │
│ messages = [system_prompt, user_query] │
│ │
│ while iterations < max_iterations: │
│ │ │
│ ▼ │
│ response = LLM(messages) │
│ │ │
│ ▼ │
│ tool_calls = extract_tool_calls(response) │
│ │ │
│ if not tool_calls: │
│ return response ◄── Final Answer │
│ │ │
│ for tool_call in tool_calls: │
│ result = execute_tool(tool_call) ◄── Local │
│ messages.append(ToolMessage(result)) │
│ │ │
│ iterations += 1 │
│ │
└─────────────────────────────────────────────────────┘
Implementation Pattern¶
class ReActAgent(Agent):
def __init__(self, tools: List[Tool], max_iterations: int = 10):
self.tools = {t.name: t for t in tools}
self.max_iterations = max_iterations
@processor(ChatCompletionRequest, depends_on=["llm"])
def react_loop(self, ctx: ProcessContext, llm: LLM):
messages = list(ctx.payload.messages)
for _ in range(self.max_iterations):
# LLM call
response = llm.completion(ChatCompletionRequest(messages=messages))
# Check for tool calls
tool_calls = self._extract_tool_calls(response)
if not tool_calls:
ctx.send(response) # Final answer
return
# Execute tools locally
messages.append(response.choices[0].message)
for tc in tool_calls:
result = self.tools[tc.name].execute(tc.args)
messages.append(ToolMessage(content=result, tool_call_id=tc.id))
ctx.send(MaxIterationsReached())
Characteristics¶
| Aspect | Description |
|---|---|
| Execution model | Synchronous loop within single agent |
| Tool execution | Direct function calls |
| State management | Local variable (messages list) |
| Visibility | Internal to agent |
| Latency | Minimal (no message passing overhead) |
| Complexity | Self-contained, easy to understand |
Approach 2: Message-based ReAct (Rustic AI)¶
Architecture¶
The ReAct pattern emerges from message routing between agents:
┌─────────────┐ ┌─────────────┐
│ │ ToolCallParams message │ │
│ LLMAgent │ ────────────────────────► │ ToolAgent │
│ │ │ │
│ │ ◄──────────────────────── │ │
│ │ ToolResult message │ │
└─────────────┘ (routed with history) └─────────────┘
│ │
│ ┌─────────────────┐ │
│ │ RoutingSlip │ │
└────────►│ + History │◄───────────┘
│ (session_state)│
└─────────────────┘
Message Flow¶
1. User sends ChatCompletionRequest to LLMAgent
2. LLMAgent:
- Processes request through plugins
- Calls LLM
- Extracts tool calls
- Publishes ToolCallParams message
3. RoutingSlip routes ToolCallParams to appropriate ToolAgent
4. ToolAgent:
- Executes the tool
- Publishes ToolResult message
5. RoutingSlip routes ToolResult back to LLMAgent (with history)
6. LLMAgent:
- Receives ToolResult
- Reconstructs conversation with observation
- Calls LLM again
- If more tool calls → goto step 3
- If final answer → publish response
7. Loop terminates naturally when LLMAgent produces no tool calls
Implementation Pattern¶
# No special ReActAgent needed - just configuration
# LLMAgent with ToolsManagerPlugin (already exists)
llm_agent_spec = (
AgentBuilder(LLMAgent)
.set_properties(LLMAgentConfig(
model="gpt-4",
llm_request_wrappers=[
ToolsManagerPlugin(toolset=my_tools)
]
))
.build_spec()
)
# ToolAgent that executes tools
tool_agent_spec = (
AgentBuilder(ToolExecutorAgent)
.set_properties(ToolExecutorConfig(tools=my_tools))
.build_spec()
)
# Guild with routing that creates the ReAct loop
guild_spec = (
GuildBuilder()
.add_agent(llm_agent_spec)
.add_agent(tool_agent_spec)
.set_routing_slip(RoutingSlip(
rules=[
# Tool calls go to tool agent
RoutingRule(
match=MessageMatch(format="ToolCallParams"),
destination="tool_agent"
),
# Tool results go back to LLM agent with history
RoutingRule(
match=MessageMatch(format="ToolResult"),
destination="llm_agent",
include_history=True
),
]
))
.build()
)
Characteristics¶
| Aspect | Description |
|---|---|
| Execution model | Asynchronous message passing between agents |
| Tool execution | Delegated to specialized ToolAgents |
| State management | Carried via session_state/routing_slip |
| Visibility | Every step is an observable message |
| Latency | Higher (serialization, routing, message passing) |
| Complexity | Distributed, requires routing configuration |
Detailed Comparison¶
Performance¶
| Metric | Loop-based | Message-based |
|---|---|---|
| Tool call latency | ~microseconds | ~milliseconds |
| 5-tool ReAct chain | ~5 LLM calls | ~5 LLM calls + 10 message hops |
| Memory | Single agent memory | Distributed across agents |
| Serialization | None | Per message hop |
Verdict: Loop-based wins for latency-sensitive applications.
Flexibility¶
| Capability | Loop-based | Message-based |
|---|---|---|
| Simple function tools | Easy | Requires ToolAgent wrapper |
| Stateful tools | Possible but complex | Natural (agents have state) |
| Long-running tools | Blocks the loop | Async, no blocking |
| Human-in-the-loop | Difficult | Natural (human is just an agent) |
| Tool is another LLM | Nested complexity | Same pattern |
| Cross-service tools | Requires HTTP calls | G2G messaging |
Verdict: Message-based wins for complex, heterogeneous tool ecosystems.
Observability¶
| Aspect | Loop-based | Message-based |
|---|---|---|
| Logging | Must add explicitly | Every message logged |
| Debugging | Breakpoints in loop | Message trace analysis |
| Replay | Must implement | Built-in (replay messages) |
| Auditing | Custom implementation | Message history |
| Monitoring | Agent-level metrics | Per-hop metrics |
Verdict: Message-based wins for production observability requirements.
Development Experience¶
| Aspect | Loop-based | Message-based |
|---|---|---|
| Learning curve | Low (just a while loop) | Higher (routing, messages) |
| Testing | Unit test the agent | Integration test message flows |
| Local development | Simple | Requires message infrastructure |
| Error handling | try/catch in one place | Distributed error handling |
| Debugging | Step through code | Trace through messages |
Verdict: Loop-based wins for simplicity and developer experience.
Scalability¶
| Aspect | Loop-based | Message-based |
|---|---|---|
| Horizontal scaling | Scale the agent | Scale agents independently |
| Tool isolation | Same process | Separate processes/containers |
| Resource management | Shared | Independent per agent |
| Failure isolation | Agent fails = all fails | Tool agent fails = just that tool |
Verdict: Message-based wins for large-scale production deployments.
When to Use Each Approach¶
Use Loop-based When:¶
- Building prototypes or demos
- Tools are simple, pure functions
- Latency is critical (interactive chat)
- Single-process deployment
- Team is new to agent frameworks
- Tools are tightly coupled to the reasoning
Use Message-based When:¶
- Building production systems
- Tools are complex (stateful, long-running, external)
- Observability and auditing are required
- Tools need independent scaling
- Human approval steps are needed
- Tools span multiple services or organizations
- Want to reuse existing agent infrastructure
Hybrid Approach¶
A practical architecture might combine both:
┌─────────────────────────────────────────────────────────────┐
│ ReActAgent │
├─────────────────────────────────────────────────────────────┤
│ │
│ for each iteration: │
│ response = LLM(messages) │
│ tool_calls = extract(response) │
│ │
│ for tool_call in tool_calls: │
│ if tool_call.is_simple: │
│ result = local_execute(tool_call) ◄── Fast │
│ else: │
│ ctx.send(ToolCallRequest(tool_call)) │
│ result = await ctx.receive(ToolResult) ◄── Msg │
│ │
│ messages.append(result) │
│ │
└─────────────────────────────────────────────────────────────┘
This allows: - Simple tools (calculator, string manipulation) to execute inline - Complex tools (web search, database, human approval) to use messaging
Rustic AI Recommendation¶
Given Rustic AI's philosophy of "agents as tools, tools as agents", the message-based approach is the natural fit:
- Consistency: Everything is an agent, everything communicates via messages
- Composability: Tools are first-class agents that can be composed
- Scalability: Ready for production from day one
- Flexibility: Same LLMAgent handles simple queries and complex ReAct chains
The "ReAct pattern" in Rustic AI is not a special agent type - it's an emergent behavior from routing configuration:
Required Infrastructure¶
To fully support the message-based ReAct pattern, ensure:
- ToolsManagerPlugin: Extracts tool calls and publishes them as messages
- ToolExecutorAgent: Base agent for executing tools and publishing results
- History preservation: Routing carries conversation history via session_state
- Termination detection: Natural (no tool calls = no routing = done)
Conclusion¶
| Criteria | Loop-based | Message-based |
|---|---|---|
| Simplicity | Excellent | Good |
| Performance | Excellent | Good |
| Flexibility | Good | Excellent |
| Scalability | Good | Excellent |
| Observability | Good | Excellent |
| Rustic AI alignment | Partial | Full |
For Rustic AI: Embrace the message-based approach as the primary pattern, leveraging the existing agent and messaging infrastructure. The ReAct "loop" becomes a natural consequence of how agents communicate, not special code to maintain.
For simple use cases: Consider a lightweight inline executor for trivial tools to avoid message passing overhead, while still supporting message-based tools for complex operations.