ReActAgent¶
Overview¶
The ReActAgent implements the ReAct (Reasoning and Acting) pattern, enabling AI agents to solve complex tasks through an iterative cycle of reasoning, acting (tool use), and observing results.
ReAct is a prompting paradigm that interleaves reasoning traces with actions, allowing LLMs to:
1. Think - Reason about the current state and determine next steps
2. Act - Invoke tools to gather information or perform actions
3. Observe - Process tool results to inform subsequent reasoning
Architecture¶
Loop-Based Design¶
The ReActAgent uses a loop-based architecture where tool execution happens synchronously within the agent:
flowchart TD
A[User Query] --> B[ReActAgent]
B --> C{Iteration Loop}
C --> D[Call LLM]
D --> E{Tool Calls?}
E -->|Yes| F[Execute Tools Locally]
F --> G[Append Tool Results]
G --> C
E -->|No| H[Return Final Answer]
H --> I[ChatCompletionResponse]
Key Components¶
- ReActAgent: Main agent class that runs the ReAct loop
- ReActAgentConfig: Configuration including model, toolset, and plugin settings
- ReActToolset: Abstract base class defining tool specifications and execution
- ReActSkillSpec: Optional metadata for progressively disclosing related tools
- ReActStep: Model representing a single reasoning step (stored in trace)
Basic Usage¶
Simple ReActAgent¶
from rustic_ai.core.guild.agent_ext.depends.dependency_resolver import DependencySpec
from rustic_ai.core.guild.agent_ext.depends.llm.models import (
ChatCompletionRequest,
ChatCompletionResponse,
UserMessage,
)
from rustic_ai.core.guild.builders import AgentBuilder
from rustic_ai.llm_agent.react import MathToolset, ReActAgent, ReActAgentConfig
from rustic_ai.testing.helpers import wrap_agent_for_testing
# Build agent spec
agent_spec = (
AgentBuilder(ReActAgent)
.set_id("react_agent")
.set_name("Calculator Agent")
.set_description("Solves math problems")
.set_properties(
ReActAgentConfig(
model="gpt-5-nano",
max_iterations=10,
toolset=MathToolset(),
)
)
.build_spec()
)
# Test the agent
agent, results = wrap_agent_for_testing(
agent_spec,
dependency_map={
"llm": DependencySpec(
class_name="rustic_ai.litellm.agent_ext.llm.LiteLLMResolver",
properties={"model": "gpt-5-nano"}
)
}
)
# Create and send a message (typically done via pytest fixtures)
from rustic_ai.core.messaging.core.message import Message, AgentTag
from rustic_ai.core.guild.dsl import GuildTopics
from rustic_ai.core.utils.gemstone_id import GemstoneGenerator
from rustic_ai.core.utils.basic_class_utils import get_qualified_class_name
generator = GemstoneGenerator(1)
request = ChatCompletionRequest(
messages=[UserMessage(content="What is 15 * 23?")]
)
message = Message(
id_obj=generator.get_id(),
sender=AgentTag(name="test", id="test-1"),
topics=GuildTopics.DEFAULT_TOPICS,
payload=request.model_dump(),
format=get_qualified_class_name(ChatCompletionRequest),
)
agent._on_message(message)
# Get response from results list
assert len(results) == 1
response = ChatCompletionResponse.model_validate(results[0].payload)
print(response.choices[0].message.content) # "345"
YAML Configuration¶
You can also define ReActAgent in YAML:
id: react_calculator
name: Calculator Agent
description: Solves mathematical problems using tools
class_name: rustic_ai.llm_agent.react.ReActAgent
properties:
model: gpt-5-nano
max_iterations: 10
toolset:
kind: my_package.CalculatorToolset
Configuration Options¶
ReActAgentConfig¶
| Property | Type | Default | Description |
|---|---|---|---|
model |
str \| Models |
Required | LLM model identifier |
toolset |
ReActToolset |
Required | Toolset providing available tools |
max_iterations |
int |
10 |
Maximum reasoning iterations |
system_prompt |
str |
Default ReAct prompt | Custom system prompt |
temperature |
float |
None |
Sampling temperature (0.0-2.0) |
max_tokens |
int |
None |
Max tokens per LLM response |
tool_disclosure |
all \| skills |
all |
Expose every tool immediately or select skill groups first |
skill_activation_followup |
auto \| required |
auto |
Let the model choose its next action or require a disclosed domain tool immediately after activation |
skill_activation_observation |
standard \| explicit |
standard |
Optionally state explicitly that activation did not answer the request |
skill_disclosure_progression |
sticky \| expand_after_first_success |
sticky |
Keep only activated groups visible or expose the full catalog after the first successful domain action |
failure_handling |
safe \| legacy |
safe |
Apply interpreted-result safety and bounded recovery or preserve the unrestricted historical loop |
base_url |
str |
None |
Custom LLM API base URL |
timeout |
float |
None |
Request timeout in seconds |
Plugin Configuration¶
ReActAgent supports multiple plugin types at different execution levels:
Loop-Level Plugins¶
Run once for the entire ReAct loop:
config = ReActAgentConfig(
model="gpt-4",
toolset=my_toolset,
# Runs ONCE before loop starts
request_preprocessors=[LoggingPreprocessor()],
# Wraps entire loop (pre once, post once)
llm_request_wrappers=[CostTrackingWrapper()],
# Runs ONCE after loop completes
response_postprocessors=[ValidationPostprocessor()],
)
Iteration-Level Plugins¶
Run for each LLM call within the loop:
config = ReActAgentConfig(
model="gpt-4",
toolset=my_toolset,
# Runs BEFORE each LLM call
iteration_preprocessors=[ContextInjector()],
# Wraps each individual LLM call
iteration_wrappers=[PerCallLogger()],
# Runs AFTER each LLM call
iteration_postprocessors=[StepEvaluator()],
)
Tool-Level Plugins¶
Run for each tool execution:
config = ReActAgentConfig(
model="gpt-4",
toolset=my_toolset,
# Wraps each tool execution
tool_wrappers=[
ToolCachingWrapper(),
ToolValidationWrapper(),
ToolErrorHandler(),
],
)
Creating Custom Toolsets¶
Simple Toolset¶
from typing import List
from pydantic import BaseModel, Field
from rustic_ai.core.guild.agent_ext.depends.llm.tools_manager import ToolSpec
from rustic_ai.llm_agent.react.toolset import ReActToolset
class SearchParams(BaseModel):
query: str = Field(description="Search query")
class SearchToolset(ReActToolset):
"""Toolset for web search."""
api_key: str = Field(description="API key for search service")
def get_toolspecs(self) -> List[ToolSpec]:
return [
ToolSpec(
name="web_search",
description="Search the web for information",
parameter_class=SearchParams
)
]
def execute(self, tool_name: str, args: BaseModel) -> str:
if tool_name == "web_search":
# Implement search logic
return perform_search(args.query, self.api_key)
raise ValueError(f"Unknown tool: {tool_name}")
Composite Toolset¶
Combine multiple toolsets:
from rustic_ai.llm_agent.react.toolset import CompositeToolset
composite_toolset = CompositeToolset(
toolsets=[
CalculatorToolset(),
SearchToolset(api_key="..."),
WeatherToolset(),
]
)
config = ReActAgentConfig(
model="gpt-4",
toolset=composite_toolset,
)
DuckDuckGo Instant Answers¶
rusticai-llm-agent includes an opt-in toolset for factual Instant Answer lookups:
properties:
toolset:
kind: rustic_ai.llm_agent.react.toolsets.duckduckgo.DuckDuckGoInstantAnswerToolset
timeout_seconds: 10
The duckduckgo_instant_answer tool returns bounded JSON containing an answer,
abstract, definition, and related topics when available. It is not a general web
search API and explicitly reports when no Instant Answer exists. Sandboxed agents
must be granted egress to api.duckduckgo.com; defining the toolset does not grant
network access by itself.
Use concise entity or topic queries such as France, Python (programming
language), or Nineteen Eighty-Four, rather than natural-language questions.
Safe failure handling permits one meaningfully different retry and will not
present model memory as a verified fact after no_result.
MediaWiki search¶
MediaWikiSearchToolset provides key-free, bounded search over English
Wikipedia for stable encyclopedic facts:
properties:
toolset:
kind: rustic_ai.llm_agent.react.toolsets.mediawiki.MediaWikiSearchToolset
timeout_seconds: 10
The mediawiki_search tool performs one fixed-host MediaWiki request that
returns ranked article titles, plain-text introductory extracts, canonical
URLs, and disambiguation markers. Results are attributed to English Wikipedia.
The tool reports ambiguous when the top article is a disambiguation page, so
safe failure handling asks the user to select a meaning rather than guessing.
This is an encyclopedia lookup, not general web search. It should not be used
for current events, live data, shopping, or information outside Wikipedia.
Sandboxed agents need egress only to en.wikipedia.org.
Combine the built-in lookup and offline tools when an agent needs all four:
properties:
toolset:
kind: rustic_ai.llm_agent.react.toolset.CompositeToolset
toolsets:
- kind: rustic_ai.llm_agent.react.toolsets.math.MathToolset
- kind: rustic_ai.llm_agent.react.toolsets.temporal.TemporalToolset
- kind: rustic_ai.llm_agent.react.toolsets.mediawiki.MediaWikiSearchToolset
- kind: rustic_ai.llm_agent.react.toolsets.duckduckgo.DuckDuckGoInstantAnswerToolset
Deterministic math and unit conversion¶
MathToolset provides offline arithmetic and physical-unit conversion without
evaluating Python code:
The calculate tool accepts a restricted arithmetic expression, including
sum, mean, median, floor, and ceil. Percentages must be explicit
arithmetic, such as (150 - 120) / 120 * 100. The convert_units tool accepts
a value plus compatible source and target units. Its schema advertises canonical
unit IDs to constrain model choices while runtime validation remains compatible
with documented aliases. US and Imperial volume units must be identified
explicitly.
Deterministic date and time¶
TemporalToolset provides offline current-time, timezone conversion, calendar
arithmetic, weekday, and Monday-to-Friday business-day operations:
Timezone arguments use IANA names such as America/Vancouver; when omitted,
the toolset discovers the operating system timezone. Date inputs use
YYYY-MM-DD, datetime inputs use ISO format, and business-day calculations do
not include jurisdictional holidays. Ambiguous or nonexistent daylight-saving
wall times return a clarification request instead of choosing an offset.
ReActAgentConfig.failure_handling defaults to safe. Safe mode interprets
built-in structured results, suppresses duplicate execution, permits one
corrective retry, returns clarification for ambiguous units, and preserves
verified partial results. Set it to legacy only when unrestricted historical
loop behavior is required. Custom toolsets remain opaque unless they implement
the optional interpret_result hook.
Progressive Tool Disclosure¶
tool_disclosure="skills" is opt-in. A toolset may return lightweight
ReActSkillSpec records from get_skill_specs(). The agent initially exposes a
single activate_tool_skill meta-tool. Each call activates exactly one skill and
adds that skill's tools to later model rounds. The selector remains available,
so another skill can be activated only when a later step needs it; the model is
not required to predict the complete skill set upfront. Several independent
singular activations may be requested in one response. Domain tools emitted
before they have appeared in a model request are rejected without execution.
The ordinary max_iterations setting is the only expansion bound.
The built-in groups are math_and_units, dates_and_time, and
knowledge_lookup. MediaWiki and DuckDuckGo contribute separate tools to the
shared lookup group. CompositeToolset merges same-name groups only when their
descriptions agree, applies explicit disclosure ordering, and rejects conflicting
groups, tools claimed by the wrong child, or partial metadata that would leave
tools permanently hidden. Toolsets with no skill metadata retain the existing
all-tools behavior even when skill mode is requested.
config = ReActAgentConfig(
model="local/small-model",
toolset=CompositeToolset(toolsets=[MathToolset(), TemporalToolset()]),
tool_disclosure="skills",
skill_activation_observation="explicit",
skill_disclosure_progression="expand_after_first_success",
)
expand_after_first_success preserves a small first routing decision but avoids
requiring the model to predict every later skill in a multi-tool workflow. The
default sticky mode keeps disclosure scoped for applications where minimizing
the visible tool surface is more important.
Custom system_prompt remains a full replacement. The selector's description
contains the complete incremental-disclosure contract, so replacement prompts do
not need to duplicate it.
Or in YAML:
properties:
toolset:
kind: rustic_ai.llm_agent.react.toolset.CompositeToolset
toolsets:
- kind: rustic_ai.llm_agent.react.toolsets.math.MathToolset
- kind: my_package.SearchToolset
api_key: ${SEARCH_API_KEY}
- kind: my_package.WeatherToolset
api_key: ${WEATHER_API_KEY}
Response Format¶
The ReActAgent returns a standard ChatCompletionResponse with the reasoning trace embedded:
# After calling agent._on_message(message) and getting response from results:
response = ChatCompletionResponse.model_validate(results[0].payload)
# Access the final answer
answer = response.choices[0].message.content
# Access the ReAct trace from provider_specific_fields
provider_fields = response.choices[0].provider_specific_fields
if provider_fields:
trace = provider_fields.get("react_trace", [])
iterations = provider_fields.get("iterations", 0)
# Each trace step contains:
for step in trace:
print(f"Thought: {step['thought']}")
print(f"Action: {step['action']}")
print(f"Input: {step['action_input']}")
print(f"Observation: {step['observation']}")
Advanced Features¶
Custom System Prompts¶
custom_prompt = """You are a financial analyst agent.
Use the provided tools to analyze financial data and provide insights.
Always cite your sources and show your calculations."""
config = ReActAgentConfig(
model="gpt-4",
toolset=financial_toolset,
system_prompt=custom_prompt,
)
Tool Call Wrapping¶
Implement custom logic around tool execution:
from pydantic import BaseModel
from rustic_ai.llm_agent.plugins.tool_call_wrapper import (
ToolCallWrapper,
ToolCallResult,
ToolSkipResult,
)
class CachingWrapper(ToolCallWrapper):
"""Cache tool results to avoid redundant calls."""
def __init__(self):
self.cache = {}
def preprocess(
self, agent, ctx, tool_name: str, tool_input: BaseModel
):
cache_key = f"{tool_name}:{tool_input.model_dump_json()}"
if cache_key in self.cache:
# Skip execution, return cached result
return ToolSkipResult(output=self.cache[cache_key])
return tool_input
def postprocess(
self,
agent,
ctx,
tool_name: str,
tool_input: BaseModel,
tool_output: str
) -> ToolCallResult:
cache_key = f"{tool_name}:{tool_input.model_dump_json()}"
self.cache[cache_key] = tool_output
return ToolCallResult(output=tool_output)
config = ReActAgentConfig(
model="gpt-4",
toolset=my_toolset,
tool_wrappers=[CachingWrapper()],
)
Error Handling¶
Handle tool errors gracefully:
class ErrorHandlingWrapper(ToolCallWrapper):
"""Provide helpful error messages to the LLM."""
def preprocess(self, agent, ctx, tool_name: str, tool_input: BaseModel):
return tool_input # Pass through unchanged
def postprocess(
self,
agent,
ctx,
tool_name: str,
tool_input: BaseModel,
tool_output: str
) -> ToolCallResult:
try:
# Validate result
validate_result(tool_output)
return ToolCallResult(output=tool_output)
except Exception as e:
# Return error as observation
error_msg = f"Tool execution failed: {str(e)}"
return ToolCallResult(output=error_msg)
Integration with Skills¶
ReActAgent integrates seamlessly with the Skills module. See Skills Documentation for details.
Quick example:
from rustic_ai.skills import SkillToolset
# Load skills
toolset = SkillToolset.from_paths([
Path("/tmp/rustic-skills/pdf"),
Path("/tmp/rustic-skills/csv"),
])
config = ReActAgentConfig(
model="gpt-4",
toolset=toolset,
system_prompt=toolset.get_combined_system_prompt(),
)
Best Practices¶
1. Set Appropriate Max Iterations¶
# For simple queries
config = ReActAgentConfig(model="gpt-4", toolset=tools, max_iterations=5)
# For complex multi-step problems
config = ReActAgentConfig(model="gpt-4", toolset=tools, max_iterations=20)
2. Provide Clear Tool Descriptions¶
ToolSpec(
name="fetch_user",
description=(
"Fetch user profile by ID. "
"Returns JSON with name, email, and creation date. "
"Use when you need user information."
),
parameter_class=FetchUserParams
)
3. Use Iteration Wrappers for Debugging¶
from rustic_ai.llm_agent.plugins.llm_call_wrapper import LLMCallWrapper
from rustic_ai.core.guild.agent_ext.depends.llm.models import ChatCompletionRequest
class DebugWrapper(LLMCallWrapper):
iteration: int = 0
def preprocess(self, agent, ctx, request: ChatCompletionRequest, llm):
self.iteration += 1
logger.debug(f"Iteration {self.iteration}: {request.messages[-1]}")
return request
def postprocess(self, agent, ctx, final_prompt, llm_response, llm):
logger.debug(f"Response: {llm_response.choices[0].message.content[:100]}...")
return None # Return None to not emit additional messages
4. Handle Tool Timeouts¶
def execute(self, tool_name: str, args: BaseModel) -> str:
try:
result = run_with_timeout(
lambda: self._execute_tool(tool_name, args),
timeout=30
)
return result
except TimeoutError:
return "Tool execution timed out. Please try again."
Comparison: Loop-based vs Message-based¶
| Aspect | ReActAgent (Loop) | Message-based |
|---|---|---|
| Latency | Low (microseconds) | Higher (message hops) |
| Simplicity | Simple while loop | Requires routing config |
| Use Case | Self-contained tools | Distributed tool agents |
| Observability | Custom logging | Built-in message trace |
| Scalability | Single process | Independent scaling |
Choose ReActAgent when: - Tools are simple, pure functions - Latency is critical - Single-process deployment - Prototyping or demos
Choose message-based when: - Tools are complex or stateful - Need distributed execution - Human-in-the-loop required - Production observability needed
See ReAct Agent Design for detailed architectural comparison.
Testing¶
from rustic_ai.testing.helpers import wrap_agent_for_testing
from rustic_ai.core.guild.agent_ext.depends.llm.models import (
ChatCompletionResponse,
FinishReason,
)
agent_spec = (
AgentBuilder(ReActAgent)
.set_id("test_agent")
.set_name("Test Agent")
.set_description("Agent for testing")
.set_properties(config)
.build_spec()
)
agent, results = wrap_agent_for_testing(
agent_spec,
dependency_map={"llm": llm_dependency}
)
# Send test message (see examples above for message construction)
agent._on_message(test_message)
# Assert on response
assert len(results) == 1
response = ChatCompletionResponse.model_validate(results[0].payload)
assert response.choices[0].finish_reason == FinishReason.stop
assert "expected answer" in response.choices[0].message.content
# Verify trace
trace = response.choices[0].provider_specific_fields["react_trace"]
assert len(trace) == expected_iterations
Reference¶
Module: rustic_ai.llm_agent.react¶
Classes:
- ReActAgent - Main agent implementation
- ReActAgentConfig - Configuration model
- ReActStep - Reasoning step model
- ReActToolset - Abstract toolset base class
- CompositeToolset - Combine multiple toolsets
- ReActSkillSpec - Progressive-disclosure metadata
Constants:
- DEFAULT_REACT_SYSTEM_PROMPT - Default ReAct system prompt