DEV Community

Solon Framework
Solon Framework

Posted on

Solon 4.0 ReActAgent: A Practical Guide to Building AI Agents That Think and Act

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
@Inject("${solon.ai.chat.demo}")
ChatConfig chatConfig;

ChatModel chatModel = ChatModel.of(chatConfig).build();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

When you run this, the agent will:

  1. Think: "The user wants to know the time. I have a getCurrentTime tool."
  2. Act: Call getCurrentTime().
  3. Observe: Get the timestamp.
  4. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The agent will:

  1. Realize it needs to check ORD-1002
  2. Call getOrderStatus("ORD-1002")
  3. Read the result: "PENDING, payment not confirmed"
  4. 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. ReActAgent follows a Thought → Action → Observation loop — it reasons, acts, and learns.
  2. Tools are defined with @ToolMapping and @Param annotations — simple POJOs.
  3. Interceptors give you full observability into the agent's decision process.
  4. Per-call options let you fine-tune behavior without rebuilding the agent.
  5. Text ReAct mode works with lightweight models that don't support native tool calls.
  6. 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)