Demo agents usually work once. Production agents fail in boring, expensive ways: they loop on the same tool call, they retry forever against a flaky API, and they paste a 40KB JSON blob back into the next thought.
Solon AI already ships a small set of built-in ReAct interceptors for those failure modes. Pair them with stream()
event chunks, and you get both guardrails and live UI feedback without inventing a custom agent runtime.
This article sticks to official Solon v4.0.3 APIs from the Agent docs.
Not a bigger prompt. Not a fake harness wrapper. Three concrete controls:
| Guardrail | Built-in interceptor | Job |
|---|---|---|
| Stop thrashing | StopLoopInterceptor |
|
| Break A-B-A-B tool loops | ||
| Survive flaky tools | ToolRetryInterceptor |
|
| Physical retry + self-heal feedback | ||
| Keep context clean | ToolSanitizerInterceptor |
|
| Truncate / desensitize observations | ||
| Show progress | stream() |
|
| Event chunks for thought / action / final answer |
HITL and context compression are part of the same family, but they already have their own deep-dives. Today we assemble the resilience trio and wire a stream UI.
Same pattern as the official after-sales sample: AbsToolProvider
@ToolMapping
. No implements Tool
.
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.annotation.Param;
public class OpsTools extends AbsToolProvider {
@ToolMapping(description = "Query order status by order id")
public String get_order(@Param(description = "Order id") String orderId) {
// Simulate occasional transport noise
if (Math.random() < 0.3) {
throw new RuntimeException("upstream timeout");
}
return "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\",\"sku\":\"keyboard\"}";
}
@ToolMapping(description = "Fetch raw logistics payload (can be large)")
public String get_track(@Param(description = "Tracking number") String trackNo) {
StringBuilder sb = new StringBuilder("{\"trackNo\":\"" + trackNo + "\",\"events\":[");
for (int i = 0; i < 200; i++) {
if (i > 0) sb.append(',');
sb.append("{\"ts\":").append(i).append(",\"msg\":\"hub-scan-").append(i).append("\"}");
}
sb.append("]}");
return sb.toString();
}
}
All five built-in interceptors live under org.noear.solon.ai.agent.react.intercept
. For a default production baseline, start with these three:
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.intercept.StopLoopInterceptor;
import org.noear.solon.ai.agent.react.intercept.ToolRetryInterceptor;
import org.noear.solon.ai.agent.react.intercept.ToolSanitizerInterceptor;
import org.noear.solon.ai.chat.ChatModel;
ReActAgent agent = ReActAgent.of(chatModel)
.name("ops_agent")
.role("Operations assistant for order and logistics lookup")
.defaultToolAdd(new OpsTools())
.maxTurns(10)
.autoRethink(true)
// 1) break repeated action thrashing in a sliding window
.defaultInterceptorAdd(new StopLoopInterceptor(2, 6))
// 2) retry flaky tool calls with linear backoff
.defaultInterceptorAdd(new ToolRetryInterceptor(3, 1000L))
// 3) truncate / clean oversized observations before they poison context
.defaultInterceptorAdd(new ToolSanitizerInterceptor(2000))
.modelOptions(o -> o.temperature(0.1))
.build();
| Class | Constructor | Meaning |
|---|---|---|
StopLoopInterceptor |
||
(maxRepeatCount, windowSize) |
||
In the last windowSize actions, the same action may appear at most maxRepeatCount times |
||
ToolRetryInterceptor |
||
(maxRetries, retryDelayMs) |
||
| Physical linear-backoff retries on tool failures; also supports logical self-heal feedback | ||
ToolSanitizerInterceptor |
||
(maxObservationLength) |
||
Observation-stage truncate / denoise; optional custom Function<ToolResult, ToolResult> |
||
Default no-arg constructors exist for all three if you want the built-in defaults first.
When tools return tokens, phones, or internal IDs, pass a sanitizer:
import org.noear.solon.ai.chat.tool.ToolResult;
ToolSanitizerInterceptor sanitizer = new ToolSanitizerInterceptor(
1500,
result -> {
// Keep structure, scrub obvious secrets before Observation is stored
String cleaned = String.valueOf(result)
.replaceAll("(?i)token\\s*[:=]\\s*\\S+", "token=***")
.replaceAll("1\\d{10}", "1**********");
// Prefer returning a ToolResult produced by your project helper
// if you have one; otherwise keep max-length truncation only.
return result;
}
);
agent = ReActAgent.of(chatModel)
.defaultToolAdd(new OpsTools())
.defaultInterceptorAdd(new StopLoopInterceptor())
.defaultInterceptorAdd(new ToolRetryInterceptor())
.defaultInterceptorAdd(sanitizer)
.build();
In practice, many teams start with length truncation only, then add a project-specific redaction function once real payloads are known.
stream()
for interactive products
call()
is perfect for jobs and batch flows. Chat UIs want event stream, not a single final string.
Official docs are explicit: stream is an event stream, not a token-only data stream. For ReActAgent
the common sequence is:
ReasonChunk
→ThoughtChunk
→ActionChunk
→ObservationChunk
→ … →ReActChunk
import org.noear.solon.ai.agent.AgentChunk;
import org.noear.solon.ai.agent.react.chunk.ActionChunk;
import org.noear.solon.ai.agent.react.chunk.ObservationChunk;
import org.noear.solon.ai.agent.react.chunk.ReActChunk;
import org.noear.solon.ai.agent.react.chunk.ReasonChunk;
import org.noear.solon.ai.agent.react.chunk.ThoughtChunk;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
import reactor.core.publisher.Flux;
InMemoryAgentSession session = InMemoryAgentSession.of("ops_job_001");
Flux<AgentChunk> chunks = agent.prompt("Order ORD_1001 looks stuck. Check status and track.")
.session(session)
.stream();
chunks.doOnNext(chunk -> {
if (chunk instanceof ReasonChunk) {
ui.appendThinking(chunk.getContent()); // gray "thinking" text
} else if (chunk instanceof ThoughtChunk) {
ui.appendThought(chunk.getContent()); // aggregated thought
} else if (chunk instanceof ActionChunk) {
ui.showToolRunning(chunk.getContent()); // tool started / args summary
} else if (chunk instanceof ObservationChunk) {
ui.showToolDone(); // tool finished
} else if (chunk instanceof ReActChunk) {
ui.appendFinalAnswer(chunk.getContent()); // final bubble
}
})
.doOnError(err -> ui.showError(err.getMessage()))
.blockLast();
| Agent | Chunk | Use in UI |
|---|---|---|
| ReActAgent | ReasonChunk |
|
| Streaming reasoning process | ||
| ReActAgent | ThoughtChunk |
|
| Aggregated thought | ||
| ReActAgent | ActionChunk |
|
| Tool is about to run / running | ||
| ReActAgent | ObservationChunk |
|
| Tool result observed | ||
| ReActAgent | PlanChunk |
|
| Planning-mode plan text | ||
| ReActAgent | ContextSizeChunk |
|
| Context size notice (v4.0.0+) | ||
| ReActAgent | ReActChunk |
|
| Final answer aggregation | ||
| Any | ||
getAgentName / getSession / getMeta |
||
| Routing + observability |
call()
throws. stream()
surfaces errors through Reactor onError
. Keep both paths intentional.
Background workers should stay simple:
String answer = agent.prompt("Order ORD_1001 looks stuck. Check status and track.")
.session(session)
.call()
.getContent();
Same interceptors apply. The difference is only delivery: one final AgentResponse
vs a live Flux<AgentChunk>
.
For most business ReAct agents, this is a sane baseline:
ReActAgent productionAgent = ReActAgent.of(chatModel)
.name("biz_agent")
.role("Business operations agent")
.defaultToolAdd(orderTools)
.defaultToolAdd(logisticsTools)
.maxTurns(10)
.autoRethink(true)
.sessionWindowSize(8)
.defaultInterceptorAdd(new StopLoopInterceptor(2, 6))
.defaultInterceptorAdd(new ToolRetryInterceptor(3, 1000L))
.defaultInterceptorAdd(new ToolSanitizerInterceptor(2000))
// add when money / irreversible actions exist:
// .defaultInterceptorAdd(new HITLInterceptor(...))
// add when long multi-turn jobs bloat trace history:
// .defaultInterceptorAdd(new ContextCompressionInterceptor(...))
.modelOptions(o -> o.temperature(0.1))
.build();
| Temptation | Prefer instead |
|---|---|
| Custom loop-breaker prompt only | StopLoopInterceptor |
| Hand-rolled sleep/retry around every tool | ToolRetryInterceptor |
| Dumping full HTTP bodies into chat history | ToolSanitizerInterceptor |
| Polling a black-box job for “thinking…” | |
stream() + chunk instanceof |
|
| Business agent wrapped in fake harness APIs | |
ReActAgent + interceptors + tools |
org.noear.solon.ai.agent.react.intercept
A production agent is not “the same demo with a better model.” It is a loop that can stop thrashing, retry safely, sanitize observations, and stream progress to the user.
In Solon, those pieces are already interceptors and event chunks. Mount them once on ReActAgent
, keep tools as AbsToolProvider
, and ship the boring reliability work instead of re-deriving it in prompts.