# Human-in-the-Loop for Solon ReActAgent: Pause Risky Tools Before They Run

> Source: <https://dev.to/solonjava/human-in-the-loop-for-solon-reactagent-pause-risky-tools-before-they-run-10f9>
> Published: 2026-07-12 03:56:45+00:00

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.

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

: the agent reasons and acts as usual, but sensitive tool calls can be paused, reviewed, corrected, and resumed without rebuilding the whole conversation.

This post walks through a production-shaped pattern using only official Solon APIs (current docs against Solon v4.0.3).

A naive design puts approval outside the agent:

That is usually too late. Solon places HITL on the ReAct lifecycle itself:

So the break point is exact: the model has already chosen `transfer(amount=2000)`

, but the tool has not executed yet.

From the official HITL docs, the model is intentionally small:

| Piece | Role |
|---|---|
`HITLInterceptor` |
Declares which tools need review |
`HITLTask` |
Snapshot of tool name + args for the approver UI |
`HITLDecision` |
Approve / reject / skip + optional arg fixes |
`HITL` |
Business-layer helpers: `getPendingTask` , `submit` , `approve` , `reject`
|

You do **not** invent a custom `implements Tool`

interface or wrap business agents in a fictional harness. Domain tools stay as `AbsToolProvider`

+ `@ToolMapping`

.

``` python
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.ai.chat.tool.AbsToolProvider;

public class FinanceTools extends AbsToolProvider {

    @ToolMapping(description = "Query account balance by user id")
    public String get_balance(@Param(description = "User id") String userId) {
        return "{\"userId\":\"" + userId + "\",\"balance\":5200.00,\"currency\":\"CNY\"}";
    }

    @ToolMapping(description = "Transfer money to another account")
    public String transfer(
            @Param(description = "Source user id") String fromUserId,
            @Param(description = "Target account") String toAccount,
            @Param(description = "Amount") double amount) {
        return "Transfer accepted: " + amount + " -> " + toAccount;
    }
}
```

This matches the official domain-tool style used in the e-commerce after-sales sample: provider class + annotated methods, then `defaultToolAdd(...)`

.

``` python
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.intercept.HITLInterceptor;
import org.noear.solon.ai.chat.ChatModel;

ChatModel chatModel = LlmUtil.getChatModel();

HITLInterceptor hitl = new HITLInterceptor()
        // always pause these tools
        .onSensitiveTool("transfer")
        // or pause only when the amount crosses a threshold
        .onTool("transfer", (trace, args) -> {
            double amount = Double.parseDouble(args.get("amount").toString());
            return amount > 1000 ? "Large transfer needs human approval" : null;
        });

ReActAgent agent = ReActAgent.of(chatModel)
        .defaultToolAdd(new FinanceTools())
        .defaultInterceptorAdd(hitl)
        .maxTurns(10)
        .build();
```

Two useful patterns from the docs:

`onSensitiveTool(...)`

`onTool(name, strategy)`

`null`

to let it passThat keeps low-risk automation fast while still fencing the dangerous path.

``` python
import org.noear.solon.annotation.*;
import org.noear.solon.ai.agent.AgentSession;
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.ReActResponse;
import org.noear.solon.ai.agent.react.intercept.*;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
import org.noear.solon.core.handle.Result;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Controller
@Mapping("/ai/hitl")
public class HitlWebController {

    private final Map<String, AgentSession> sessions = new ConcurrentHashMap<>();

    private final ReActAgent agent = ReActAgent.of(LlmUtil.getChatModel())
            .defaultToolAdd(new FinanceTools())
            .defaultInterceptorAdd(new HITLInterceptor()
                    .onTool("transfer", (trace, args) -> {
                        double amount = Double.parseDouble(args.get("amount").toString());
                        return amount > 1000 ? "Large transfer approval" : null;
                    }))
            .build();

    private AgentSession sessionOf(String sid) {
        return sessions.computeIfAbsent(sid, InMemoryAgentSession::of);
    }

    @Post
    @Mapping("ask")
    public Result ask(String sid, String prompt) throws Throwable {
        AgentSession session = sessionOf(sid);
        ReActResponse resp = agent.prompt(prompt).session(session).call();

        if (resp.getTrace().isPending()) {
            return Result.failure("REQUIRED_APPROVAL", HITL.getPendingTask(session));
        }
        return Result.succeed(resp.getContent());
    }

    @Get
    @Mapping("task")
    public HITLTask task(String sid) {
        return HITL.getPendingTask(sessionOf(sid));
    }

    @Post
    @Mapping("approve")
    public Result approve(String sid, String action, @Body Map<String, Object> modifiedArgs)
            throws Throwable {
        AgentSession session = sessionOf(sid);
        HITLTask task = HITL.getPendingTask(session);
        if (task == null) {
            return Result.failure("No pending task");
        }

        HITLDecision decision;
        if ("approve".equals(action)) {
            decision = HITLDecision.approve().comment("Verified by operator");
            if (modifiedArgs != null && !modifiedArgs.isEmpty()) {
                decision.modifiedArgs(modifiedArgs);
            }
        } else {
            decision = HITLDecision.reject("Rejected by operator");
        }

        HITL.submit(session, task.getToolName(), decision);

        // resume from the breakpoint; no need to resend the original prompt
        ReActResponse resp = agent.prompt().session(session).call();
        return Result.succeed(resp.getContent());
    }
}
```

This is the production shape:

`resp.getTrace().isPending()`

, return the `HITLTask`

to your admin UI`HITLDecision`

`agent.prompt().session(session).call()`

again to continueImagine the user says: “Transfer 2000 to account A10086.”

`transfer(...)`

. The interceptor stores a `HITLTask`

and interrupts.`HITL.approve(session, "transfer")`

`HITL.reject(session, "transfer", "account anomaly")`

```
HITL.submit(session, "transfer",
        HITLDecision.approve()
                .modifiedArgs(Map.of("toAccount", "correct_888")));
```

`call()`

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

, keep the interceptor on the `ReActAgent`

that actually owns the sensitive tool. Team collaboration can suspend through the same pending mechanism, but the tool fence still lives at the acting agent.

`InMemoryAgentSession`

is perfect for demos. For multi-instance deployments, swap in a shared `AgentSession`

implementation so pending tasks survive restarts and stick to the same conversation id.

`onSensitiveTool`

is blunt and safe. `onTool`

with a predicate keeps small automated refunds / transfers flowing while still catching the expensive mistakes.

HITL’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.

`AbsToolProvider`

+ `@ToolMapping`

`HITLInterceptor`

`trace.isPending()`

`HITL.submit`

/ `approve`

/ `reject`

`AgentSession`

without replaying the user promptOfficial docs used for this article:

If 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.
