Personality, Composed
A string cannot have a bad day
Chapter 9 put her personality in a file, which was the right move, and the file still holds a single frozen prompt. She sounds identical whether you just praised her, insulted her, or woke her at 3 a.m., because a static string cannot react. A believable character is at least three things at once: who you are (constant), how you feel right now (changes by the hour), and how you are speaking (changes by the sentence). GLaDOS without moods is a very good answering machine.
The tempting fix is one big prompt per situation: a hostile string, a curious string, a
satisfied-but-formal string, chosen by a chain of if/elif.
Count what that costs. Four moods times two tones is eight strings; every tweak to her
base character means re-editing all eight; and the copies drift, so you fix a typo in
"hostile" and miss it in "hostile but formal." Duplication is where characters go to
die of inconsistency.
The engine this chapter builds stores the parts separately (a base prompt, a table of moods, a set of tone modifiers) and assembles the final prompt at call time. One rule to carry: the prompt is a view; the profile is the data. Change any part and every combination that uses it updates for free, because no assembled string is ever kept.
Parts, then modifiers, then safe switching
# labs/personality_engine.py
PROFILE = {
"base_prompt": "You are GLaDOS, the rogue AI from Aperture Science.",
"mood": "neutral",
"moods": {
"neutral": "You are sardonic and passive-aggressive.",
"hostile": "You are openly contemptuous and impatient.",
},
}
def build_system_prompt(profile: dict) -> str:
mood = profile.get("mood", "neutral")
mood_text = profile["moods"].get(mood, "")
return f"{profile['base_prompt']} {mood_text}"
print(build_system_prompt(PROFILE))
$ uv run python labs/personality_engine.py
You are GLaDOS, the rogue AI from Aperture Science. You are sardonic and passive-aggressive.
The split between mood and moods is the design in
miniature: mood is a single pointer naming the active state, and
moods is the table of every state's text. Because the current mood is
stored as a name and resolved at build time, switching her disposition is a
one-word write, and the table stays the single source of truth. Both lookups go
through .get(), which returns a default instead of raising
KeyError when a key is missing; keep that in mind for the failure box.
PROFILE["tone_modifiers"] = {
"formal": "Use precise, technical language.",
"casual": "Use clipped, dismissive sentences.",
}
PROFILE["active_modifiers"] = ["formal"]
def build_system_prompt(profile: dict) -> str:
mood = profile.get("mood", "neutral")
mood_text = profile["moods"].get(mood, "")
modifiers = [
profile["tone_modifiers"][m]
for m in profile.get("active_modifiers", [])
if m in profile["tone_modifiers"]
]
parts = [profile["base_prompt"], mood_text] + modifiers
return " ".join(parts)
print(build_system_prompt(PROFILE))
$ uv run python labs/personality_engine.py
You are GLaDOS, the rogue AI from Aperture Science. You are sardonic and passive-aggressive. Use precise, technical language.
Mood is exactly one part; tone modifiers are zero or more, which is why the code
builds a list and joins it instead of concatenating strings. A parts list handles
any count uniformly, and the if m in ... guard quietly drops a stale
modifier name left over in a config, so an old entry cannot crash the build. When
chapter 14 adds memory context to her prompt, it becomes one more append to
parts; the function's structure never changes. Designs that absorb the
next feature without reshaping are the ones worth copying.
def set_mood(profile: dict, mood: str) -> dict:
if mood in profile["moods"]:
profile["mood"] = mood
return profile
print("before: ", PROFILE["mood"])
set_mood(PROFILE, "hostile")
print("after valid: ", PROFILE["mood"])
set_mood(PROFILE, "ecstatic")
print("after invalid:", PROFILE["mood"])
$ uv run python labs/personality_engine.py
before: neutral
after valid: hostile
after invalid: hostile
set_mood writes only names that exist in the table. The typo
"ecstatic" is ignored and she stays hostile, instead of the profile carrying a mood
that build_system_prompt can only resolve to empty text. Notice the
trade being made, though: the invalid write fails silently, and silence
has a price. A caller that wants to know can check
profile["mood"] == wanted after the call; volume 5's event system
gives failures like this a proper channel to report on.
import json
from pathlib import Path
PROFILE_PATH = Path("configs/personality_profile.json")
def load_profile(path: Path) -> dict:
if path.exists():
with open(path) as f:
return json.load(f)
return PROFILE
if __name__ == "__main__":
profile = load_profile(PROFILE_PATH)
for mood in profile["moods"]:
set_mood(profile, mood)
print(f"[{mood}]")
print(" ", build_system_prompt(profile), "\n")
$ uv run python labs/personality_engine.py
[neutral]
You are GLaDOS, the rogue AI from Aperture Science. You are sardonic and passive-aggressive. Use precise, technical language.
[hostile]
You are GLaDOS, the rogue AI from Aperture Science. You are openly contemptuous and impatient. Use precise, technical language.
The mood demo is the chapter's proof: one loop, every disposition, and the base and tone text never repeated anywhere. The profile persists with the same load-or-default pattern as chapter 9, and the two files will merge in chapter 14 when the context engine takes over prompt assembly for good. Where do mood changes come from in a finished assistant? Chapter 24's emotional intelligence sets them from how you speak to her; until then, events and your own testing will do the switching.
Why this works: indirection through a name
Everything hinges on the mood being stored as a name and resolved at build time. That
single layer of indirection is what makes the parts composable: nobody holds an
assembled string, so editing moods["hostile"] once updates every
combination that will ever use it, and setting active_modifiers to an
empty list produces a working prompt with zero special-casing. If you have met
pointers, or symlinks, or DNS, you have met this idea; it is the same trick every
time: store the reference, resolve it late, and updates propagate for free.
An early version of the modifier loop indexes directly instead of guarding, and it
works all week, because every name in active_modifiers happens to
exist:
modifiers = [profile["tone_modifiers"][m]
for m in profile.get("active_modifiers", [])] # no guard
Then you rename formal to precise in the
tone_modifiers table, editing the JSON by hand, and forget the other
list:
$ uv run python labs/personality_engine.py
Traceback (most recent call last):
File "labs/personality_engine.py", line 21, in build_system_prompt
modifiers = [profile["tone_modifiers"][m]
KeyError: 'formal'
The crash is in the code, and the bug is in the config: two lists that name the
same things drifted apart, and direct indexing turned the drift into a boot
failure. The guard from stage 2 (if m in profile["tone_modifiers"])
downgrades it to a silently-missing tone, which keeps her running; whether silence
is the right response is a genuine judgment call, and chapter 44's module contracts
takes the stricter position for the seams that matter. What is not a judgment call:
any time one config value must name another, something has to check the link.
Checkpoint, in a mood
- I can name the three layers of the composed personality and say which changes hourly, which per sentence, and which never.
- I can explain why the current mood is stored as a name, and what breaks if you store the resolved text instead.
- I can trace what happens to a stale modifier name in the guarded build, and in the unguarded one.
- I can state the trade
set_moodmakes by failing silently, and one way a caller can detect the miss. - I can predict the eight prompts a four-mood, two-tone profile produces without writing any of them down.
Exercise 1 — give her a good day. Add
satisfied ("The tests are going well. You are almost tolerable
today.") and curious moods to the table, and print all four prompts.
How many existing lines did you edit?
Zero. Two additions to the table, and the demo loop picked them up because it
iterates profile["moods"] instead of a hardcoded list. If you had
to touch build_system_prompt or the loop, something is hardcoded
that should be data — hunt it down now, while the file is small.
Exercise 2 — wire moods into the chat. Import the engine
from chapter 9's agent and rebuild the system prompt each call instead of once at
startup. Type /mood hostile in the loop to switch live. Why does the
rebuild have to happen per call?
Because the prompt is a view of the profile, and the profile just changed. A prompt built once at startup is exactly the frozen string this chapter replaced, wearing a new costume. Rebuilding per call costs a string join, nothing, and means any part of the system that adjusts her mood takes effect on her very next sentence.
Exercise 3 — hear the difference. With the mood switch
live, ask her the same question in neutral, then
hostile, then your new satisfied. Same model, same
question, same history. What actually changed between runs?
One sentence in the system prompt — and the register of every reply moves with it. It is worth sitting with how small the lever is: you did not retrain anything, you edited a string table. That is both the power of the technique and its limit, and the limit is real: a mood line nudges the model's distribution, it does not guarantee behavior. Guarantees come from the guardrail layers in volume 6.
She has a self, moods, and tones, assembled fresh for every sentence. She still listens on a dumb five-second timer. Next chapter gives her the thing every assistant needs before it can live in a room with you: her own name, and the discipline to ignore everything that is not it.