← Back to list

Inversion of Control in Multi-agent Solutions | Context Management | When Sub-agents take control…

A famous Hollywood saying — “Don’t call us, we will call you”- that was quoted in 1980s Xerox PARC’s Multi-windows OS paper describing…

Suresh Kandula · 2026-06-13 21:44 · 5 claps · 13.4 min read paywalled
#inversion-of-control #multi-agent-systems #conversational-ai #context-engineering #backend-for-frontend
Open on Medium ↗
Wiki topics: AGT · AI Agents BIZ · Business Strategy 🌐 · Web Development

Inversion of Control in Multi-agent Solutions | Context Management | When Sub-agents take control of the conversation

A famous Hollywood saying — “Don’t call us, we will call you”- that was quoted in 1980s Xerox PARC’s Multi-windows OS paper describing MESA.

The concept was that, in multi-window environments, the sub-window would notify the parent “executive window” when it needed to take control back. MESA was used to build other Xerox commercial GUIs such as Star and Alto, which inspired Apple to build the Macintosh.

MESA prescribed a principle which was later generalized to Inversion of Control, and this foundational design pattern led to modern application servers (e.g., IBM Websphere), container run-times, and the popular Spring Framework.

Why are we talking about this esoteric topic now? When building multi-agent solutions across the enterprise, between multiple teams and geographical boundaries, and ownership structures, I run into these philosophical questions:

  1. When and why does a Supervisory (Orchestrator) agent cede control of the conversation to a Sub-Agent?
  2. Why Peer-to-Peer Agent networks don’t scale (e.g., n peers require O(n²) connections, competition of messages, confusion matrix), and most multi-agent networks are hierarchical in nature (How many times you head Sub-agents as tools Vs. Agent Mesh with no supervisor)
  3. What would take the inversion of control (IoC) pattern to work in these patterns, both within the enterprise and across the enterprise?

So, I took this inspiration from MESA’s “executive window” and “sub-window,” equivalent to the now multi-agent systems. Let’s check it out.

Photo by Shubham Dhage on Unsplash

Photo by Shubham Dhage on Unsplash

I wanted to build a multi-agent system in which the sub-agents take control over the conversation for a large Multi-turn (N-turn) scenario and notify the orchestrator/supervisor agent when it is done and return control back to the parent.

You can do this in A2A, which we will talk about towards the end, but not fit-for-purpose for intra-org and dynamic situations. Also, a bulky JSON protocol.

In standard multi-agent patterns, you will see the following 5 common patterns of interactions.

Multi-agent Typical Design Patterns

Multi-agent Typical Design Patterns

When there is a parent-child relationship pattern, more often than not, the supervisor or parent agent is always in the loop (or message chain) relative to the end user of the agentic system, and it is almost always synchronous and fewer turns, or even 1-turn (Sub-agents as tools).

The Child Agent (or sub-agent) is never left alone during a multi-turn conversation and in many patterns above the sub-agent is returning after 1-turn!

I am going to use this example of a customer service contact center to showcase the behavior I am trying to build.

Motivation — Let me use this call center workflow as an example.

Depicting a typical call center flow

Depicting a typical call center flow

In this typical flow, the specialized “sub-agents” would talk to the caller for an extended period of time before any resolutions or escalations, and we would close the call with generic steps again.

This is an Orchestrator or Root Agent Pattern listed above. But typical implementations have some drawbacks. Concerns:

  1. Latency on every turn, orchestrator prompt gets bloated with conversation history
  2. Risk of the orchestrator “helpfully” modifying messages it shouldn’t based on the system prompt or pre-determined resolution criteria
  3. Static rules on terminating the sub-agent turns (to avoid infinite cycles)
  4. Orchestrator is not specialized in all sorts of knowledge and artifacts produced by sub-agents, and could cause degradation in performance before ending the overall caller session

So, I have combined a few foundational patterns to come up with a composite solution to address these concerns.

Orchestrator + Delegation + Blackboard Pattern to demonstrate IoC

Before we dig into the code, some concepts, and then a demo screenshot & reference to the repo for your offline review.

I am implementing a common orchestrator using a 3-sub-agent pattern, with a few twists.

Setup: A brokerage company called Perfect Trading Co. An agentic contact center implementation with 3 Sub-agents:

Sub-Agents (each owns its domain knowledge base):          
│    • Account Balance Agent   — balances, holdings, P&L      
│    • Trade Execution Agent   — orders, fills, history        
│    • Technical Platform Agent — app issues, 2FA, browsers

Orchestrator to Sub-agent pattern with IoC

Orchestrator to Sub-agent pattern with IoC

Let me demo how this works, and you can spin one up for yourself with a Claude API key for testing, using the Repo below and the instructions.

Reference Repository: https://github.com/sureshkvn/tradingCo

Welcome message with commands

Welcome message with commands

We will discuss the concept of the blackboard in a bit. Let me demo how IoC works.

I asked a simple question that the orchestrator could just answer: “What is the website for Perfect Trading Co?”

Orchestrator answered the question

Orchestrator answered the question

I asked about holidays and working hours on President’s Day. The orchestrator had the knowledge to answer it by itself.

Orchestrator answered the question

Orchestrator answered the question

I asked a question about my cash balance!

The Orchestrator is set up with 3 sub-agents, and it knows to delegate the conversation for a multi-turn to the “Account Balances” Sub-agent. You can see the trace of the delegation. We will come back to see how this works.

The key thing here:

The orchestrator will no longer track the message history for every turn unless we raise an event as agreed by the “contract”.

Account Balance Sub-agent answered the question

Account Balance Sub-agent answered the question

Account Balances sub-agent provided the account balance data. Asked if there are any other topics it could help with?

Account Balance Sub-agent answered the question

Account Balance Sub-agent answered the question

I asked about my IRA Account maxing out. It answered the question. Since it knows how to handle it. No Orchestrator involvement!

Account Balance Sub-agent summarized the session and sent it back to Orchestrator

Account Balance Sub-agent summarized the session and sent it back to Orchestrator

When I said I don’t have any account-related questions, or if the sub-agent determines the conversation ended successfully, it can send the control back to Orchestrator.

This session could last 20–30 turns. The overall context of the orchestrator has not increased. No cognitive load on the supervisor.

You could see that the sub-agent summarized the conversation and shared the notes back to the supervisor/orchestrator as resolved.

If I asked any follow-up questions, example — Hotline number, this came from the orchestrator itself, again.

Back to Orchestrator — answering the question

Back to Orchestrator — answering the question

This is a quick demo of the multi-agent solution I have.

Let’s talk about the code and architecture and how this is fit-for-purpose for IoC solution.

Orchestrator + IoC + Blackboard (Shared Session) + Eventing

Orchestrator + IoC + Blackboard (Shared Session) + Eventing

What is unique about this architecture that is not in a typical parent-child multi-agent solution?

  1. Parent delegates the conversation to the child agent (and stays out of the loop)
  2. Single Shared Memory (called Blackboard)
  3. Single Shared Eventing (called EventBus)

In an intra-enterprise scenario, the implementation consists of a single BFF consisting of Single Auth, Memory, and an Async Event consumption layer.

In my code, here are a few key components:

Here is my singleton session state called “Blackboard” which can be scaled using the sessionId as a key in a distributed cache such as Redis.

import type { SessionState } from "./types.js";

// In-memory shared blackboard — sub-agents write here on every turn;
// orchestrator reads here on re-entry.
class Blackboard {
  private store = new Map<string, SessionState>();

  write(state: SessionState): void {
    this.store.set(state.sessionId, { ...state });
  }

  read(sessionId: string): SessionState | undefined {
    const s = this.store.get(sessionId);
    return s ? { ...s } : undefined;
  }

  readLatest(): SessionState | undefined {
    if (this.store.size === 0) return undefined;
    const keys = [...this.store.keys()];
    return this.read(keys[keys.length - 1]);
  }

  delete(sessionId: string): void {
    this.store.delete(sessionId);
  }

  snapshot(): SessionState[] {
    return [...this.store.values()].map((s) => ({ ...s }));
  }
}

// Singleton exported for use by all agents
export const blackboard = new Blackboard();

A single eventBus — which can be scaled horizontally using Redis streams or pub/sub.

import { EventEmitter } from "events";
import type { SignalEvent, SignalType } from "./types.js";

// Typed event bus — sub-agents emit signals; orchestrator subscribes.
class AgentEventBus extends EventEmitter {
  emitSignal(payload: SignalEvent): void {
    this.emit("signal", payload);
  }

  onSignal(listener: (payload: SignalEvent) => void): this {
    return this.on("signal", listener as (...args: unknown[]) => void);
  }
}

export const eventBus = new AgentEventBus();

export function describeSignal(signal: SignalType): string {
  const map: Record<SignalType, string> = {
    DONE: "session completed",
    ESCALATE: "escalation requested",
    TOPIC_SHIFT: "topic changed — re-routing",
    FAILED: "sub-agent failed",
  };
  return map[signal];
}

Key functions/methods in the orchestrator are the entry point handleUserMessage coming from a UI or CLI, and the Turn Management method called orchestratorTurn.

  // ------------------------------------------------------------------ //
  // Public entry point — called by CLI on every user message
  // ------------------------------------------------------------------ //
  async handleUserMessage(userMessage: string): Promise<void> {
    if (this.activeSession) {
      // A sub-agent owns the conversation — forward the message to it
      await this.forwardToSubAgent(userMessage);
    } else {
      // Orchestrator is in direct control
      await this.orchestratorTurn(userMessage);
    }
  }

  // ------------------------------------------------------------------ //
  // Orchestrator handles the message directly
  // ------------------------------------------------------------------ //
  private async orchestratorTurn(userMessage: string): Promise<void> {
    // First decide whether to handle in-house or delegate
    const routing = await this.decideRouting(userMessage);

    if (routing.route === "self") {
      // Answer directly using the knowledge base
      this.orcHistory.push({ role: "user", content: userMessage });
      const reply = await this.callClaude(this.orcHistory);
      this.orcHistory.push({ role: "assistant", content: reply });
      this.output(reply);
    } else {
      // Delegate to a sub-agent
      await this.delegateTo(routing.route, routing.intent, userMessage);
    }
  }

The Routing decision is determined by the LLM using an existing list of choices of sub-agents’ capabilities as a structured JSON response. Not a free-form text.

  // ------------------------------------------------------------------ //
  // Routing decision — Claude decides intent + destination
  // ------------------------------------------------------------------ //
  private async decideRouting(userMessage: string): Promise<RoutingDecision> {
    const routingPrompt = `You are a routing classifier for Perfect Trading Co. customer service.

Classify the following customer message into ONE of these routes:
- "self"              → General information, company info, hours, contact details
- "account_balance"   → Account balances, holdings, portfolio, transactions, deposits
- "trade_execution"   → Order status, trade history, order types, fills, cancellations
- "technical_platform" → App issues, login problems, 2FA, browser support, platform bugs

Respond ONLY with a JSON object — no prose:
{"route":"<route>","intent":"<one-line intent>","reason":"<one-line reason>"}

Customer message: "${userMessage.replace(/"/g, '\\"')}"`;

    try {
      const response = await this.client.messages.create({
        model: "claude-sonnet-4-6",
        max_tokens: 256,
        messages: [{ role: "user", content: routingPrompt }],
      });

      const text =
        response.content[0].type === "text" ? response.content[0].text : "{}";
      const match = text.match(/\{.*\}/s);
      if (match) {
        return JSON.parse(match[0]) as RoutingDecision;
      }
    } catch {
      // Fall through to self-handling on parse error
    }

    return { route: "self", intent: "general inquiry", reason: "routing failed — defaulting to self" };
  }

The only specialized code each sub-agent has is loading its own knowledge base, which was a markdown file in this example.

All the complexity of the IoC-enabled sub-agents is in baseAgent.ts — it handles turns with the handleTurn function — which is the only thing all sub-agents do, along with determining the next action based on the multi-turn discussion.

  switch (sig.status) {
          case "done":
            sessionState.status = "done";
            sessionState.summary = sig.summary ?? undefined;
            signalToEmit = "DONE";
            break;
          case "escalate":
            sessionState.status = "escalate";
            sessionState.escalationReason = sig.escalationReason ?? undefined;
            signalToEmit = "ESCALATE";
            signalEvent.reason = sig.escalationReason ?? undefined;
            break;
          case "topic_shift":
            sessionState.status = "topic_shift";
            sessionState.topicShiftTo = sig.topicShift as AgentType;
            signalToEmit = "TOPIC_SHIFT";
            signalEvent.topicShiftTo = sig.topicShift as AgentType;
            break;
          default:
            sessionState.status = "active";
        }

The biggest issue for me is TOPIC_SHIFT.

This is not a capability any “one-off” subagents can do in any of the popular patterns. A2A cannot do this either (see the reference below).

So, how do we do a topic shift? Review this below conversation — I went from Trading status sub-agent (2 turns) to Technical Assistance Sub-agent, and the conversation shifted from one sub-agent to another sub-agent without overloading all the context of my trade status conversations with the orchestrator!

You: 

You: what are the trade statuses for my NVDA trades?

Assistant: 
[Orchestrator → delegating to Trade Execution Agent]

You: 
Assistant: Here's a summary of all your NVDA trades, Rajesh:

---

### NVDA Trade Summary

| Order ID | Date | Action | Type | Qty | Price | Status |
|----------|------|--------|------|-----|-------|--------|
| ORD-2025-009821 | 2025-06-01 | BUY | Limit | 15 shares | $945.00 limit | 🟡 **Open** |
| ORD-2025-009755 | 2025-05-30 | BUY | Market | 10 shares | $948.00 avg fill | ✅ **Filled** |

---

### Key Details:

- **ORD-2025-009821 (Open):** This is a GTC Limit order to buy 15 shares at **$945.00**. NVDA is currently trading at **$950.10**, so the order is still pending. It will fill if NVDA drops to $945.00 or below.

- **ORD-2025-009755 (Filled):** This market order for 10 shares was executed on May 30 at an average fill price of **$948.00**, for a total cost of **$9,480.00**.

---

You: whta about AMZN?

Assistant: Here's the details on your AMZN trade, Rajesh:

---

### AMZN Trade Summary

| Order ID | Date | Action | Type | Qty | Limit Price | Avg Fill Price | Status |
|----------|------|--------|------|-----|-------------|----------------|--------|
| ORD-2025-009215 | 2025-05-10 | BUY | Limit | 15 shares | $194.00 | $193.40 | ✅ **Filled** |

---

### Key Details:

- **ORD-2025-009215 (Filled):** Your limit order to buy 15 shares was executed on May 10 at **$193.40** — that's $0.60 **below** your limit of $194.00, which is a favorable fill. Total cost came to **$2,901.00**.

---

Is there anything else you'd like to know about your trades?

You: what is the latest status on my login issue?

Assistant: That's a great question, but I'm afraid login and platform access issues fall outside my area — I handle trade execution and order status only.

Let me transfer you to our **Technical Platform Issues Agent**, who will be best equipped to help you with your login concern.

One moment while I route you over!

You: 
Assistant: 

You: 
Assistant: [Blackboard topic shift → technical_platform]

You: 
Assistant: [Orchestrator → re-routing to technical platform]

You: 
Assistant: 
[Orchestrator → delegating to Technical Platform Agent]

You: 
Assistant: Hi Rajesh! Here's the latest on your open ticket:

---

**Ticket TKT-2025-06101 — 2FA Issue on iOS App**
*Last updated: June 1, 2025 at 11:15 AM EST*

Our Mobile Engineering team has confirmed this is a **known bug in PerfectTrade iOS app v4.3.0** affecting 2FA login on iOS 18.3 devices (your iPhone 15). A fix is actively in progress.

**Patch v4.3.1 ETA: within 24–48 hours**

---

**In the meantime, you can log in normally using either of these workarounds:**

- 🌐 **Web Portal:** [https://trade.perfecttradingco.com](https://trade.perfecttradingco.com)
- 🖥️ **Desktop App:** Available on your MacBook Pro or Home Windows PC — both are currently active and unaffected by this issue.

You'll receive a notification once v4.3.1 is available. You can also monitor real-time status at [https://status.perfecttradingco.com](https://status.perfecttradingco.com).

So, how did this work? This is the final puzzle.

Topic Shift

Topic Shift

First is Topic Shift, then Routing.

First Magic is Topic Shift — this is the base Agent: The System prompt that gets built for each of the sub-agents knows the peers, since all peers have the same common system prompt, other than the knowledge base, which is different.

  protected buildSystemPrompt(): string {
    return `You are a specialized customer service agent for Perfect Trading Co.

${this.knowledgeBase}

---

## Behavioral Rules

1. Answer only questions within your domain. If the customer asks about something outside your scope, indicate you need to transfer them.
2. Be concise, accurate, and professional.
3. At the END of every response, output a JSON signal block on its own line in this exact format:
   <<<SIGNAL:{"status":"active","intent":"<short intent>","entities":{},"summary":null,"escalationReason":null,"topicShift":null}>>>

   Use these status values:
   - "active"       → conversation is ongoing within your domain
   - "done"         → customer's question has been fully resolved; set summary
   - "escalate"     → requires human agent; set escalationReason
   - "topic_shift"  → question is outside your domain; set topicShift to one of: account_balance | trade_execution | technical_platform | orchestrator

4. The signal block is machine-read — keep it valid JSON. Never add extra fields.
5. Do NOT show the signal block to the customer — it appears after your visible response.`;
  }

Second is the “magic routing method” decideRouting — This is non-deterministic routing, so there could be issues, such as human case escalation, so not really a major issue when it happens; the experience decreases deflection accuracy.

In my implementation, I have a simplistic view of the buildSystemPrompt and some trade-offs here. But this could be injected.

Every sub-agent inherits buildSystemPrompt() unchanged, so all three peers receive the same routing vocabulary. When LLM (running inside a sub-agent) decides the topic is out of scope, it picks the right name from that fixed list and emits it in the signal JSON.

The orchestrator then reads topicShiftTo off the blackboard and re-routes.

The tradeoff: it’s simple and works well for a stable small team of agents, but it has two weak points:

  1. Adding a new sub-agent — You must update buildSystemPrompt() in baseAgent.ts — every existing agent is blind to the new peer until then
  2. Agent naming drift (Description) — Think of this as the dynamic version of A2A card description — If an AgentType value changes in types.ts, the prompt string doesn’t update automatically — they’re decoupled

A more robust alternative would be to inject the peer list dynamically at construction time and also add a lot more verbose description of each agent, so the topic shift could be identified with higher probability.

// In Orchestrator, pass the registry when creating agents
protected buildSystemPrompt(peers: AgentType[]): string {
  const peerList = peers.join(' | ');
  return `...set topicShift to one of: ${peerList}...`;
}

To round out the other Generic code components:

  1. Each Sub-agent and Orchestrator has their own context/memory — this will avoid cognitive overload, and this is per session
  2. All Sub-agents are derived from a single baseAgent that manages its turns, knows how to use the event emit and listen, and shares blackboard state

What is A2A, how A2A supports use cases similar to this in a “peer format,” and the pros and cons of using A2A vs. this IoC Multi-agent Pattern.

A2A operations are designed for asynchronous task execution.

Operations return immediately with either Task objects or Message objects, and when a Task is returned, processing continues in the background. Clients retrieve task updates through polling, streaming, or push notifications. Agents MAY accept additional messages for tasks in non-terminal states to enable multi-turn interactions.

Why not A2A in this case?

  1. A2A Agents are “static cards” and are not dynamic in nature
  2. A2A’s TaskState is a finite set: submitted → working → completed/failed/canceled. It doesn’t have a native concept of mid-session signals like TOPIC_SHIFT or ESCALATE
  3. A2A doesn’t know about any of your other peers, since these agents are dynamically registered.
  4. A2A is good for static and one-off tasks that are inside or outside of the organization, with no coupling involved. E.g., an A2A card that says, This agent can do background checks on customers in the United States. This can be done asynchronously.

Building Multi-agent scaffolding is relatively straightforward — where I have seen issues is scaling to enterprise solutions and complex conversation flows where the pattern reduces its acceptable deflection because the context is overloaded by the supervisor or too many deflections to humans in the loop.

Hopefully, this approach will reduce the supervisory overload.

Thanks for reading this composite pattern in detail. Looking forward to your feedback.


메타데이터
post_id
10c0313cdf74
slug
inversion-of-control-in-multi-agent-solutions-context-management-when-sub-agents-take-control-10c0313cdf74
url
https://medium.com/@sureshkandula/inversion-of-control-in-multi-agent-solutions-context-management-when-sub-agents-take-control-10c0313cdf74
canonical_url
https://medium.com/@sureshkandula/inversion-of-control-in-multi-agent-solutions-context-management-when-sub-agents-take-control-10c0313cdf74
author_url
https://medium.com/@sureshkandula
status
ok
fetched_at
2026-06-14 13:58:26