HumanInTheLoopMiddleware used to do everything itself: decide which tool calls needed approval, present the request, and wait for a decision. In 3.4.0, that logic has been pulled apart into a real subsystem, and the piece developers will feel the most is that a human's "always allow this" now actually means always.
The extraction
HITL now lives in a dedicated models/hitl/ package with two clear responsibilities split apart:
- An
IApprovalPolicydecides whether a given tool call needs a human to sign off. - A
HumanInteractionCoordinatorowns presenting the request through an attached gateway and resolving the decision.
HumanInTheLoopMiddleware is now a thin adapter over the two. The simplest case still looks exactly like before:
import bxModules.bxai.models.middleware.core.HumanInTheLoopMiddleware;
// Match by tool name, the default policy
hitl = new HumanInTheLoopMiddleware( toolsRequiringApproval: [ "deleteRecord" ] )
But now you can swap in any policy that implements IApprovalPolicy:
hitl = new HumanInTheLoopMiddleware(
policy : new RiskLevelApprovalPolicy( minLevel: "high" ),
gateway: aiGateway( "http" )
)
agent = aiAgent( middleware: [ hitl ], checkpointer: aiMemory( "cache" ) )
Built-in policies cover the common shapes out of the box: ToolNameApprovalPolicy (the default), RiskLevelApprovalPolicy, AnnotationApprovalPolicy, CallbackApprovalPolicy, and CompositeApprovalPolicy for combining several. Or write your own, an IApprovalPolicy is just a decision function with a name.
Durable grants, the actual headline feature
Before 3.4.0, an "always approve this" decision only lived as long as the process did. Restart your app, and the human gets asked again. That's fixed with a pluggable IDecisionStore:
// Explicit store
store = aiDecisionStore( "jdbc", { datasource: "myDSN", table: "ai_decisions" } )
hitl = new HumanInTheLoopMiddleware( toolsRequiringApproval: [ "placeOrder" ], decisionStore: store )
Three store implementations ship: cache, jdbc, and file. Or configure the application-wide default once and every HITL middleware in the app shares it:
// boxlang.json
{
"modules": {
"bxai": {
"settings": {
"hitl": {
"decisionStore": {
"provider": "cache",
"config": {}
}
}
}
}
}
}
That last part is by design, not a limitation: an application never attaches more than one HITL middleware at a time, so there's exactly one source of truth for "what has this human already approved."
The five decisions
Every HITL flow resolves to one of: approve, approve_always, approve_session, reject, edit, cancel. approve_always and approve_session are the two that now persist through the decision store instead of evaporating on restart.
Migration, if you're already using HITL
Existing mode: "cli" / mode: "web" configuration keeps working exactly as it did. If you're attaching a specific gateway anyway, prefer gateway: going forward, an unrecognized mode now falls back to a CLI gateway with a console warning instead of silently pretending to be "cli".
// Still works, unchanged
agent = aiAgent(
tools : [ deleteRecordTool ],
middleware: [ new HumanInTheLoopMiddleware( mode: "web", toolsRequiringApproval: [ "deleteRecord" ] ) ],
checkpointer: aiMemory( "cache" )
)
Why the split matters
Separating "should this be approved" from "how do we ask" means you can change either independently. Swap your approval policy from tool-name matching to risk-level scoring without touching how approvals get presented. Swap your gateway from CLI to HTTP without touching your approval logic. And with durable grants, the humans approving your agents' actions only have to make each decision once.
Next in the series: what happens when a single turn asks for approval on multiple tool calls at once, and why that used to silently drop half of them.
Add Your Comment