DEV Community

Solon Framework
Solon Framework

Posted on

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

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

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

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

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:

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

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

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

Why this order

  1. StopLoop protects against bad reasoning loops.
  2. ToolRetry absorbs transient infrastructure failures before the model overreacts.
  3. ToolSanitizer keeps Observation payloads short and safer for the next Reason step.
  4. HITL (optional) pauses irreversible tools.
  5. 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

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)