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.
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
# 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.
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.
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.
# 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.
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
- I can name the two independent decisions
evaluatemakes 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=Truehere 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
reactgoes nowhere when it does.
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.