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

> Source: <https://dev.to/solonjava/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize-observations-24m2>
> Published: 2026-07-18 02:52:24+00:00

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`

.

``` python
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:

``` python
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:

``` python
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`

``` python
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.
