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

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

▣ Build · stage 1 — base plus mood
# 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.

▣ Build · stage 2 — tone modifiers, zero or more
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.

▣ Build · stage 3 — switching moods without poisoning the profile
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.

▣ Build · stage 4 — the profile on disk, every mood printed
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.

⚠ Worked failure — the KeyError that waited for a config edit

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

✓ Checkpoint — what you can now do
  • 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_mood makes 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.
⚡ Exercises — try first, then reveal
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.