cd /news/ai-agents/production-interceptors-for-solon-re… · home topics ai-agents article
[ARTICLE · art-64312] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Production Interceptors for Solon ReActAgent: Stop Loops, Retry Tools, Sanitize Observations, Stream Events

Solon AI has introduced a set of built-in ReAct interceptors in v4.0.3 to address common production agent failures such as tool loops, flaky APIs, and oversized observations. The StopLoopInterceptor, ToolRetryInterceptor, and ToolSanitizerInterceptor provide guardrails for agent resilience, while the stream() method enables live UI feedback. Developers can wire these interceptors into a ReActAgent to create a production-ready agent without custom runtime code.

read5 min views1 publishedJul 18, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @solon ai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/production-intercept…] indexed:0 read:5min 2026-07-18 ·