{"slug": "human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run", "title": "Human-in-the-Loop for Solon ReActAgent: Pause Risky Tools Before They Run", "summary": "Solon AI has introduced a Human-in-the-Loop (HITL) interceptor for its ReActAgent that pauses sensitive tool calls—such as money transfers or data deletions—before execution, allowing human review and correction without rebuilding the conversation. The feature uses official Solon APIs, including HITLInterceptor, HITLTask, and HITLDecision, and supports conditional pausing based on tool name or argument thresholds. This enables teams to keep low-risk automation fast while adding safety gates for dangerous operations.", "body_md": "Most teams can get an AI agent to call tools. The hard part starts when the tool can move money, delete data, or send irreversible messages.\n\nThat is where Human-in-the-Loop (HITL) stops being a slide-deck concept and becomes a product requirement. Solon AI ships this as a first-class interceptor on `ReActAgent`\n\n: the agent reasons and acts as usual, but sensitive tool calls can be paused, reviewed, corrected, and resumed without rebuilding the whole conversation.\n\nThis post walks through a production-shaped pattern using only official Solon APIs (current docs against Solon v4.0.3).\n\nA naive design puts approval outside the agent:\n\nThat is usually too late. Solon places HITL on the ReAct lifecycle itself:\n\nSo the break point is exact: the model has already chosen `transfer(amount=2000)`\n\n, but the tool has not executed yet.\n\nFrom the official HITL docs, the model is intentionally small:\n\n| Piece | Role |\n|---|---|\n`HITLInterceptor` |\nDeclares which tools need review |\n`HITLTask` |\nSnapshot of tool name + args for the approver UI |\n`HITLDecision` |\nApprove / reject / skip + optional arg fixes |\n`HITL` |\nBusiness-layer helpers: `getPendingTask` , `submit` , `approve` , `reject`\n|\n\nYou do **not** invent a custom `implements Tool`\n\ninterface or wrap business agents in a fictional harness. Domain tools stay as `AbsToolProvider`\n\n+ `@ToolMapping`\n\n.\n\n``` python\nimport org.noear.solon.ai.annotation.ToolMapping;\nimport org.noear.solon.annotation.Param;\nimport org.noear.solon.ai.chat.tool.AbsToolProvider;\n\npublic class FinanceTools extends AbsToolProvider {\n\n    @ToolMapping(description = \"Query account balance by user id\")\n    public String get_balance(@Param(description = \"User id\") String userId) {\n        return \"{\\\"userId\\\":\\\"\" + userId + \"\\\",\\\"balance\\\":5200.00,\\\"currency\\\":\\\"CNY\\\"}\";\n    }\n\n    @ToolMapping(description = \"Transfer money to another account\")\n    public String transfer(\n            @Param(description = \"Source user id\") String fromUserId,\n            @Param(description = \"Target account\") String toAccount,\n            @Param(description = \"Amount\") double amount) {\n        return \"Transfer accepted: \" + amount + \" -> \" + toAccount;\n    }\n}\n```\n\nThis matches the official domain-tool style used in the e-commerce after-sales sample: provider class + annotated methods, then `defaultToolAdd(...)`\n\n.\n\n``` python\nimport org.noear.solon.ai.agent.react.ReActAgent;\nimport org.noear.solon.ai.agent.react.intercept.HITLInterceptor;\nimport org.noear.solon.ai.chat.ChatModel;\n\nChatModel chatModel = LlmUtil.getChatModel();\n\nHITLInterceptor hitl = new HITLInterceptor()\n        // always pause these tools\n        .onSensitiveTool(\"transfer\")\n        // or pause only when the amount crosses a threshold\n        .onTool(\"transfer\", (trace, args) -> {\n            double amount = Double.parseDouble(args.get(\"amount\").toString());\n            return amount > 1000 ? \"Large transfer needs human approval\" : null;\n        });\n\nReActAgent agent = ReActAgent.of(chatModel)\n        .defaultToolAdd(new FinanceTools())\n        .defaultInterceptorAdd(hitl)\n        .maxTurns(10)\n        .build();\n```\n\nTwo useful patterns from the docs:\n\n`onSensitiveTool(...)`\n\n`onTool(name, strategy)`\n\n`null`\n\nto let it passThat keeps low-risk automation fast while still fencing the dangerous path.\n\n``` python\nimport org.noear.solon.annotation.*;\nimport org.noear.solon.ai.agent.AgentSession;\nimport org.noear.solon.ai.agent.react.ReActAgent;\nimport org.noear.solon.ai.agent.react.ReActResponse;\nimport org.noear.solon.ai.agent.react.intercept.*;\nimport org.noear.solon.ai.agent.session.InMemoryAgentSession;\nimport org.noear.solon.core.handle.Result;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\n@Controller\n@Mapping(\"/ai/hitl\")\npublic class HitlWebController {\n\n    private final Map<String, AgentSession> sessions = new ConcurrentHashMap<>();\n\n    private final ReActAgent agent = ReActAgent.of(LlmUtil.getChatModel())\n            .defaultToolAdd(new FinanceTools())\n            .defaultInterceptorAdd(new HITLInterceptor()\n                    .onTool(\"transfer\", (trace, args) -> {\n                        double amount = Double.parseDouble(args.get(\"amount\").toString());\n                        return amount > 1000 ? \"Large transfer approval\" : null;\n                    }))\n            .build();\n\n    private AgentSession sessionOf(String sid) {\n        return sessions.computeIfAbsent(sid, InMemoryAgentSession::of);\n    }\n\n    @Post\n    @Mapping(\"ask\")\n    public Result ask(String sid, String prompt) throws Throwable {\n        AgentSession session = sessionOf(sid);\n        ReActResponse resp = agent.prompt(prompt).session(session).call();\n\n        if (resp.getTrace().isPending()) {\n            return Result.failure(\"REQUIRED_APPROVAL\", HITL.getPendingTask(session));\n        }\n        return Result.succeed(resp.getContent());\n    }\n\n    @Get\n    @Mapping(\"task\")\n    public HITLTask task(String sid) {\n        return HITL.getPendingTask(sessionOf(sid));\n    }\n\n    @Post\n    @Mapping(\"approve\")\n    public Result approve(String sid, String action, @Body Map<String, Object> modifiedArgs)\n            throws Throwable {\n        AgentSession session = sessionOf(sid);\n        HITLTask task = HITL.getPendingTask(session);\n        if (task == null) {\n            return Result.failure(\"No pending task\");\n        }\n\n        HITLDecision decision;\n        if (\"approve\".equals(action)) {\n            decision = HITLDecision.approve().comment(\"Verified by operator\");\n            if (modifiedArgs != null && !modifiedArgs.isEmpty()) {\n                decision.modifiedArgs(modifiedArgs);\n            }\n        } else {\n            decision = HITLDecision.reject(\"Rejected by operator\");\n        }\n\n        HITL.submit(session, task.getToolName(), decision);\n\n        // resume from the breakpoint; no need to resend the original prompt\n        ReActResponse resp = agent.prompt().session(session).call();\n        return Result.succeed(resp.getContent());\n    }\n}\n```\n\nThis is the production shape:\n\n`resp.getTrace().isPending()`\n\n, return the `HITLTask`\n\nto your admin UI`HITLDecision`\n\n`agent.prompt().session(session).call()`\n\nagain to continueImagine the user says: “Transfer 2000 to account A10086.”\n\n`transfer(...)`\n\n. The interceptor stores a `HITLTask`\n\nand interrupts.`HITL.approve(session, \"transfer\")`\n\n`HITL.reject(session, \"transfer\", \"account anomaly\")`\n\n```\nHITL.submit(session, \"transfer\",\n        HITLDecision.approve()\n                .modifiedArgs(Map.of(\"toAccount\", \"correct_888\")));\n```\n\n`call()`\n\nconsumes the decision, runs (or skips) the tool, and the agent produces the final answer from a real observation.If you later compose agents with `TeamAgent`\n\n, keep the interceptor on the `ReActAgent`\n\nthat actually owns the sensitive tool. Team collaboration can suspend through the same pending mechanism, but the tool fence still lives at the acting agent.\n\n`InMemoryAgentSession`\n\nis perfect for demos. For multi-instance deployments, swap in a shared `AgentSession`\n\nimplementation so pending tasks survive restarts and stick to the same conversation id.\n\n`onSensitiveTool`\n\nis blunt and safe. `onTool`\n\nwith a predicate keeps small automated refunds / transfers flowing while still catching the expensive mistakes.\n\nHITL’s value is operational: fewer irreversible mistakes, auditable breakpoints, human correction of bad tool args. Keep claims qualitative unless you have your own measured baseline.\n\n`AbsToolProvider`\n\n+ `@ToolMapping`\n\n`HITLInterceptor`\n\n`trace.isPending()`\n\n`HITL.submit`\n\n/ `approve`\n\n/ `reject`\n\n`AgentSession`\n\nwithout replaying the user promptOfficial docs used for this article:\n\nIf you are building agents that only chat, you may not need HITL. The moment your agent can spend money or change production state, you do.", "url": "https://wpnews.pro/news/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run", "canonical_source": "https://dev.to/solonjava/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run-10f9", "published_at": "2026-07-12 03:56:45+00:00", "updated_at": "2026-07-12 04:43:39.493112+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "developer-tools"], "entities": ["Solon AI", "ReActAgent", "HITLInterceptor", "HITLTask", "HITLDecision", "FinanceTools"], "alternates": {"html": "https://wpnews.pro/news/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run", "markdown": "https://wpnews.pro/news/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run.md", "text": "https://wpnews.pro/news/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run.txt", "jsonld": "https://wpnews.pro/news/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run.jsonld"}}