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.
What “production shape” means here
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.
1. Domain tools stay boring and real
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();
}
}
2. Mount the resilience trio
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();
What each constructor means
| 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.
3. Optional: custom sanitizer for secrets
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.
4. Prefer 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();
Chunk cheat sheet
| 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.
5. Sync path still matters
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>.
6. A practical default stack
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();
Why this order
- StopLoop protects against bad reasoning loops.
- ToolRetry absorbs transient infrastructure failures before the model overreacts.
- ToolSanitizer keeps Observation payloads short and safer for the next Reason step.
- HITL (optional) pauses irreversible tools.
- Context compression (optional) keeps long sessions alive.
7. What not to reinvent
| 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 |
Official anchors
- Built-in interceptors (5): article/1379
- ReActInterceptor lifecycle: article/1316
- call vs stream: article/1355
- ReActAgent config / options: article/1293, article/1347
- Domain tools sample: article/1285
- Package:
org.noear.solon.ai.agent.react.intercept
Closing
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.
Top comments (0)