Blog

Luis Majano

April 03, 2026

Spread the word


Share your thoughts

BoxLang AI 3.0 Series Β· Part 3 of 7


A single agent is useful. An orchestra of agents is powerful.

The problem with most multi-agent frameworks is that the orchestration layer is bolted on β€” you're managing agent references manually, passing outputs between them by hand, and hoping you haven't introduced a cycle. There's no concept of hierarchy. No cycle detection. No way to ask "who's in charge here?" or "how deep in the tree am I?"

BoxLang AI 3.0 changes this. AiAgent now tracks its position in a full hierarchy tree, and sub-agents are wired as tools automatically β€” the coordinator doesn't need to know how to delegate, only that it can.


🌲 The Agent Tree

Every AiAgent carries a parentAgent property and a full set of hierarchy helpers. The relationship is bidirectional: addSubAgent() registers the sub-agent as a callable tool and sets the parent reference in one call.

coordinator = aiAgent( name: "coordinator" )
    .addSubAgent( aiAgent( name: "researcher" ) )
    .addSubAgent( aiAgent( name: "writer" ) )

// Hierarchy queries
println( coordinator.isRootAgent() )           // true
println( coordinator.getAgentDepth() )         // 0

println( researcherAgent.isRootAgent() )       // false
println( researcherAgent.getAgentDepth() )     // 1
println( researcherAgent.getAgentPath() )      // /coordinator/researcher
println( researcherAgent.getAncestors() )      // [ coordinator ]

println( writerAgent.getRootAgent().getAgentName() ) // coordinator

The full hierarchy API from the source:

// From AiAgent.bx
setParentAgent( parent )   // assign parent β€” with self-reference and cycle guards
clearParentAgent()         // detach from parent
hasParentAgent()           // boolean
isRootAgent()              // true when depth == 0
getRootAgent()             // walks up, returns the root
getAgentDepth()            // 0 = root, 1 = child, 2 = grandchild, ...
getAgentPath()             // "/coordinator/researcher"
getAncestors()             // [immediateParent, grandparent, ..., root]

Cycle Detection Built-In

Setting a parent that would create a cycle throws immediately β€” no silent infinite loops:

// From AiAgent.bx β€” setParentAgent()
AiAgent function setParentAgent( required AiAgent parent ) {
    if ( arguments.parent == this ) {
        throw( type: "AiAgent.InvalidParent", message: "An agent cannot be its own parent." )
    }
    // Walk up the proposed parent's ancestry and check for self
    var ancestor = arguments.parent
    while ( ancestor.hasParentAgent() ) {
        ancestor = ancestor.getParentAgent()
        if ( ancestor == this ) {
            throw( type: "AiAgent.CyclicParent", message: "Setting this parent would create a cycle..." )
        }
    }
    variables.parentAgent = arguments.parent
    return this
}

πŸ€– Sub-Agents as Tools

The magic of addSubAgent() is that each sub-agent is automatically wrapped as a tool the parent can call β€” no manual wiring, no custom callback code.

// From AiAgent.bx β€” createSubAgentTool()
private ITool function createSubAgentTool( required AiAgent subAgent ) {
    var agentName    = arguments.subAgent.getConfig().name
    var toolName     = "delegate_to_" & agentName.slugify()
    var toolDescription = "Delegate a task to the '#agentName#' sub-agent. "
        & "Use this tool when the task matches the sub-agent's specialty: #agentConfig.description#"

    return aiTool(
        name: toolName,
        description: toolDescription,
        callable: ( required task ) => {
            return subAgent.run( task )
        }
    ).describeArg( "task", "The task or question to delegate to the sub-agent" )
}

When addSubAgent() is called, the parent's AiModel gets a new tool named delegate_to_researcher, delegate_to_writer, etc. The LLM sees these tools in its context and decides when to use them β€” exactly the same way it decides when to call any other tool.

The coordinator doesn't need special logic. It just has more tools.


🏒 AiAgent is Now Fully Stateless

One of the most important architectural changes in 3.0: AiAgent no longer holds userId or conversationId as instance state. They are resolved per-call.

// From AiAgent.bx β€” run()
public any function run( any input = "", struct params = {}, struct options = {} ) {
    // Resolve per-call β€” no shared state
    var threadId       = arguments.options.threadId ?: variables.options.threadId ?: createUUID()
    var userId         = arguments.options.userId ?: variables.options.userId ?: ""
    var conversationId = arguments.options.conversationId ?: variables.options.conversationId ?: ""
    // ...
}

This means one agent instance can safely serve multiple concurrent users β€” no race conditions, no cross-user contamination, no per-user agent factory needed.

// One shared agent β€” many concurrent users
sharedAgent = aiAgent( name: "support", memory: aiMemory( "cache" ) )

// Each call is fully isolated
sharedAgent.run( "Hello",           {}, { userId: "alice", conversationId: "sess-1" } )
sharedAgent.run( "What did I say?", {}, { userId: "alice", conversationId: "sess-1" } ) // remembers alice
sharedAgent.run( "Hello",           {}, { userId: "bob",   conversationId: "sess-2" } ) // isolated from alice

🧠 Per-Call Identity Routing on Memory

All memory types follow the same pattern β€” add(), getAll(), clear(), and trim() all accept optional userId and conversationId:

// One memory instance, many tenants
sharedMemory = aiMemory( "cache" )

sharedMemory.add( message, userId: "alice", conversationId: "conv-1" )
sharedMemory.add( message, userId: "bob",   conversationId: "conv-2" )

// Each retrieval is tenant-scoped
aliceHistory = sharedMemory.getAll( userId: "alice", conversationId: "conv-1" )
bobHistory   = sharedMemory.getAll( userId: "bob",   conversationId: "conv-2" )

When the agent calls loadMemoryMessages() internally, it passes the resolved per-call userId and conversationId down to all attached memories. Memory is naturally tenant-isolated without any extra wiring.


πŸ—οΈ The Agent Run Lifecycle

Understanding what happens inside run() is useful when you're debugging or building middleware (more on that in Part 4). Here's the sequence:

1. Resolve threadId / userId / conversationId (per-call, not instance state)
2. Build the user message struct
3. Build the system message (description + instructions + skills + tools + MCP servers)
4. Load memory messages for this userId/conversationId
5. Assemble: [system, ...memory, userMessage]
6. Fire beforeAgentRun middleware (forward pass)
7. Fire BoxAnnounce "beforeAIAgentRun" (global event)
8. Execute via AiModel.run() β€” handles tool calling, retries, streaming
9. If suspended (HITL): checkpoint and return
10. Store assistant response in all memories (userId/conversationId scoped)
11. Fire afterAgentRun middleware (reverse pass)
12. Fire BoxAnnounce "afterAIAgentRun" (global event)
13. Return response

The system message is also cached and fingerprinted β€” if description, instructions, and skill pools haven't changed since the last call, the cached version is used instead of rebuilding. This matters for high-throughput scenarios where the same agent handles many requests.


🌊 Streaming with Multi-Agent Teams

Streaming works the same way in multi-agent setups β€” each agent can stream independently:

coordinator = aiAgent( name: "coordinator" )
    .addSubAgent( aiAgent( name: "researcher" ) )
    .addSubAgent( aiAgent( name: "writer" ) )

// Stream the coordinator's output
coordinator.stream(
    onChunk : chunk => writeOutput( chunk.choices?.first()?.delta?.content ?: "" ),
    input   : "Research and write a 500-word article about BoxLang's JVM interop",
    options : { userId: "user-123", conversationId: "session-abc" }
)

When the coordinator decides to delegate to the researcher, that sub-call happens synchronously inside the tool invocation β€” the streaming coordinator gets back the researcher's result as a tool response, then continues streaming.


πŸ”„ Suspend and Resume

When HumanInTheLoopMiddleware (covered in Part 4) suspends an agent, the state needs to be preserved. The checkpointer property handles this:

agent = aiAgent(
    name        : "finance-bot",
    middleware  : new HumanInTheLoopMiddleware(
        mode                  : "web",
        toolsRequiringApproval: [ "transferFunds" ]
    ),
    checkpointer: aiMemory( "cache" )
)

// First call β€” suspended waiting for approval
result = agent.run( "Transfer $500 to account #12345" )
// result.isSuspended() == true
// Agent auto-saves checkpoint with the threadId

// Later, after human approves via web UI
threadId = result.getData().threadId
agent.resume( "approve", threadId )

// Or reject:
agent.resume( "reject", threadId )

// Or edit the arguments:
agent.resume( "edit", threadId, { correctedArgs: { amount: 100, account: "#12345" } } )

The resume() implementation re-runs from the saved checkpoint, injecting the human's decision into the middleware context:

// From AiAgent.bx β€” resume()
any function resume( required string decision, required string threadId, struct editedData = {} ) {
    var savedState = variables.checkpointer.loadState( arguments.threadId )
    // Clear so it can't be resumed twice
    variables.checkpointer.clearState( arguments.threadId )
    // Re-run with resume context injected into options
    return run(
        savedState.input,
        savedState.params,
        savedState.options.append( { _resumeContext: {
            resumeDecision : arguments.decision,
            editedData     : arguments.editedData,
            suspendData    : savedState.suspendData
        } } )
    )
}

πŸ” Introspection

The getConfig() method gives you full visibility into an agent's state β€” useful for debugging, monitoring dashboards, and logging:

config = agent.getConfig()

// Hierarchy
println( config.agentDepth )    // 0, 1, 2, ...
println( config.agentPath )     // "/coordinator/researcher"
println( config.parentAgent )   // "coordinator" (name string)

// Capabilities
println( config.toolCount )     // total tools (including sub-agent delegation tools)
println( config.tools )         // [{ name, description }]
println( config.mcpServers )    // [{ url, toolNames }]

// Memory
println( config.memoryCount )
println( config.memories )

// Skills
println( config.activeSkillCount )
println( config.availableSkillCount )
println( config.skills )        // { activeSkills: [...], availableSkills: [...] }

// Middleware
println( config.middlewareCount )
println( config.middleware )    // [{ name, description }]

πŸš€ A Complete Multi-Agent Example

Here's a practical orchestration: a coordinator that delegates research to a specialized researcher and writing to a specialized writer, both with their own skills and tools.

// Specialist agents
researchAgent = aiAgent(
    name        : "researcher",
    description : "Expert at finding, analyzing, and summarizing information from multiple sources",
    instructions: "Always cite your sources. Prioritize recent information. Summarize findings clearly.",
    tools       : [ "searchWeb@tools", "fetchURL@tools" ],
    skills      : [ aiSkill( ".ai/skills/research-methodology/SKILL.md" ) ]
)

writerAgent = aiAgent(
    name        : "writer",
    description : "Expert at transforming research into polished, engaging prose",
    instructions: "Write for a technical audience. Be clear and direct. No fluff.",
    skills      : [ aiSkill( ".ai/skills/writing-style/SKILL.md" ) ]
)

// Coordinator with both as sub-agents
coordinator = aiAgent(
    name        : "content-coordinator",
    description : "Orchestrates research and writing to produce complete articles",
    instructions: "Break complex requests into research and writing phases. Delegate accordingly.",
    subAgents   : [ researchAgent, writerAgent ],
    memory      : aiMemory( "cache" )
)

// Inspect the hierarchy
println( coordinator.isRootAgent() )               // true
println( researchAgent.getAgentPath() )            // /content-coordinator/researcher
println( coordinator.getConfig().toolCount )       // 2 (delegate_to_researcher + delegate_to_writer)

// Run β€” coordinator decides when to delegate and to whom
response = coordinator.run(
    "Write a comprehensive article about BoxLang's AI capabilities",
    {},
    { userId: "luis", conversationId: "article-session-1" }
)

The LLM driving the coordinator sees two tools: delegate_to_researcher and delegate_to_writer. It decides to call the researcher first, gets back a detailed summary, then calls the writer with that summary and the original request, and finally synthesizes the writer's output into a final response. You didn't write any of that logic β€” the LLM figured it out from the tool descriptions.


What's Next

In Part 4, we tackle the middleware system β€” the six built-in middleware classes, how the hook lifecycle works, writing your own middleware, and the FlightRecorderMiddleware that makes AI agents properly testable in CI.

πŸ“– Full Documentation πŸ“¦Install Today: install-bx-module bx-ai 🫢Professional Support

← Previous

Next β†’

Add Your Comment

Recent Entries

Your ColdFusion Upgrade Passed. Did Your Business Workflows?

Your ColdFusion Upgrade Passed. Did Your Business Workflows?

The server restarted. The administrator shows the expected version. Your homepage loads, and a few familiar screens look fine.

Would you tell the business the upgrade is complete?

Consider an illustrative scenario: a customer submits an order after an upgrade. The application saves it and displays a confirmation page. But the confirmation email never arrives, and the scheduled process that sends the order to fulfillment has not completed.

From the browser, the transaction...

Maria Jose Herrera
Maria Jose Herrera
October 22, 2026
Updating Adobe ColdFusion isn't always just installing the latest patch

Updating Adobe ColdFusion isn't always just installing the latest patch

Keeping Adobe ColdFusion updated is an important part of maintaining a secure and reliable application environment.

But applying a ColdFusion update is not always as simple as installing the latest patch and restarting the server.

Updates can include security fixes, library upgrades, JVM-related changes, changes to the ColdFusion Administrator, updated packages, bug fixes, and changes in runtime behavior. Most updates will not cause problems for most applications. But when you'r...

Cristobal Escobar
Cristobal Escobar
October 21, 2026
BoxLang AI 3.6: Your Agent Now Has Eyes, Ears, and a Voice

BoxLang AI 3.6: Your Agent Now Has Eyes, Ears, and a Voice

Ortus Solutions today announces BoxLang AI 3.6, the release that connects the AI framework to the rest of the BoxLang productivity platform. Your agents can now drive a real browser via bx-playwright, speak and master audio via bx-audio, stream text-to-speech token by token, talk to any MCP server over stdio β€” and do all of it with the same fluent BoxLang syntax they always have.

Luis Majano
Luis Majano
October 20, 2026