DEV Community

Solon Framework
Solon Framework

Posted on

Securing Financial Agents: Implementing Human-in-the-Loop (HITL) Workflows in Solon AI

In the era of autonomous AI Agents, letting a Large Language Model (LLM) invoke backend tools directly presents a significant risk. If an Agent receives a command to "transfer $10,000 to user X" or "permanently purge the user database," executing it without human verification could result in financial or data disaster.

To bridge the gap between AI autonomy and enterprise safety, Solon AI (v4.0.5) introduces a robust, native Human-in-the-Loop (HITL) framework. By utilizing session-aware interceptors, it intercepts high-risk tool calls, suspends execution, and exposes an API for administrators to approve, reject, skip, or modify arguments before resuming.

This guide explores the design, API structure, and practical implementation of HITL workflows in Solon AI.


The HITL Architecture in Solon AI

The HITL architecture is designed around the ReAct (Reasoning and Acting) loop. Instead of letting the Agent execute tools immediately after reasoning, an interceptor chain checks the arguments against predefined safety rules.

[User Request] 
      │
      ▼
 [ReActAgent] (Reasoning)
      │
      ▼
  [ToolCall] ────► [HITLInterceptor] (Evaluate Args)
                          │
                ┌─────────┴─────────┐
         [Allowed / Safe]    [High-Risk / Suspend]
                │                   │
                ▼                   ├─► Push HITLPendingEvent
          [Execute Tool]            ├─► Set Session to PENDING
                │                   ▼
                │           [Wait for Human Decision]
                │                   │
                │                   ▼  (via API Controller)
                │           [HITL.submit(...) / approve / reject]
                │                   │
                │                   ├─► Push HITLDecidedEvent
                │                   ├─► Apply modifiedArgs / skip / reject
                │                   ▼
                └───────────────► [Resume Execution]
Enter fullscreen mode Exit fullscreen mode

At its core, Solon AI provides three main components:

  1. HITLInterceptor: ReAct interceptor that registers target tools and runs strategies to decide if a request should be suspended.
  2. HITL: A static helper class offering convenient APIs for your controller to inspect pending tasks and submit human decisions.
  3. HITLDecision: An object containing the decision type (Approve, Reject, Skip), comments, and optional parameter modifications.

Step 1: Defining a High-Risk Tool

Let's assume we have a Java tool mapped to execute database transfers. In Solon AI, this is defined as a standard tool provider:

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

public class FinancialTools extends AbsToolProvider {

    @ToolMapping(name = "transfer", description = "Transfer money to another account")
    public String transfer(String targetAccount, double amount) {
        // Execute the transfer transaction
        System.out.printf("Successfully transferred $%.2f to %s\n", amount, targetAccount);
        return "SUCCESS";
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Configuring the HITL Interceptor

An interceptor is attached to the ReActAgent during construction. We register our safety evaluation rules using the HITLStrategy interface:

import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.intercept.HITLInterceptor;
import org.noear.solon.ai.agent.react.intercept.HITLStrategy;

// Setup ReActAgent with HITL Interceptor
ReActAgent agent = ReActAgent.of(llmModel)
    .defaultToolAdd(new FinancialTools())
    .defaultInterceptorAdd(new HITLInterceptor()
        // Define a strategy for the "transfer" tool
        .onTool("transfer", (trace, args) -> {
            double amount = Double.parseDouble(args.get("amount").toString());
            // If the amount exceeds $5,000, trigger HITL interception
            if (amount > 5000) {
                return "The transfer amount exceeds the single-transaction limit of $5000.";
            }
            return null; // Safe to proceed without intervention
        })
    )
    .build();
Enter fullscreen mode Exit fullscreen mode

When the LLM outputs a tool call to transfer with an amount greater than 5000, the HITLInterceptor intercepts it, blocks execution, and tags the AgentSession as Pending.


Step 3: Handling Suspension in the Controller

When exposing the Agent through an HTTP endpoint, your controller must handle the PENDING state. Under the hood, Solon AI stores the task snapshots in the session context, which can be fetched using the HITL helper class.

import org.noear.solon.annotation.*;
import org.noear.solon.ai.agent.AgentSession;
import org.noear.solon.ai.agent.react.ReActResponse;
import org.noear.solon.ai.agent.react.intercept.HITL;
import org.noear.solon.ai.agent.react.intercept.HITLTask;
import org.noear.solon.core.handle.Result;

@Controller
@Mapping("/api/agent")
public class AgentController {

    private final ReActAgent agent; // Injected or constructed

    @Post
    @Mapping("/ask")
    public Result ask(String sessionId, String prompt) throws Throwable {
        AgentSession session = getOrCreateSession(sessionId);

        // Run the agent loop
        ReActResponse resp = agent.prompt(prompt)
                                  .session(session)
                                  .call();

        // If the execution was intercepted by HITL, return the pending task details
        if (session.isPending()) {
            HITLTask pendingTask = HITL.getPendingTask(session);
            return Result.failure(403, "REQUIRED_HUMAN_APPROVAL", pendingTask);
        }

        return Result.succeed(resp.getContent());
    }
}
Enter fullscreen mode Exit fullscreen mode

If the agent decides to transfer $12,000, the REST API returns a status showing that approval is required, along with the HITLTask metadata (including the unique callUuid, the tool name, and the arguments).


Step 4: Submitting Human Decisions

Administrators need an endpoint to approve, reject, or bypass the transaction. With Solon AI v4.0.5, decisions are bound to a specific callUuid to handle parallel or batch tool calls.

Here is how you handle approval, rejection, skipping, and parameter modification:

import org.noear.solon.ai.agent.react.intercept.HITLDecision;
import java.util.Map;

@Post
@Mapping("/approve")
public Result approve(String sessionId, String callUuid, String action, @Body Map<String, Object> modifiedArgs) {
    AgentSession session = getSession(sessionId);

    // 1. Locate the precise task using callUuid
    HITLTask task = HITL.getPendingTaskByCallUuid(session, callUuid);
    if (task == null) {
        return Result.failure("No pending task found for UUID: " + callUuid);
    }

    HITLDecision decision;
    if ("approve".equalsIgnoreCase(action)) {
        decision = HITLDecision.approve().comment("Approved by operations manager.");

        // Human Override: If the manager adjusted the transfer amount down, apply it!
        if (modifiedArgs != null && !modifiedArgs.isEmpty()) {
            decision.modifiedArgs(modifiedArgs);
        }
    } else if ("reject".equalsIgnoreCase(action)) {
        decision = HITLDecision.reject("Rejected: Suspected fraudulent activity.");
    } else {
        // Skip: Bypass this tool call, returning a custom mock message to the LLM
        decision = HITLDecision.skip("Skipped: Submitter bypassed this step.");
    }

    // 2. Submit the decision back to the session
    HITL.submit(session, task, decision);

    // 3. Resume execution: Trigger agent call with a blank prompt
    try {
        ReActResponse resp = agent.prompt()
                                  .session(session)
                                  .call();
        return Result.succeed(resp.getContent());
    } catch (Throwable e) {
        return Result.failure(e.getMessage());
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside the Decision Lifecycle

When HITL.submit(...) is called, the decision is stored in the AgentSession context under _hitl_decision_<callUuid>. When the Agent resumes:

  1. onAgentStart(trace): The interceptor intercepts the resume event. It scans all pending tasks in the session. If all have decisions, it sets the routing directly to the ACTION phase, skipping another LLM reasoning step.
  2. ActionTask: When executing the tool task:
    • On Approve: The tool is invoked. If modifiedArgs are provided, the interceptor clones the original arguments to preserve history, applies the modified parameters, and invokes the tool.
    • On Reject: The tool is blocked. A rejection message is injected into the exchange context as if the tool failed, steering the LLM to handle the denial gracefully.
    • On Skip: The tool is not run. The comment from the decision is directly returned to the LLM as the mock result of the tool.

Batch Tool Call & AlwaysAllow Settings

Solon AI's HITL framework also supports advanced features:

  • Batch Approvals: If the model decides to invoke three tools simultaneously, HITL.getPendingTasks(session) returns a list of tasks. You can submit individual decisions using HITL.submitAll(session, Map<callUuid, HITLDecision>) or approve them all with HITL.approveAll(session).
  • alwaysAllow: In some cases, a manager wants to say "Approve this transfer, and trust this specific action for the rest of the session." By submitting HITLDecision.approve(true) (or .alwaysAllow(true)), the framework automatically registers a session-level rule to bypass subsequent checks for this specific tool.

Summary

The Human-in-the-Loop mechanism in Solon AI (v4.0.5) transforms risky AI operations into secure, auditable, and human-guided workflows. By placing interceptors right at the boundary of tool execution and enabling real-time argument overrides, Solon AI ensures that autonomous agents remain safely within corporate compliance guardrails.

Top comments (0)