DEV Community

Solon Framework
Solon Framework

Posted on

Plan-ReAct for Solon Agents: Draft the Map Before the Tool Loop

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

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

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

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

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

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

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. 1–2 tool hops, clear goal → plain ReAct
  2. Multi-source research / multi-phase write-upplanningMode(true)
  3. 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();
Enter fullscreen mode Exit fullscreen mode

Production checklist

  • Prefer stronger reasoning models for planning (docs call out heavy-logic models).
  • Cap loops with maxTurns and keep autoRethink(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 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)