GLaDOS Vol 2 · Her Mind
ch 20 / 99
Chapter 20

A Committee of Specialists

One brain cannot be good at everything

Listen to a day of her requests and they stop sounding like one job. "Turn off the kitchen lights" wants a tool call into the automation engine. "What is the speed of light?" wants a factual lookup and a straight answer. "Write me a haiku about robots" wants free-form generation, in her voice, with no facts checked at all. These are not variations on a task; they want different system prompts, different tools, and different definitions of a good answer.

The tempting fix is one giant prompt: a single model with instructions for every case stuffed into its context. It works until it does not. The prompt grows unmaintainable, the model starts blurring roles (a control command answered with a poem is funnier the first time than the fourth), and you cannot test or improve one behavior without risking all the others. You also pay to send the kitchen-lights instructions along with every haiku request, token by token, forever.

A multi-agent system splits the work. Small, focused agents each own one domain, and a router sits in front, classifying the request and dispatching to whoever owns it. The rule to carry: the router decides who acts; each agent decides how. And because every agent is a plain string-in, string-out function, adding a capability means registering one function. The router never changes.

Agents, intents, router

▣ Build · stage 1 — agents as functions in a registry
# labs/multi_agent.py
from typing import Callable

def home_control_agent(text: str) -> str:
    return f"[HOME] Executing home command for: '{text}'"

def knowledge_agent(text: str) -> str:
    return f"[KNOWLEDGE] Looking up: '{text}'"

AGENT_REGISTRY: dict[str, Callable[[str], str]] = {
    "home_control": home_control_agent,
    "knowledge": knowledge_agent,
}

print(AGENT_REGISTRY["home_control"]("Turn off the lights."))
print(AGENT_REGISTRY["knowledge"]("What is the speed of light?"))
$ uv run python labs/multi_agent.py
[HOME] Executing home command for: 'Turn off the lights.'
[KNOWLEDGE] Looking up: 'What is the speed of light?'

If this file feels familiar, it should: functions filed in a dict by string key is chapter 17's action registry wearing a new hat. The caller never names an agent in code; it asks the registry for whatever is filed under an intent string. Adding a fifth agent later means adding one key, not editing a branch. The dict stores the function objects themselves, a fact you have been using since chapter 2, and it is about to carry the whole design.

▣ Build · stage 2 — decide the intent from the text
INTENT_KEYWORDS: dict[str, list[str]] = {
    "home_control": ["lights", "timer", "switch", "turn on", "turn off", "thermostat"],
    "knowledge": ["what is", "who is", "how does", "explain", "define", "when did"],
    "creative": ["write", "poem", "story", "joke", "haiku", "creative"],
}

def classify_intent(text: str) -> str:
    lower = text.lower()
    for intent, keywords in INTENT_KEYWORDS.items():
        if any(kw in lower for kw in keywords):
            return intent
    return "fallback"

for q in [
    "Turn off the lights in the kitchen.",
    "What is the speed of light?",
    "Write me a haiku about robots.",
    "Do something random.",
]:
    print(f"{classify_intent(q):<13} <- {q}")
$ uv run python labs/multi_agent.py
home_control  <- Turn off the lights in the kitchen.
knowledge     <- What is the speed of light?
creative      <- Write me a haiku about robots.
fallback      <- Do something random.

Classification answers "what kind of request is this?", which is a fact about the input, so it returns an intent name and not an agent. Keeping the question separate from the dispatch means you can swap keyword matching for a smarter classifier later without touching the registry. The any() short-circuits at the first keyword hit, so even long lists stay cheap, and the unmatched case is an explicit "fallback" string instead of a crash. That last choice is the hinge of the failure box.

▣ Build · stage 3 — the router, and a fallback with a seat
def creative_agent(text: str) -> str:
    return f"[CREATIVE] Generating content for: '{text}'"

def fallback_agent(text: str) -> str:
    return f"[FALLBACK] I'm not sure how to handle: '{text}'"

AGENT_REGISTRY["creative"] = creative_agent
AGENT_REGISTRY["fallback"] = fallback_agent

def route(text: str) -> str:
    intent = classify_intent(text)
    agent_fn = AGENT_REGISTRY[intent]
    print(f"  [ROUTER] Intent: '{intent}'")
    return agent_fn(text)

if __name__ == "__main__":
    for q in [
        "Turn off the lights in the kitchen.",
        "Write me a haiku about robots.",
        "Do something random.",
    ]:
        print(route(q), "\n")
$ uv run python labs/multi_agent.py
  [ROUTER] Intent: 'home_control'
[HOME] Executing home command for: 'Turn off the lights in the kitchen.'

  [ROUTER] Intent: 'creative'
[CREATIVE] Generating content for: 'Write me a haiku about robots.'

  [ROUTER] Intent: 'fallback'
[FALLBACK] I'm not sure how to handle: 'Do something random.'

The router is four lines: classify, look up, log, call. The log line is not decoration; it is the router answering "why did you do that?" before anyone asks, and when a request lands on the wrong specialist, that one line tells you whether to blame the classifier or the agent. Note that fallback_agent is registered exactly like its more talented colleagues. "I don't know" is a real answer, and giving it a real seat at the table is what keeps the router total: every string the classifier can return has a function waiting for it.

◆ Note — stubs today, specialists tomorrow

Every agent here is a print in a trench coat, on purpose: the chapter is about the routing, and stubs keep it runnable in a second with nothing loaded. The real bodies arrive on schedule. Chapter 35's control loop puts the language model behind home_control so talk becomes action; chapter 91 grows the committee into full agent teams, each with its own system prompt and tools. The seam you built today is where all of that plugs in, and the seam does not change.

Why this works: the volume's third dispatch table

Count them. Chapter 15 mapped event names to handler lists. Chapter 17 mapped action names to handler functions. This chapter maps intent names to agents. Three switchboards, one skeleton: a string is the entire contract, a dict is the mechanism, and code stands still while data moves. What is new here is where the key comes from. The bus's key was declared by a publisher and the engine's key was written in a rule, but the router's key is derived, by a classifier reading your sentence and making a judgment. That is a small, real step toward her deciding things, and much of the rest of this book is about making judgments like that one sharper, cheaper, and safer to be wrong about.

⚠ Worked failure — the committee with no chair for "I don't know"

You wire the router before registering the fallback, because the interesting agents felt more urgent. Three requests work perfectly; the fourth is unremarkable, and fatal:

# AGENT_REGISTRY["fallback"] = fallback_agent   # not yet written
print(route("Do something random."))
$ uv run python labs/multi_agent.py
  [ROUTER] Intent: 'fallback'
Traceback (most recent call last):
  File "labs/multi_agent.py", line 44, in <module>
    print(route("Do something random."))
  File "labs/multi_agent.py", line 38, in route
    agent_fn = AGENT_REGISTRY[intent]
KeyError: 'fallback'

The classifier's return values and the registry's keys are two sets that must match, and nothing checks them; they are a contract between two pieces of data, enforced by hope. It is chapter 12's drifting-lists bug at a new address, and the defenses rank the same way. Cheapest: register the fallback first, before any specialist. Sturdier: assert at startup that every value classify_intent can return is a registry key, so the mismatch crashes at boot with a clear message instead of at 9 p.m. on an unlucky sentence. Chapter 33 builds that startup checklist; chapter 44 turns contracts like this one into a discipline.

Checkpoint, and a volume closed

✓ Checkpoint — what you can now do
  • I can name the three costs of the one-giant-prompt design, including the one you pay in tokens on every request.
  • I can explain why classify_intent returns a name instead of a function, and what that seam lets me swap later.
  • I know where any() stops evaluating and what that means for long keyword lists.
  • I can say why the fallback is registered like any other agent, and quote the exact error its absence produces.
  • I can list the volume's three dispatch tables and say where each one's key comes from.
⚡ Exercises — try first, then reveal
Exercise 1 — the fifth specialist. Add a weather agent: keywords, a stub function, a registry entry. Count the lines you touched outside the new code.

One keyword list, one function, one registry line, and zero edits to route() or any existing agent. The diff has the same silhouette as chapter 17's twenty-first automation, because it is the same pattern paying the same dividend: capabilities land as additions, never as surgery.

Exercise 2 — the ambiguous sentence. Run route("Write down what a thermostat is."). Which intent wins, why that one, and what are your options?

home_control wins: the classifier walks intents in dict order, home_control comes first, and "thermostat" hits before "write" is ever consulted. First match is a policy you set by ordering the dict, whether you meant to or not. Mitigations: order intents most-specific-first, count hits per intent and take the max, or admit the sentence needs a reader that understands meaning, which is exercise 3.

Exercise 3 — promote the classifier. Replace keyword matching with a call to chapter 8's model: ask it to answer with exactly one word from the intent list. What must you do to its answer before using it, and what did the swap cost?

Validate it against the registry keys, and route anything else to fallback, because the model may answer "home-control" or a helpful little sentence, and free text used as a dict key is this chapter's failure box on a timer. The cost is one extra model call per request, about a second on your hardware. The registry and route() did not change at all, which is the seam from stage 2 keeping its promise.

Take stock, because this is the volume's last page. She keeps memory that survives the power button, wears a personality assembled from parts, answers to her own name, carries the right facts into every sentence, and runs on a nervous system of events. She logs and escalates her failures, follows rules written as data, notices your habits, acts on a schedule without being asked, and now routes each request to the specialist who should answer it. Her mind is not one clever function. It is a small team of legible parts, each testable alone, each replaceable without disturbing the others, and you built every seat at the table.

What she says is in place. How she says it is volume 3's business: speech that stops sounding read from a page, reactions to how you sound and not just what the words were, and the craftsman's tooling (experiments, debugging, measurement) that a system this alive now deserves.