Here's a bug that's easy to miss until it bites someone in production: an agent turn asks for two tool calls at once, both need human approval, and only the first one actually suspends. The second one gets silently skipped. Not rejected, not queued, just gone. That's what happened before 3.4.0, and it's fixed now with batched tool-call approvals.
The old behavior
Say a turn asks the model to check the weather and email the result. Both get_weather and send_email require approval:
agent = aiAgent(
tools : [ getWeatherTool, sendEmailTool ],
middleware : [ new HumanInTheLoopMiddleware( toolsRequiringApproval: [ "get_weather", "send_email" ] ) ],
checkpointer: aiMemory( "cache" )
)
result = agent.run( "Check the weather in KC and email me the result", {}, { threadId: "t1" } )
Before 3.4.0, that call sequence played out like this:
1. Model returns two tool calls: get_weather, send_email
2. Middleware sees get_weather needs approval -> suspends the run, checkpoint saved
3. send_email never gets evaluated this pass -> not approved, not rejected, not queued
4. Human approves what they were shown (get_weather)
5. agent.resume() finishes the run -> send_email was NEVER presented for approval
The human who clicked "approve" only ever saw get_weather. They had no way to know send_email was sitting in the same turn, unreviewed. That's the bug, silent, not a crash, not a rejection, just a tool call the approval flow never surfaced.
The fix: one checkpoint, the whole batch
In 3.4.0, the same run suspends with both calls visible at once:
result = agent.run( "Check the weather in KC and email me the result", {}, { threadId: "t1" } )
writeDump( result.isSuspended() ) // true
writeDump( result ) // both get_weather and send_email show up as pending
// in this ONE checkpoint, not just the first one
Both calls belong to the same checkpoint. A human reviewing this run sees the full picture before deciding anything, not one call now and a second one that never asked.
Resuming finishes the whole batch directly against the saved assistant message. Nothing that already executed, or already got blocked, runs twice. And critically, there is no replay of the LLM call, the model isn't asked to think through the turn again just because a human took a minute to click approve.
Resolving a batch
A single decision applies uniformly to everything pending, approve the weather check and the email together:
final = agent.resume( "approve", "t1" )
// get_weather runs, then send_email runs, using the model's original arguments for both
Or resolve each call individually, say the human is fine with checking the weather but doesn't want an email sent:
final = agent.resume(
[
{ decision: "approve" }, // get_weather -> executes normally
{ decision: "reject", reason: "not needed" } // send_email -> never executes, agent
// gets a rejection result for that call instead
],
"t1"
)
The array lines up positionally with the pending calls in the checkpoint. get_weather still runs and returns real weather data to the agent's context; send_email doesn't run at all, the agent sees a rejection in its place and can react accordingly, for example by telling the user the email wasn't sent.
If you're already calling agent.resume() with a single decision string, nothing breaks. That call shape is unchanged, it's just now correct against a multi-call batch instead of only ever seeing the first pending call.
What's happening under the hood
Two new pieces make this possible. AiMiddlewareResult.defer() lets a middleware mark a pending call as "needs a decision" without stopping the provider from scanning the rest of the batch for other calls that might also need review. And a new IAiMiddleware.afterToolBatch( context ) hook fires once per turn, after every tool call in it has been decided, to build the combined suspension, or fall back to per-provider default handling if no middleware participates.
Provider coverage
This is consistent across OpenAI, Claude, Bedrock, and Cohere. Streaming batching currently covers OpenAI and Claude, the only two providers with streaming tool-call support today.
Why it matters
An agent that silently drops half its approval requests isn't just buggy, it's a security gap. A human reviewing "approved: send_email" has no idea get_weather also ran unreviewed in the same turn. Batching closes that gap by making the checkpoint reflect exactly what the turn actually asked for, nothing more and nothing quietly skipped.
Next in the series: the three-phase guardrail stack that defends the rest of the pipeline against prompt injection and data loss.
Docs: Middleware, Human-in-the-Loop Series Part 1 : https://www.ortussolutions.com/blog/boxlang-34-blog-series-part-i-gateways-one-interface-any-platform Series Part 2 : https://www.ortussolutions.com/blog/boxlang-34-blog-series-part-2-revamped-human-in-the-loop-hitl
Add Your Comment