Blog

Luis Majano

September 08, 2026

Spread the word


Share your thoughts

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

Recent Entries

BoxLang 1.17 Series Part 3 : Encrypted Config Secrets

BoxLang 1.17 Series Part 3 : Encrypted Config Secrets

Everyone knows the datasource password should not be sitting in plain text in a config file. Everyone has also, at some point, shipped exactly that, because the alternative was a pile of environment variable plumbing that nobody wanted to build on a deadline. x

Luis Majano
Luis Majano
September 08, 2026
Getting Started with BoxLang as an Alternative CFML Engine

Getting Started with BoxLang as an Alternative CFML Engine

If you have an existing ColdFusion or Lucee application, one of the first questions you may have about BoxLang is probably not:

“Should I rewrite my application in BoxLang?”

It is much simpler:

“Can BoxLang run the CFML application I already have?”

That was the focus of veteran CFML troubleshooter Charlie Arehart’s session at Into the Box 2026, Getting Started with BoxLang as an Alternative CFML Engine.

Cristobal Escobar
Cristobal Escobar
September 08, 2026
BoxLang 3.4 Blog Series Part 2: Revamped Human in The Loop HITL

BoxLang 3.4 Blog Series Part 2: Revamped Human in The Loop HITL

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.

Luis Majano
Luis Majano
September 05, 2026