If you've ever wanted an AI that doesn't just chat but actually does things — queries databases, calls APIs, makes decisions, and learns from results — you're in the right place.
In this tutorial, I'll show you how to build production-ready AI agents using Solon 4.0's ReActAgent. By the end, you'll have built an agent that can reason through complex problems, use external tools, and adapt its behavior based on real-world feedback.
What Makes ReActAgent Different?
Traditional LLMs are great at generating text, but they hit a wall when they need to interact with the real world — checking a database, fetching live data, or performing calculations.
ReActAgent (Reason + Act) breaks through that wall. It implements a cognitive loop:
Thought → Action → Observation → (repeat or finish)
The agent thinks about what to do next, acts by calling a tool, observes the result, and decides whether to continue or deliver the final answer.
This isn't just theory. Solon's ReActAgent has been used in production for automated customer support, intelligent data analysis, and multi-step workflow automation.
1. Adding the Dependency
First, add the solon-ai-agent module to your project:
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-ai-agent</artifactId>
</dependency>
Note: If you're using Solon's parent POM, the version is managed automatically. Otherwise, use the latest Solon version.
2. Building a ChatModel (The Agent's Brain)
Every agent needs a "brain" — a ChatModel that powers reasoning. Let's build one using the fluent API:
import org.noear.solon.ai.chat.ChatModel;
ChatModel chatModel = ChatModel.of("https://api.moark.com/v1/chat/completions")
.apiKey("your-api-key-here")
.model("Qwen3-32B")
.build();
You can also configure it via YAML and inject it:
solon.ai.chat:
demo:
apiUrl: "http://127.0.0.1:11434/api/chat"
provider: "ollama"
model: "llama3.2"
@Inject("${solon.ai.chat.demo}")
ChatConfig chatConfig;
ChatModel chatModel = ChatModel.of(chatConfig).build();
3. Hello World: Your First ReActAgent
Let's start simple. Create a tool and a basic agent:
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.annotation.Param;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import java.time.LocalDateTime;
// 1. Define a tool
public class TimeTool extends AbsToolProvider {
@ToolMapping(description = "Get the current date and time")
public String getCurrentTime() {
return LocalDateTime.now().toString();
}
}
// 2. Build and run the agent
public class HelloAgent {
public static void main(String[] args) throws Throwable {
ChatModel chatModel = ChatModel.of("https://api.moark.com/v1/chat/completions")
.apiKey("***")
.model("Qwen3-32B")
.build();
ReActAgent agent = ReActAgent.of(chatModel)
.role("You are a helpful assistant that can check the time and date.")
.defaultToolAdd(new TimeTool())
.build();
String response = agent.prompt("What time is it right now?")
.call()
.getContent();
System.out.println(response);
}
}
When you run this, the agent will:
-
Think: "The user wants to know the time. I have a
getCurrentTimetool." -
Act: Call
getCurrentTime(). - Observe: Get the timestamp.
- Respond: "The current time is 2026-07-04T14:30:22..."
4. A Real-World Example: Customer Support Agent
Let's build something more practical — a support agent that can query an order database and check inventory.
Step 1: Define the Tools
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.annotation.Param;
public class OrderTool extends AbsToolProvider {
@ToolMapping(description = "Query order status by order ID")
public String getOrderStatus(@Param(description = "The order ID") String orderId) {
// Simulate database lookup
if ("ORD-1001".equals(orderId)) {
return "Order ORD-1001: SHIPPED, estimated delivery July 7";
} else if ("ORD-1002".equals(orderId)) {
return "Order ORD-1002: PENDING, payment not confirmed";
}
return "Order not found: " + orderId;
}
@ToolMapping(description = "Check product inventory by product ID")
public String checkInventory(@Param(description = "The product SKU") String sku) {
// Simulate inventory check
if ("SKU-A100".equals(sku)) {
return "In stock: 42 units";
} else if ("SKU-B200".equals(sku)) {
return "Low stock: 3 units remaining";
}
return "Product not found: " + sku;
}
}
Step 2: Build the Agent with Configuration
ReActAgent supportAgent = ReActAgent.of(chatModel)
.name("customer_support")
.role("Customer Support Agent — you handle order inquiries and inventory checks.")
.defaultToolAdd(new OrderTool())
.maxTurns(8) // Max reasoning steps
.autoRethink(true) // Auto-rethink when stuck
.retryConfig(3, 1000L) // Retry 3 times, 1s delay
.modelOptions(options -> {
options.temperature(0.1); // Low temperature for deterministic decisions
})
.build();
String result = supportAgent.prompt("Customer ORD-1002 wants to know when their order will arrive. Can you check?")
.call()
.getContent();
System.out.println(result);
The agent will:
- Realize it needs to check
ORD-1002 - Call
getOrderStatus("ORD-1002") - Read the result: "PENDING, payment not confirmed"
- Explain to the customer that payment hasn't been confirmed yet
5. Adding Interceptors for Observability
In production, you need visibility into what your agent is thinking. ReActInterceptor gives you lifecycle hooks:
import org.noear.solon.ai.agent.react.ReActInterceptor;
import org.noear.solon.ai.agent.react.ReActTrace;
import org.noear.solon.ai.agent.react.task.ToolExchanger;
ReActAgent observableAgent = ReActAgent.of(chatModel)
.name("observable_agent")
.role("I help with various tasks.")
.defaultToolAdd(new OrderTool())
.defaultInterceptorAdd(new ReActInterceptor() {
@Override
public void onAgentStart(ReActTrace trace) {
System.out.println("🤖 Agent started. Prompt: " + trace.getOriginalPrompt().getUserContent());
}
@Override
public void onThought(ReActTrace trace, String thoughtContent,
AssistantMessage assistantMessage) {
System.out.println("💭 Thinking: " + thoughtContent);
}
@Override
public void onAction(ReActTrace trace, ToolExchanger toolExchanger) {
System.out.println("🛠️ Tool: " + toolExchanger.getToolName()
+ ", args: " + toolExchanger.getArgs());
}
@Override
public void onObservation(ReActTrace trace, ToolExchanger toolExchanger,
ChatMessage observation, Throwable error,
long durationMs) {
if (error != null) {
System.err.println("❌ Tool failed: " + error.getMessage());
} else {
System.out.println("✅ Tool result in " + durationMs + "ms");
}
}
@Override
public void onAgentEnd(ReActTrace trace) {
System.out.println("✅ Agent finished.");
}
})
.build();
This gives you a full audit trail of every decision your agent makes.
6. Streaming Responses
For long-running tasks, use stream() to get real-time output:
agent.prompt("Analyze our top 10 products and give me a sales summary.")
.stream()
.doOnNext(resp -> {
System.out.print(resp.getMessage().getContent());
})
.doOnComplete(() -> {
System.out.println("\n✅ Analysis complete!");
})
.subscribe();
7. Advanced: Per-Call Options
You can fine-tune behavior for individual calls using .options():
agent.prompt("Analyze this complex dataset and generate a JSON report.")
.session(mySession) // Reuse an existing session
.options(o -> o
.maxTurns(15) // More turns for complex tasks
.planningMode(true) // Enable planning phase
.temperature(0.3) // Balance creativity and precision
.outputSchema("{\"type\":\"object\",\"properties\":{...}}") // Structured output
.toolAdd(new ReportingTool()) // Add temporary tool for this call
)
.call();
Available Options at a Glance
| Category | Method | Description | Default |
|---|---|---|---|
| Control | maxTurns(int) |
Max reasoning steps | 8 |
| Control | autoRethink(boolean) |
Enable auto-rethink | false |
| Control | retryConfig(int, long) |
Retry count & delay | 3, 1000ms |
| Model | temperature(double) |
Randomness (0-2) | 0.5 |
| Model | max_tokens(long) |
Max tokens to generate | — |
| Tools | toolAdd(FunctionTool) |
Add tool temporarily | — |
| Tools | talentAdd(Talent) |
Add talent/skill | — |
| Extension | interceptorAdd(interceptor) |
Add interceptor | — |
8. Working with Sessions and Traces
ReActAgent sessions enable long-running conversations with memory:
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
import org.noear.solon.ai.agent.AgentSession;
// Create or reuse a session
AgentSession session = InMemoryAgentSession.of("user-session-123");
// First turn
String r1 = agent.prompt("Find me products under $50")
.session(session)
.call()
.getContent();
// Second turn (agent remembers context)
String r2 = agent.prompt("What's the shipping time for the cheapest one?")
.session(session)
.call()
.getContent();
// Inspect the trace
ReActTrace trace = agent.getTrace(session);
System.out.println("Total steps: " + trace.getStepCount());
// Or get a formatted summary
System.out.println(trace.getFormattedHistory());
The trace object gives you:
- Complete thought/action/observation history (
getFormattedHistory()) - Step count and metrics (
getStepCount(),getMetrics()) - Tool call arguments and results
- Original prompt and session (
getOriginalPrompt(),getSession())
9. Text Mode for Lightweight Models
Not all models support native tool calls. ReActAgent supports Text ReAct mode — it uses regex to extract Action: {json} tags from the model's text output. This makes it compatible with smaller, lighter models that don't have native tool-call support — perfect for edge deployments and cost-sensitive scenarios.
The execution style (ReActStyle) is configured at build time through ReActAgentConfig, choosing between ReActStyle.NATIVE (OpenAI-style tool_calls, default) and the lightweight ReActStyle.TEXT approach. When using Text mode, the agent parses Action: {json} tags from the model's output and executes the corresponding tool.
Complete Example: E-Commerce Support Agent
Here's a full, copy-paste-ready example:
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.ReActInterceptor;
import org.noear.solon.ai.agent.react.ReActTrace;
import org.noear.solon.ai.agent.react.task.ToolExchanger;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.annotation.Param;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.ai.chat.message.ChatMessage;
public class ECommerceSupportApp {
public static void main(String[] args) throws Throwable {
// 1. Build the model
ChatModel model = ChatModel.of("https://api.moark.com/v1/chat/completions")
.apiKey("${API_KEY}")
.model("Qwen3-32B")
.build();
// 2. Build the agent
ReActAgent agent = ReActAgent.of(model)
.name("ecommerce_support")
.role("E-commerce Support Agent")
.defaultToolAdd(new OrderTool())
.defaultToolAdd(new InventoryTool())
.defaultInterceptorAdd(new LoggingInterceptor())
.maxTurns(10)
.autoRethink(true)
.build();
// 3. Run
String answer = agent.prompt(
"Customer wants to order SKU-A100 but saw ORD-1001 hasn't arrived yet. " +
"Check both and explain the situation."
).call().getContent();
System.out.println(answer);
}
}
// Tools
class OrderTool extends AbsToolProvider {
@ToolMapping(description = "Query order status by order ID")
public String getOrderStatus(@Param(description = "Order ID") String orderId) {
// Your database logic here
return "ORD-1001: SHIPPED";
}
}
class InventoryTool extends AbsToolProvider {
@ToolMapping(description = "Check product inventory by SKU")
public String checkStock(@Param(description = "Product SKU") String sku) {
// Your inventory logic here
return "SKU-A100: 42 units in stock";
}
}
// Interceptor
class LoggingInterceptor implements ReActInterceptor {
@Override
public void onThought(ReActTrace trace, String thought,
AssistantMessage msg) {
System.out.println("💭 " + thought);
}
@Override
public void onAction(ReActTrace trace, ToolExchanger tool) {
System.out.println("🛠️ " + tool.getToolName());
}
}
Key Takeaways
- ReActAgent follows a Thought → Action → Observation loop — it reasons, acts, and learns.
-
Tools are defined with
@ToolMappingand@Paramannotations — simple POJOs. - Interceptors give you full observability into the agent's decision process.
- Per-call options let you fine-tune behavior without rebuilding the agent.
- Text ReAct mode works with lightweight models that don't support native tool calls.
- Sessions and Traces enable persistent conversations with full audit trails.
Solon's ReActAgent brings production-grade AI agent capabilities to the Java ecosystem with minimal boilerplate. The same framework design philosophy — restraint, efficiency, openness — applies here: you get powerful agent capabilities without framework lock-in.
Want to learn more? Check out the official Solon AI Agent documentation or explore the solon-ai GitHub repo.
Top comments (0)