If a ReAct agent jumps straight into tool calls on a multi-step job, it often thrashs: repeats searches, forgets earlier observations, or finishes with a half-done answer.
Solon AI’s answer is Plan-ReAct (also called Plan-and-Solve / Plan-Execute in the docs). Before the Reason → Act loop starts, the agent first drafts a global plan, then executes against that plan with progress awareness.
This post is a practical walkthrough using only official Solon APIs (docs checked against Solon v4.0.3).
Why plan first?
Plain ReAct is local decision-making: each turn picks the next tool from the latest thought. That works for short chains (“check order → check logistics”), but large goals (“research a topic and write a structured brief”) benefit from a map:
| Without planning | With Plan-ReAct |
|---|---|
| Local next-step only | Global plan before action |
| Easy to drift after a noisy tool result | Plan acts like a navigator |
| Hard to show progress in UI |
getPlans() / getPlanProgress() expose checklist state |
| Fuzzy goals stay fuzzy | Large goal is broken into ordered sub-tasks |
Official docs emphasize three gains: anti-noise, progress awareness (Step N/M), and logical decomposition.
Minimal enablement
Planning is a single switch on ReActAgent:
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.chat.ChatModel;
ReActAgent agent = ReActAgent.of(chatModel)
.name("researcher")
.role("Senior research assistant")
// 1) turn on planning before Reason/Act
.planningMode(true)
// 2) optional: custom planning instruction (defaults are usually enough)
.planningInstruction(trace ->
"Break the task into ordered steps and execute them in sequence.")
.defaultToolAdd(new ResearchTools())
.maxTurns(12)
.autoRethink(true)
.modelOptions(o -> o.temperature(0.2))
.build();
// Agent emits [Plans] first, then reasons and acts
var resp = agent.prompt(
"Investigate recent Solon AI Agent capabilities and write a short brief " +
"covering ReActAgent, TeamAgent, and HITL."
).call();
System.out.println(resp.getContent());
You can also enable planning per call without baking it into the builder:
var resp = agent.prompt("Investigate Solon AI Agent capabilities and write a short brief")
.options(o -> o.planningMode(true)
.maxTurns(12)
.temperature(0.2))
.call();
That per-call form is useful when simple queries stay on plain ReAct, and only heavy tasks pay for an extra planning LLM turn.
Domain tools stay boring (on purpose)
Plan-ReAct does not invent a special tool type. Domain tools remain AbsToolProvider + @ToolMapping, the same pattern as the official e-commerce after-sales sample:
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.annotation.Param;
public class ResearchTools extends AbsToolProvider {
@ToolMapping(description = "Search documentation notes by keyword")
public String search_docs(@Param(description = "Search keyword") String keyword) {
if (keyword.toLowerCase().contains("react")) {
return "ReActAgent: Thought -> Action -> Observation loop; supports planningMode, HITL, maxTurns.";
}
if (keyword.toLowerCase().contains("team")) {
return "TeamAgent: multi-agent container with protocols such as SEQUENTIAL and HIERARCHICAL.";
}
if (keyword.toLowerCase().contains("hitl")) {
return "HITLInterceptor can pause sensitive tools before execution for human review.";
}
return "No matching notes for: " + keyword;
}
@ToolMapping(description = "Save a research note for later synthesis")
public String save_note(
@Param(description = "Note title") String title,
@Param(description = "Note body") String body) {
return "Saved note: " + title + " (" + body.length() + " chars)";
}
}
Wire them with defaultToolAdd(new ResearchTools()). No custom implements Tool, no fictional harness wrapper.
Observability: plans are first-class on the trace
After planning is enabled, ReActTrace keeps a plan stack. Official methods:
| Method | Use |
|---|---|
getPlans() |
Raw plan list for UI checklists |
getFormattedPlans() |
Markdown-style list injected back into prompts |
getPlanProgress() |
“You are on step 2 of 5” style progress text |
Example after a call:
var trace = resp.getTrace();
// Show checklist in an ops console / chat UI
System.out.println("Plans:\n" + trace.getFormattedPlans());
System.out.println("Progress: " + trace.getPlanProgress());
// Or iterate structured plan items if you build a richer UI
trace.getPlans().forEach(step -> System.out.println("- " + step));
This is the production win: users stop staring at a blank spinner and can see what the agent thinks it still needs to do.
Interceptor hook for planning events
If you already use ReActInterceptor for thoughts and tool calls, planning has its own callback:
import org.noear.solon.ai.agent.react.ReActInterceptor;
import org.noear.solon.ai.agent.react.ReActTrace;
import org.noear.solon.ai.chat.message.AssistantMessage;
ReActAgent agent = ReActAgent.of(chatModel)
.planningMode(true)
.defaultToolAdd(new ResearchTools())
.defaultInterceptorAdd(new ReActInterceptor() {
@Override
public void onPlan(ReActTrace trace, AssistantMessage message) {
System.out.println("Plan draft:\n" + message.getContent());
}
@Override
public void onThought(ReActTrace trace, String thoughtContent, AssistantMessage assistantMessage) {
System.out.println("Thought: " + thoughtContent);
System.out.println("Progress: " + trace.getPlanProgress());
}
})
.build();
Use onPlan to audit plan quality, push the checklist to a frontend channel, or reject obviously bad plans before heavy tool spend.
Structured output on top of planning
When the final brief must feed another service, combine planning with outputSchema (supported on SimpleAgent / ReActAgent):
public class ResearchBrief {
public String title;
public String summary;
public java.util.List<String> key_points;
public java.util.List<String> open_questions;
}
ReActAgent agent = ReActAgent.of(chatModel)
.name("brief_writer")
.role("High-precision research synthesizer")
.instruction("Use tools to gather facts, then emit only schema-compliant JSON.")
.planningMode(true)
.outputSchema(ResearchBrief.class)
.defaultToolAdd(new ResearchTools())
.maxTurns(10)
.build();
ResearchBrief brief = agent.prompt(
"Produce a research brief on Solon AI agent collaboration patterns.")
.call()
.toBean(ResearchBrief.class);
System.out.println(brief.title);
System.out.println(brief.summary);
Planning keeps the multi-step work coherent; outputSchema keeps the hand-off machine-readable.
When not to enable planning
Official guidance is clear: planning costs an extra LLM call. Skip it for trivial prompts (“what time is it?”, single-tool lookups). A practical rule:
- 1–2 tool hops, clear goal → plain ReAct
-
Multi-source research / multi-phase write-up →
planningMode(true) - Money / irreversible side effects → planning plus HITL on sensitive tools
boolean needsPlan = taskType == TaskType.RESEARCH || taskType == TaskType.MULTI_PHASE;
var call = agent.prompt(userText);
if (needsPlan) {
call = call.options(o -> o.planningMode(true).maxTurns(12));
}
var resp = call.call();
Production checklist
- Prefer stronger reasoning models for planning (docs call out heavy-logic models).
- Cap loops with
maxTurnsand keepautoRethink(true)as a safety net. - Surface
getPlans()/getPlanProgress()in the UI early—it reduces “is it stuck?” tickets. - Keep tools as
AbsToolProvider+@ToolMapping; planning is orchestration, not a new tool SPI. - Combine with HITL when a planned step reaches a sensitive tool.
- Use per-call
.options(o -> o.planningMode(true))so cheap chats stay cheap.
References (official)
- Plan-ReAct: https://solon.noear.org/article/1342
- ReActAgent options: https://solon.noear.org/article/1347
- ReActAgent config & build: https://solon.noear.org/article/1293
- Identity / outputSchema: https://solon.noear.org/article/1315
- Domain tools sample (after-sales): https://solon.noear.org/article/1285
- Project: https://github.com/opensolon/solon
Plan-ReAct will not fix a bad tool surface, but it stops multi-step agents from improvising themselves into a corner. Turn it on when the job needs a map—not when the job is a single street.
Top comments (0)