{"slug": "production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize", "title": "Production Interceptors for Solon ReActAgent: Stop Loops, Retry Tools, Sanitize Observations, Stream Events", "summary": "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.", "body_md": "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.\n\nSolon AI already ships a small set of **built-in ReAct interceptors** for those failure modes. Pair them with `stream()`\n\nevent chunks, and you get both **guardrails** and **live UI feedback** without inventing a custom agent runtime.\n\nThis article sticks to official Solon **v4.0.3** APIs from the Agent docs.\n\nNot a bigger prompt. Not a fake harness wrapper. Three concrete controls:\n\n| Guardrail | Built-in interceptor | Job |\n|---|---|---|\n| Stop thrashing | `StopLoopInterceptor` |\nBreak A-B-A-B tool loops |\n| Survive flaky tools | `ToolRetryInterceptor` |\nPhysical retry + self-heal feedback |\n| Keep context clean | `ToolSanitizerInterceptor` |\nTruncate / desensitize observations |\n| Show progress | `stream()` |\nEvent chunks for thought / action / final answer |\n\nHITL 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.\n\nSame pattern as the official after-sales sample: `AbsToolProvider`\n\n+ `@ToolMapping`\n\n. No `implements Tool`\n\n.\n\n``` python\nimport org.noear.solon.ai.annotation.ToolMapping;\nimport org.noear.solon.ai.chat.tool.AbsToolProvider;\nimport org.noear.solon.annotation.Param;\n\npublic class OpsTools extends AbsToolProvider {\n\n    @ToolMapping(description = \"Query order status by order id\")\n    public String get_order(@Param(description = \"Order id\") String orderId) {\n        // Simulate occasional transport noise\n        if (Math.random() < 0.3) {\n            throw new RuntimeException(\"upstream timeout\");\n        }\n        return \"{\\\"orderId\\\":\\\"\" + orderId + \"\\\",\\\"status\\\":\\\"SHIPPED\\\",\\\"sku\\\":\\\"keyboard\\\"}\";\n    }\n\n    @ToolMapping(description = \"Fetch raw logistics payload (can be large)\")\n    public String get_track(@Param(description = \"Tracking number\") String trackNo) {\n        StringBuilder sb = new StringBuilder(\"{\\\"trackNo\\\":\\\"\" + trackNo + \"\\\",\\\"events\\\":[\");\n        for (int i = 0; i < 200; i++) {\n            if (i > 0) sb.append(',');\n            sb.append(\"{\\\"ts\\\":\").append(i).append(\",\\\"msg\\\":\\\"hub-scan-\").append(i).append(\"\\\"}\");\n        }\n        sb.append(\"]}\");\n        return sb.toString();\n    }\n}\n```\n\nAll five built-in interceptors live under `org.noear.solon.ai.agent.react.intercept`\n\n. For a default production baseline, start with these three:\n\n``` python\nimport org.noear.solon.ai.agent.react.ReActAgent;\nimport org.noear.solon.ai.agent.react.intercept.StopLoopInterceptor;\nimport org.noear.solon.ai.agent.react.intercept.ToolRetryInterceptor;\nimport org.noear.solon.ai.agent.react.intercept.ToolSanitizerInterceptor;\nimport org.noear.solon.ai.chat.ChatModel;\n\nReActAgent agent = ReActAgent.of(chatModel)\n        .name(\"ops_agent\")\n        .role(\"Operations assistant for order and logistics lookup\")\n        .defaultToolAdd(new OpsTools())\n        .maxTurns(10)\n        .autoRethink(true)\n        // 1) break repeated action thrashing in a sliding window\n        .defaultInterceptorAdd(new StopLoopInterceptor(2, 6))\n        // 2) retry flaky tool calls with linear backoff\n        .defaultInterceptorAdd(new ToolRetryInterceptor(3, 1000L))\n        // 3) truncate / clean oversized observations before they poison context\n        .defaultInterceptorAdd(new ToolSanitizerInterceptor(2000))\n        .modelOptions(o -> o.temperature(0.1))\n        .build();\n```\n\n| Class | Constructor | Meaning |\n|---|---|---|\n`StopLoopInterceptor` |\n`(maxRepeatCount, windowSize)` |\nIn the last `windowSize` actions, the same action may appear at most `maxRepeatCount` times |\n`ToolRetryInterceptor` |\n`(maxRetries, retryDelayMs)` |\nPhysical linear-backoff retries on tool failures; also supports logical self-heal feedback |\n`ToolSanitizerInterceptor` |\n`(maxObservationLength)` |\nObservation-stage truncate / denoise; optional custom `Function<ToolResult, ToolResult>`\n|\n\nDefault no-arg constructors exist for all three if you want the built-in defaults first.\n\nWhen tools return tokens, phones, or internal IDs, pass a sanitizer:\n\n``` python\nimport org.noear.solon.ai.chat.tool.ToolResult;\n\nToolSanitizerInterceptor sanitizer = new ToolSanitizerInterceptor(\n        1500,\n        result -> {\n            // Keep structure, scrub obvious secrets before Observation is stored\n            String cleaned = String.valueOf(result)\n                    .replaceAll(\"(?i)token\\\\s*[:=]\\\\s*\\\\S+\", \"token=***\")\n                    .replaceAll(\"1\\\\d{10}\", \"1**********\");\n            // Prefer returning a ToolResult produced by your project helper\n            // if you have one; otherwise keep max-length truncation only.\n            return result;\n        }\n);\n\nagent = ReActAgent.of(chatModel)\n        .defaultToolAdd(new OpsTools())\n        .defaultInterceptorAdd(new StopLoopInterceptor())\n        .defaultInterceptorAdd(new ToolRetryInterceptor())\n        .defaultInterceptorAdd(sanitizer)\n        .build();\n```\n\nIn practice, many teams start with length truncation only, then add a project-specific redaction function once real payloads are known.\n\n`stream()`\n\nfor interactive products\n`call()`\n\nis perfect for jobs and batch flows. Chat UIs want **event stream**, not a single final string.\n\nOfficial docs are explicit: stream is an **event stream**, not a token-only data stream. For `ReActAgent`\n\nthe common sequence is:\n\n`ReasonChunk`\n\n→`ThoughtChunk`\n\n→`ActionChunk`\n\n→`ObservationChunk`\n\n→ … →`ReActChunk`\n\n``` python\nimport org.noear.solon.ai.agent.AgentChunk;\nimport org.noear.solon.ai.agent.react.chunk.ActionChunk;\nimport org.noear.solon.ai.agent.react.chunk.ObservationChunk;\nimport org.noear.solon.ai.agent.react.chunk.ReActChunk;\nimport org.noear.solon.ai.agent.react.chunk.ReasonChunk;\nimport org.noear.solon.ai.agent.react.chunk.ThoughtChunk;\nimport org.noear.solon.ai.agent.session.InMemoryAgentSession;\nimport reactor.core.publisher.Flux;\n\nInMemoryAgentSession session = InMemoryAgentSession.of(\"ops_job_001\");\n\nFlux<AgentChunk> chunks = agent.prompt(\"Order ORD_1001 looks stuck. Check status and track.\")\n        .session(session)\n        .stream();\n\nchunks.doOnNext(chunk -> {\n            if (chunk instanceof ReasonChunk) {\n                ui.appendThinking(chunk.getContent());      // gray \"thinking\" text\n            } else if (chunk instanceof ThoughtChunk) {\n                ui.appendThought(chunk.getContent());       // aggregated thought\n            } else if (chunk instanceof ActionChunk) {\n                ui.showToolRunning(chunk.getContent());     // tool started / args summary\n            } else if (chunk instanceof ObservationChunk) {\n                ui.showToolDone();                          // tool finished\n            } else if (chunk instanceof ReActChunk) {\n                ui.appendFinalAnswer(chunk.getContent());   // final bubble\n            }\n        })\n        .doOnError(err -> ui.showError(err.getMessage()))\n        .blockLast();\n```\n\n| Agent | Chunk | Use in UI |\n|---|---|---|\n| ReActAgent | `ReasonChunk` |\nStreaming reasoning process |\n| ReActAgent | `ThoughtChunk` |\nAggregated thought |\n| ReActAgent | `ActionChunk` |\nTool is about to run / running |\n| ReActAgent | `ObservationChunk` |\nTool result observed |\n| ReActAgent | `PlanChunk` |\nPlanning-mode plan text |\n| ReActAgent | `ContextSizeChunk` |\nContext size notice (v4.0.0+) |\n| ReActAgent | `ReActChunk` |\nFinal answer aggregation |\n| Any |\n`getAgentName` / `getSession` / `getMeta`\n|\nRouting + observability |\n\n`call()`\n\nthrows. `stream()`\n\nsurfaces errors through Reactor `onError`\n\n. Keep both paths intentional.\n\nBackground workers should stay simple:\n\n```\nString answer = agent.prompt(\"Order ORD_1001 looks stuck. Check status and track.\")\n        .session(session)\n        .call()\n        .getContent();\n```\n\nSame interceptors apply. The difference is only delivery: one final `AgentResponse`\n\nvs a live `Flux<AgentChunk>`\n\n.\n\nFor most business ReAct agents, this is a sane baseline:\n\n```\nReActAgent productionAgent = ReActAgent.of(chatModel)\n        .name(\"biz_agent\")\n        .role(\"Business operations agent\")\n        .defaultToolAdd(orderTools)\n        .defaultToolAdd(logisticsTools)\n        .maxTurns(10)\n        .autoRethink(true)\n        .sessionWindowSize(8)\n        .defaultInterceptorAdd(new StopLoopInterceptor(2, 6))\n        .defaultInterceptorAdd(new ToolRetryInterceptor(3, 1000L))\n        .defaultInterceptorAdd(new ToolSanitizerInterceptor(2000))\n        // add when money / irreversible actions exist:\n        // .defaultInterceptorAdd(new HITLInterceptor(...))\n        // add when long multi-turn jobs bloat trace history:\n        // .defaultInterceptorAdd(new ContextCompressionInterceptor(...))\n        .modelOptions(o -> o.temperature(0.1))\n        .build();\n```\n\n| Temptation | Prefer instead |\n|---|---|\n| Custom loop-breaker prompt only | `StopLoopInterceptor` |\n| Hand-rolled sleep/retry around every tool | `ToolRetryInterceptor` |\n| Dumping full HTTP bodies into chat history | `ToolSanitizerInterceptor` |\n| Polling a black-box job for “thinking…” |\n`stream()` + chunk `instanceof`\n|\n| Business agent wrapped in fake harness APIs |\n`ReActAgent` + interceptors + tools |\n\n`org.noear.solon.ai.agent.react.intercept`\n\nA 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.\n\nIn Solon, those pieces are already interceptors and event chunks. Mount them once on `ReActAgent`\n\n, keep tools as `AbsToolProvider`\n\n, and ship the boring reliability work instead of re-deriving it in prompts.", "url": "https://wpnews.pro/news/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize", "canonical_source": "https://dev.to/solonjava/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize-observations-24m2", "published_at": "2026-07-18 02:52:24+00:00", "updated_at": "2026-07-18 03:26:54.118849+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Solon AI", "ReActAgent", "StopLoopInterceptor", "ToolRetryInterceptor", "ToolSanitizerInterceptor"], "alternates": {"html": "https://wpnews.pro/news/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize", "markdown": "https://wpnews.pro/news/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize.md", "text": "https://wpnews.pro/news/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize.txt", "jsonld": "https://wpnews.pro/news/production-interceptors-for-solon-reactagent-stop-loops-retry-tools-sanitize.jsonld"}}