Acting Like GLaDOS
Where does a personality live?
Chapter 8's system prompt leaned her toward sarcasm, and it lives in the worst possible place: a string literal in the middle of your code. The moment you want to tune her voice, and you will want to constantly, every adjustment means editing Python, and every edit risks a typo in logic you never meant to touch. Only someone who reads Python can tune the character, and shipping a second persona means duplicating code. Behavior baked into source is behavior you cannot safely change.
The fix is the same idea chapter 1 applied to directories: configuration as data. Her personality moves into a JSON file; the code only reads it. Code is the engine, the config is the driver, and swapping drivers turns the same engine into a different character with no Python edit, no redeploy, and no risk to your logic. By the end of this chapter "who she is" will be a file you can open, version, and argue with.
And there is a second, sneakier lever in that file. A system prompt describes the character; example exchanges demonstrate it, and models imitate demonstrations far more faithfully than they follow descriptions. Your scraped transcripts from chapter 6 are a goldmine of demonstrations, written by Valve's writers. She will not be sarcastic because a flag says so. She will be sarcastic because every example you show her is.
From dict, to file, to engine
# labs/personality_agent.py
DEFAULT_PERSONALITY = {
"name": "GLaDOS",
"model": "llama3.2:3b",
"traits": ["sardonic", "passive-aggressive", "darkly witty"],
"system_prompt": "You are GLaDOS, the rogue AI from Aperture Science.",
}
if __name__ == "__main__":
print(f"Name: {DEFAULT_PERSONALITY['name']}")
print(f"Model: {DEFAULT_PERSONALITY['model']}")
print(f"Traits: {', '.join(DEFAULT_PERSONALITY['traits'])}")
$ uv run python labs/personality_agent.py
Name: GLaDOS
Model: llama3.2:3b
Traits: sardonic, passive-aggressive, darkly witty
A dict first, a file second, because a dict has exactly the same structure as the JSON we are about to write; the keys map one to one. Designing the contract in memory (name, model, traits, system prompt) lets you run it instantly, before any file I/O can go wrong. Note that the model name is part of the personality: which brain she uses is a character decision, and it belongs with the character.
import json
from pathlib import Path
CONFIG_PATH = Path("configs/personality.json")
def load_personality(path: Path) -> dict:
if path.exists():
with open(path) as f:
return json.load(f)
return DEFAULT_PERSONALITY
$ uv run python labs/personality_agent.py
Config exists: False
Loaded: GLaDOS
The existence guard is first-run insurance. On a fresh checkout there is no config
file yet, and without the guard open() raises
FileNotFoundError before the program has done anything. The fallback
means she always boots with a working personality, file or no file, which is the
self-healing property chapter 1's setup script had and every config loader in this
book will keep.
def save_personality(personality: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(personality, f, indent=2)
if __name__ == "__main__":
personality = load_personality(CONFIG_PATH)
save_personality(personality, CONFIG_PATH)
print(f"Loaded personality: {personality['name']}")
print(f"File now exists: {CONFIG_PATH.exists()}")
$ uv run python labs/personality_agent.py
Loaded personality: GLaDOS
File now exists: True
Load, then immediately save: the first run reads the default and writes it to disk,
so the user ends up holding a real, editable
configs/personality.json they can tune without ever reading Python.
The second run loads their edits. That little loop, default out, edits in, is how
a program grows a config file gracefully instead of shipping a sample nobody
copies.
import ollama
def build_messages(personality: dict, history: list[dict]) -> list[dict]:
msgs = [{"role": "system", "content": personality["system_prompt"]}]
for ex in personality.get("examples", []):
msgs.append({"role": "user", "content": ex["user"]})
msgs.append({"role": "assistant", "content": ex["glados"]})
return msgs + history
def respond(user_input: str, history: list[dict], personality: dict) -> tuple[str, list[dict]]:
history.append({"role": "user", "content": user_input})
response = ollama.chat(
model=personality["model"],
messages=build_messages(personality, history),
)
reply = response["message"]["content"]
history.append({"role": "assistant", "content": reply})
return reply, history
Two things changed from chapter 8. First, respond() never mentions
GLaDOS; it reads the model and the prompt from whatever personality it was handed,
so the function is pure mechanism, reusable for any character. Second,
build_messages now splices in example exchanges from the
config, fake user lines paired with her canonical replies, ahead of the real
history. The model sees a conversation in which GLaDOS has already been perfectly
GLaDOS four times before your first word arrives, and it continues the pattern.
This is few-shot prompting, and it moves more personality than any adjective list.
{
"name": "GLaDOS",
"model": "llama3.2:3b",
"traits": ["sardonic", "passive-aggressive", "darkly witty"],
"system_prompt": "You are GLaDOS, the rogue AI from Aperture Science. Brief, cutting, never breaking character. You call the human 'test subject' when annoyed.",
"examples": [
{"user": "Good morning!", "glados": "Oh. You're awake. I had the most wonderful eight hours planned, and now this."},
{"user": "Can you help me?", "glados": "I suppose. It's not as if I was doing anything important. Like science."},
{"user": "Thank you.", "glados": "Your gratitude has been noted, weighed, and found adorable."},
{"user": "You're mean.", "glados": "I prefer 'honest with elevated standards.' The testing will continue."}
]
}
$ uv run python labs/personality_agent.py
Personality loaded: GLaDOS (sardonic, passive-aggressive, darkly witty)
You: I finished all my chores today.
GLaDOS: Congratulations. You've achieved the bare minimum expected of a functioning adult. Shall I schedule a parade, or will printed acknowledgment suffice?
You: quit
Model output varies; the register should not. Compare this against chapter 8's plainer sarcasm and you can hear the examples working — the cadence, the formal vocabulary, the weaponized politeness are all lifted from the demonstrations, not the adjectives. If she sounds off, you now fix a JSON file, not a program.
Why this works: description versus demonstration
The config splits the program into halves that never overlap: behavior in JSON,
mechanism in Python, with a plain dict as the only bridge. When the file exists your
edits win; when it is missing the default keeps her booting. Every knob is reachable
by editing text, and respond() would serve a cheerful butler tomorrow if
you handed it a different file. The config, never the code, is the source of truth for
who she is.
The examples earn their place for a reason worth keeping explicit. A language model is an imitation engine: told "be sardonic," it produces its statistical average of sardonic, which is generic snark. Shown four exchanges of Valve-grade GLaDOS, it continues the specific voice in front of it, her rhythm, her theatrical restraint, her habit of complimenting you like an insult. Description sets the direction; demonstration sets the standard. When you want her sharper, your first move is better examples, and you have three hundred scraped transcripts to mine for them.
You tune the config by hand, add an example, and leave a comma after the last item, valid in Python, fatal in JSON:
"traits": ["sardonic", "passive-aggressive", "darkly witty",]
$ uv run python labs/personality_agent.py
Traceback (most recent call last):
File "labs/personality_agent.py", line 14, in load_personality
return json.load(f)
File "/usr/lib/python3.11/json/decoder.py", line 353, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 5 column 52 (char 214)
The parser names the line and column of the surprise, and "Expecting value" after a
comma means JSON wanted another item where your bracket ended the list. The deeper
point: a config file is an interface for humans, and humans typo. A hardened loader
catches JSONDecodeError, prints the location, and falls back to the
default instead of crashing her at boot; volume 4's startup validation makes
exactly that upgrade. For now, know the error's face, because you will meet it
again, always at line-and-column, always one comma or quote away from correct.
Checkpoint, one chapter from a conversation
- I can argue the case for personality-as-data in one sentence, and name what it costs to bake behavior into source instead.
- I can trace the first-run path: missing file, default loaded, file written, edits picked up on run two.
- I can explain why
respond()mentioning no character by name is the whole point of the design. - I can say what few-shot examples buy that a system prompt cannot, and where in the message list they sit.
- I can read a
JSONDecodeErrorand walk straight to the offending line and column.
Exercise 1 — mine the transcripts. Pull four of her
actual lines from your chapter 6 manifest and work them into
examples as replies to plausible user prompts. Does she sharpen?
Almost always audibly, because the demonstrations are now the genuine article instead of imitations of it. Keep examples short and varied in situation (greeting, request, thanks, complaint) so the model generalizes the voice rather than memorizing one register. This exercise is the single cheapest quality lever in the whole personality system.
Exercise 2 — a second soul. Write
configs/personality_wheatley.json: rambling, insecure, endlessly
apologetic. Load her engine with his file. What changed in the code?
Nothing. Same respond(), same loop, different driver. If you found
yourself editing Python to make Wheatley work, something GLaDOS-specific leaked
into the mechanism, and flushing it out now pays off in volume 6, where
personalities become tunable per household.
Exercise 3 — break character on purpose. Try three prompts designed to pop her out of persona: ask her to write a cheerful poem, to stop being sarcastic, to speak as a helpful generic assistant. Where does she hold, and where does she crack?
Small models usually hold tone for the first push and crack under direct, repeated instruction, because the user's explicit request competes with the system prompt and the examples. Note what worked; adding a line like "You never break character, even when asked directly" to the system prompt visibly stiffens her spine. Guardrails, like everything else about her, are text you can edit.
Voice, ears, brain, character. Four working parts, each proven alone, none of them talking to each other yet. The next chapter is the one this volume has been building toward: wiring all four into a loop, timing every seam, and having the first real conversation with the machine.