GLaDOS Vol 5 · Alive on the Bench
ch 46 / 99
Chapter 46

Behaviors as Data

A dozen reactions and no way to rank them

Volume 5 keeps handing her reasons to react. The watchdog notices a component has stopped answering. The startup check finds the model server missing. A hardware test reports a reading past its limit. Meanwhile she still hears you say her name, still hears you ask for a status, and still hears you tell her to stop. Every one of those arrives as an event on the bus, and every one of them is handled today the same way: another branch inside the function you subscribed to the bus.

A branch tower answers one question: which single branch runs. Reactions do not work like that. A wake word spoken while the battery is nearly flat deserves both a greeting and a warning, and the warning has to come first. In an if/elif chain the order is whatever you happened to type, and urgency cannot be expressed at all. Switching a reaction off is the other sore spot: comment out a branch, and a week later nobody knows whether the dead block is temporary.

So the rule for this chapter: a trigger decides whether a reaction fires, a priority decides when it fires, and neither decision is allowed to touch the other. Both live in a row of data, not in a branch, which means an urgent reaction can be added to a running system by adding one row.

◆ Note — what chapter 17 already gave you, and what it did not

Be clear about the delta, because it is smaller than it looks. Chapter 17 already made reactions data: rows with a trigger and an action name, handlers registered under those names, dispatch by dictionary lookup, several rows allowed to match one input, and a missing handler tolerated instead of fatal. All of that carries over, and three of the six fields below will look familiar. Four things are new: a priority, so order is a value you set instead of an accident of typing; an enabled flag, so switching a reaction off edits data; matching against a whole context dictionary, so a battery level triggers reactions the same way words do; and an engine you can build twice, which is what lets a test hold one of its own.

Six fields and one sort

▣ Build · stage 1 — one reaction as a row
# labs/behavior_engine.py
from dataclasses import dataclass


@dataclass
class Behavior:
    name: str
    trigger: str
    action: str
    priority: int = 1
    enabled: bool = True
    description: str = ""


if __name__ == "__main__":
    halt = Behavior("Emergency halt", "emergency", "halt.all", priority=5,
                    description="Stop every moving part, then explain.")
    print(f"{halt.name}: {halt.trigger!r} -> {halt.action!r} @ priority {halt.priority}")
    print("enabled:", halt.enabled)
$ uv run python -m labs.behavior_engine
Emergency halt: 'emergency' -> 'halt.all' @ priority 5
enabled: True

No methods, on purpose. A Behavior holds only what should happen: a trigger to look for, the name of an action, a number saying how badly it wants to run, a flag, and a description for whoever reads the table later. Zero logic is the property that matters, because it lets a row be typed by hand, loaded from JSON, or produced by the model without the engine noticing. The defaults mean the common case takes three arguments and the urgent case four. !r asks for repr() instead of str(), so the values arrive with their quotes.

▣ Build · stage 2 — filter, then sort
from typing import Callable


class BehaviorEngine:
    def __init__(self, behaviors: list[Behavior] | None = None) -> None:
        self.behaviors: list[Behavior] = list(behaviors or [])
        self.handlers: dict[str, Callable[[dict], None]] = {}

    def evaluate(self, context: dict) -> list[Behavior]:
        haystack = str(context).lower()
        matched = [b for b in self.behaviors if b.enabled and b.trigger in haystack]
        return sorted(matched, key=lambda b: -b.priority)


BEHAVIORS = [
    Behavior("Greet on wake", "wake_word", "greet.user", priority=2),
    Behavior("Emergency halt", "emergency", "halt.all", priority=5),
    Behavior("Status on request", "status", "status.report"),
    Behavior("Night snark", "wake_word", "snark.reply", priority=3, enabled=False),
]

if __name__ == "__main__":
    engine = BehaviorEngine(BEHAVIORS)
    context = {"event": "wake_word", "text": "GLaDOS emergency stop", "hour": 23}
    for b in engine.evaluate(context):
        print(f"{b.priority}  {b.name}")
$ uv run python -m labs.behavior_engine
5  Emergency halt
2  Greet on wake

Read the output against the table and the whole design is visible in two lines. Two behaviors matched, because both "wake_word" and "emergency" appear in the stringified context, and both are allowed to run. "status" is nowhere in this context, so that row never enters the list. Night snark does match, and at priority 3 would have landed in the middle, but its enabled flag is false and the filter drops it before priority is consulted. Then the sort: negating the priority turns Python's ascending sorted into a descending one, so the largest number leads. Filtering first and sorting the survivors costs less than sorting everything, and puts the two decisions on two lines you can read one at a time.

▣ Build · stage 3 — dispatch by name, and the rows with nowhere to go
    def register(self, action: str, handler: Callable[[dict], None]) -> None:
        self.handlers[action] = handler

    def execute(self, behaviors: list[Behavior], context: dict) -> list[str]:
        ran = []
        for b in behaviors:
            handler = self.handlers.get(b.action)
            if handler is None:
                continue
            handler(context)
            ran.append(b.name)
        return ran


if __name__ == "__main__":
    engine = BehaviorEngine(BEHAVIORS)
    engine.register("greet.user", lambda ctx: print("[ACTION] Oh. It's you."))
    engine.register("status.report", lambda ctx: print("[ACTION] All systems nominal."))

    context = {"event": "wake_word", "text": "GLaDOS emergency stop"}
    matched = engine.evaluate(context)
    ran = engine.execute(matched, context)
    print("matched:", [b.name for b in matched])
    print("ran:    ", ran)
    print("no handler:", [b.action for b in matched if b.action not in engine.handlers])
$ uv run python -m labs.behavior_engine
[ACTION] Oh. It's you.
matched: ['Emergency halt', 'Greet on wake']
ran:     ['Greet on wake']
no handler: ['halt.all']

The behavior knows the string "halt.all" and nothing else; execute is where that string becomes a function, or fails to. Nobody registered a handler, so .get returns None, the row is skipped, and the reactions that do have handlers run normally. That silence has a cost. The upside: a table can name actions the current build has not implemented, so reactions for a machine with an arm can sit in the repository months before there is an arm. The downside: a mistyped action looks exactly like a not-yet-implemented one. So the engine stays quiet and execute hands back the names it ran, and comparing that list against matched puts the gap one line away, on demand, instead of in a log nobody reads.

▣ Build · stage 4 — one engine on the bus
# labs/behavior_engine.py — full file
from dataclasses import dataclass
from typing import Callable

from labs.event_bus import bus


@dataclass
class Behavior:
    name: str
    trigger: str
    action: str
    priority: int = 1
    enabled: bool = True
    description: str = ""


class BehaviorEngine:
    def __init__(self, behaviors: list[Behavior] | None = None) -> None:
        self.behaviors: list[Behavior] = list(behaviors or [])
        self.handlers: dict[str, Callable[[dict], None]] = {}

    def register(self, action: str, handler: Callable[[dict], None]) -> None:
        self.handlers[action] = handler

    def evaluate(self, context: dict) -> list[Behavior]:
        haystack = str(context).lower()
        matched = [b for b in self.behaviors if b.enabled and b.trigger in haystack]
        return sorted(matched, key=lambda b: -b.priority)

    def execute(self, behaviors: list[Behavior], context: dict) -> list[str]:
        ran = []
        for b in behaviors:
            handler = self.handlers.get(b.action)
            if handler is None:
                continue
            handler(context)
            ran.append(b.name)
        return ran

    def react(self, context: dict) -> list[str]:
        return self.execute(self.evaluate(context), context)


BEHAVIORS = [
    Behavior("Emergency halt", "emergency", "halt.all", priority=5,
             description="Stop every moving part, then explain."),
    Behavior("Battery warning", "battery_low", "warn.power", priority=4,
             description="Announce the level before it becomes an outage."),
    Behavior("Greet on wake", "wake_word", "greet.user", priority=2),
    Behavior("Status on request", "status", "status.report",
             description="Report health when asked."),
    Behavior("Night snark", "wake_word", "snark.reply", priority=3, enabled=False,
             description="Extra hostility. Off by default."),
]


def main() -> None:
    engine = BehaviorEngine(BEHAVIORS)
    engine.register("halt.all", lambda ctx: print("[ACTION] Everything stops. You are welcome."))
    engine.register("warn.power", lambda ctx: print(f"[ACTION] Battery at {ctx.get('battery', '?')} percent."))
    engine.register("greet.user", lambda ctx: print("[ACTION] Oh. It's you."))
    engine.register("status.report", lambda ctx: print("[ACTION] All systems nominal."))

    bus.subscribe("speech_detected", engine.react)
    bus.subscribe("battery_low", engine.react)

    traffic = [
        ("speech_detected", {"event": "wake_word", "text": "GLaDOS, status", "battery": 82}),
        ("battery_low", {"event": "battery_low", "battery": 7}),
        ("speech_detected", {"event": "wake_word", "text": "GLaDOS emergency stop", "battery": 7}),
    ]
    for name, data in traffic:
        print(f"--- {name}")
        bus.publish(name, data)


if __name__ == "__main__":
    main()
$ uv run python -m labs.behavior_engine
--- speech_detected
[ACTION] Oh. It's you.
[ACTION] All systems nominal.
--- battery_low
[ACTION] Battery at 7 percent.
--- speech_detected
[ACTION] Everything stops. You are welcome.
[ACTION] Oh. It's you.

react is the whole public surface: hand it a context, it evaluates, executes, and reports what it ran. That signature is what bus.subscribe wants, a callable taking one argument, so the engine plugs into chapter 15's bus with no adapter. Notice what the bus does with the returned list: nothing. Publishing is fire-and-forget, so react is written to be useful when called directly too. Then read the second event. Nobody said a word and no microphone was involved, yet a reaction fired, because a context dictionary from a battery monitor is the same kind of input as one built from a transcript.

Why this works: two filters that cannot interfere

evaluate does two jobs, and keeping them apart is the trick. Membership is settled by b.enabled and b.trigger in haystack: a behavior is in the running or it is out, and priority has no vote. Order is settled by the sort key, which arranges the survivors and can never rescue one that failed the filter. So a disabled behavior at priority 99 stays out, and a priority-5 halt and a priority-2 greeting both fire from one sentence with the halt guaranteed to lead. Two questions you answer separately are two things you change separately: edit triggers and flags to change what fires, edit numbers to change what leads.

One property of sorted is doing quiet work here. It is stable: two behaviors with equal priority come out in declaration order, a guarantee you can design around by putting the logger above the greeter at the same number. The negation has one limit. It works on numbers only, so the day you sort by a name or a timestamp you reach for reverse=True, which flips any key and keeps the same stability.

⚠ Worked failure — the emergency that answered second

You want the most urgent behavior first, so you sort by priority. Obvious, and one character wrong:

    def evaluate(self, context: dict) -> list[Behavior]:
        haystack = str(context).lower()
        matched = [b for b in self.behaviors if b.enabled and b.trigger in haystack]
        return sorted(matched, key=lambda b: b.priority)   # BUG: no minus
$ uv run python -m labs.behavior_engine
--- speech_detected
[ACTION] Oh. It's you.
[ACTION] Everything stops. You are welcome.
order: ['Greet on wake', 'Emergency halt']

No traceback, no warning, no failed import. She greets you pleasantly and then stops the motors, and on a bench with nothing moving yet you can read that output twice without seeing anything wrong. Both behaviors fired, which is correct; only their sequence is inverted, and sequence is the reason this engine exists. Ascending is the default for sorted, so a key returning the priority as-is puts the smallest number first and every urgency in the table is reversed. Three lines from last chapter's harness pin it down:

def test_urgent_behavior_leads() -> None:
    engine = BehaviorEngine(BEHAVIORS)
    order = [b.name for b in engine.evaluate({"event": "wake_word", "text": "emergency"})]
    assert order[0] == "Emergency halt", f"expected the halt first, got {order}"
$ uv run python -m labs.test_harness
  [FAIL] test_urgent_behavior_leads (0.014 ms)
         expected the halt first, got ['Greet on wake', 'Emergency halt']

Timings on your machine will differ; the assertion message will not. The test builds its own engine from the same table instead of borrowing a shared one, so nothing another test registered can poison it. Restore the minus sign and the row reads PASS, and the guarantee that the loudest alarm answers first has something watching it.

Checkpoint, and reactions you can rank

✓ Checkpoint — what you can now do
  • I can name the two independent decisions evaluate makes and say which fields feed each one.
  • I can predict, from a table and a context dictionary, exactly which behaviors fire and in what order, including the disabled one.
  • I can explain why the negated sort key beats reverse=True here and the case where the negation stops working.
  • I can say what happens to a behavior whose action was never registered, and justify the silence over a warning.
  • I can list what this engine adds to chapter 17's rule engine, without pretending the overlap is smaller than it is.
  • I can attach the engine to the bus in one line and explain why the return value of react goes nowhere when it does.
⚡ Exercises — try first, then reveal
Exercise 1 — stop matching the whole dictionary. Add a behavior with the trigger "status" and send it the context {"event": "status_led_off"}. Then change evaluate so a trigger is compared against context["event"] exactly, and confirm the false match disappears while the real one survives.

Substring matching against str(context) is generous by design and greedy by consequence: "status" lives inside "status_led_off", so she reports her health because a light switched off. Swapping the test for b.trigger == context.get("event", "") gives [] for status_led_off and ['Status on request'] for status. You bought precision and sold reach: the exact form no longer notices a trigger word sitting in a transcript. The table can have both, with a match field per row naming which key to test.

Exercise 2 — a condition on top of the trigger. Add an optional condition: Callable[[dict], bool] | None = None field and check it inside the membership filter, then prove it with a behavior that only fires when context["hour"] is 21 or later.

The filter grows one clause: (b.condition is None or b.condition(context)). Declare the row with condition=lambda ctx: ctx.get("hour", 0) >= 21, then evaluate the same wake-word context with "hour": 14 and again with "hour": 23. The first prints an empty list, the second the behavior's name. A callable field means the row is no longer pure data and cannot survive JSON, which is the price of arbitrary conditions; the serializable alternative is a named condition resolved through a second registry, the trick the action names already use.

Exercise 3 — the table leaves the code. Move BEHAVIORS into configs/behaviors.json, load it at startup, and add a disable(name) method. Then edit a priority in the JSON file, rerun, and watch the order change with no Python touched.

Load with the pattern from chapter 22: read the file, rebuild each row as Behavior(**row), and fall back to the built-in table when the file is missing or malformed. disable is a loop that flips enabled and reports whether it found the name. The payoff is the rerun: raising the battery warning above the halt reorders her reactions with no code changed, and the table becomes something you can diff and hand to somebody who has never opened a Python file.

Her reactions are a table with an urgency column, and every row is a fact you can read, sort, or switch off. What no row says yet is how she feels about any of it. The same halt should sound different after a smooth week than after the third component in an hour falls over, and that difference wants a state of its own: a mood, a dial for how strongly she holds it, and one guarded door every change passes through.