tinyworld / agents.py
sush0401's picture
TinyWorld + Crisis Mode, ZeroGPU in-process inference
d3a7a1c verified
Raw
History Blame Contribute Delete
38.9 kB
import os
import random
import re
import time
import concurrent.futures
import decide
import world_state
from worlds import get_world
MOCK = os.environ.get("TINYWORLD_MOCK", "0") == "1"
MODAL_REACT_URL = os.environ.get(
"MODAL_REACT_URL",
"https://mitvho09--tinyworld-inference-react-endpoint.modal.run",
)
PRIMARY_MODEL = "nvidia/Nemotron-Mini-4B-Instruct"
_FAILURES = 0
_CIRCUIT_OPEN_UNTIL = 0.0
_RUNTIME_STATUS = {
"mode": "mock" if MOCK else "unknown",
"model": "offline demo" if MOCK else PRIMARY_MODEL,
"message": "Offline demo (mock)" if MOCK else "Model not checked yet",
"latency": None,
"error": None,
}
MOODS = [
"happy", "stressed", "bored", "excited", "hungry",
"tired", "nostalgic", "curious", "proud", "embarrassed",
]
SURPRISE_SEEDS = [
"Slip in one oddly specific detail from earlier today.",
"React as if this event confirms a private theory you never told anyone.",
"Let a petty personal concern steer part of your response.",
"Compare the event to a memory that still annoys you.",
"Say one thing confidently, then soften or complicate it.",
"Ask one pointed question that reveals your bias.",
"Treat one small part of the event as the real emergency.",
"Reveal a secret wish connected to the situation.",
"Interpret the event through your relationship with someone nearby.",
"End with a surprising offer, warning, or demand.",
]
MOOD_CONTEXT = {
"happy": "You are in a good mood. Let that warmth show.",
"stressed": "You are stressed and overwhelmed. It colors your view.",
"bored": "You are bored. Nothing impresses you.",
"excited": "You are buzzing with excitement.",
"hungry": "You are hungry and losing patience.",
"tired": "You are exhausted. Everything is too much effort.",
"nostalgic": "You are nostalgic. The past feels more real.",
"curious": "You are intensely curious. You want answers.",
"proud": "You are proud. This is your turf.",
"embarrassed": "You are embarrassed. You hope nobody noticed.",
}
GENERIC_MOCK = {
"happy": ["Honestly? I love this. Best thing to happen here in ages.", "Oh, this is wonderful. Count me in!"],
"stressed": ["This is exactly what I was afraid of. We need a plan, now.", "Okay, okay — everybody stay calm. Mostly me."],
"bored": ["Huh. Neat, I guess. Wake me if it gets interesting.", "Seen weirder. Slightly."],
"excited": ["No way! This is HUGE, we have to do something!", "I have been WAITING for something like this!"],
"hungry": ["Can we deal with this after I eat? Just asking.", "Great, drama on an empty stomach."],
"tired": ["I do not have the energy for this today.", "Can someone else handle it? I'm wiped."],
"nostalgic": ["This takes me back. Funny how things come around.", "Reminds me of the old days, before all... this."],
"curious": ["Wait, how does that even work? I need to know more.", "Fascinating. Let me get a closer look."],
"proud": ["Stand back — I've got this handled.", "Finally, a chance to show what I can do."],
"embarrassed": ["Oh no. Please tell me nobody saw that.", "I'm just going to pretend that didn't happen."],
}
MOCK_REACTIONS = {
"Marta Voss": {
"happy": [
"Well, that's a pleasant surprise. I could use more days like this around here.",
"Ha! Finally something good happens. Don't tell anyone I said that.",
],
"stressed": [
"Oh great, just what I needed. As if this block wasn't chaotic enough already.",
"I'm going to need a moment. Or a time machine to 1987.",
],
"bored": [
"Huh. That's something, I guess. Wake me when it gets interesting.",
"Back in my day, we had real excitement. This is... fine.",
],
"excited": [
"Now THIS is what I've been waiting for! Reminds me of the great power outage of '09!",
"I've been watching this neighborhood for forty years and THIS is the day!",
],
"hungry": [
"Nice, but can we talk about lunch first? I skipped my tea.",
"That's all well and good, but I haven't had a decent scone since Tuesday.",
],
"tired": [
"Can this wait? I barely slept last night thanks to Jay's delivery bike.",
"*yawn* That's... very... I need my afternoon nap.",
],
"nostalgic": [
"This reminds me of something from '83. Similar energy, different decade.",
"You know, we had something like this once before. Not quite the same though.",
],
"curious": [
"Wait, hold on. Tell me more. What exactly did you see?",
"Something doesn't add up. I've been watching this street for decades and I notice things.",
],
"proud": [
"I've been saying this block would get noticed. Called it years ago.",
"This is exactly the kind of thing I've seen coming. You're all welcome.",
],
"embarrassed": [
"Oh no. Please tell me nobody saw me react to that.",
"I... uh... let's just pretend that didn't happen.",
],
},
"Jay Park": {
"happy": [
"Ha! Finally! I KNEW something good was coming today! My luck's turning around!",
"This is amazing! I'm already texting everyone about it!",
],
"stressed": [
"Oh no. Oh no no no. I literally cannot deal with this right now!",
"My schedule is already packed and NOW this happens?!",
],
"bored": [
"That's... cool I guess? I've seen more exciting delivery routes.",
"Wake me when something actually interesting happens. I'm going for a ride.",
],
"excited": [
"WAIT WAIT WAIT — did you SEE that?! This is HUGE! I'm already on it!",
"I KNEW something like this would happen! My instincts are NEVER wrong!",
],
"hungry": [
"Cool cool cool, but has anyone seen the pizza truck? I'm STARVING.",
"I'd be more excited if I hadn't skipped lunch for three deliveries.",
],
"tired": [
"Mmhmm. Very cool. *stares into distance* I delivered twelve orders today.",
"Can this wait? My legs are literally trembling.",
],
"nostalgic": [
"This gives me the same vibe as that time I found twenty bucks in a taxi. Good memories.",
"You know, this reminds me of my first week on the job. Good times.",
],
"curious": [
"Wait, what?! Tell me EVERYTHING. I need details!",
"Something's up. I ride through here every day and I notice things.",
],
"proud": [
"Ha! I've been saying this neighborhood was special! Who's laughing now?",
"I literally called this. Check my texts from last week.",
],
"embarrassed": [
"Oh god. Please tell me I didn't just yell that out loud.",
"I'm going to ride away now. Very fast. In the opposite direction.",
],
},
"Nia Okafor": {
"happy": [
"Well, that's a nice change of pace. I could get used to this.",
"Good. We deserve something good around here for once.",
],
"stressed": [
"Okay, deep breaths. I've handled worse in the ambulance. This is fine.",
"Of course this happens on my day off. Of course it does.",
],
"bored": [
"Fascinating. Truly. *checks watch* I've seen more action at a stop sign.",
"My enthusiasm is immeasurable. *yawn*",
],
"excited": [
"Now THIS is interesting! Finally, something that gets the blood pumping!",
"Oh, this is good. This is REALLY good. I'm alert now.",
],
"hungry": [
"I've got one eye on this and one eye on the clock. Shift starts in an hour and I need food.",
"Cool, but I'm about to pass out from low blood sugar. Priorities.",
],
"tired": [
"I've seen worse. Much worse. But I'd rather be sleeping right now.",
"Can we... handle this efficiently? I worked a double shift.",
],
"nostalgic": [
"This reminds me of my first month on the job. Similar chaos, different faces.",
"You know, we had something like this at the hospital once. Long story.",
],
"curious": [
"Hold on. What exactly happened? I need the facts before I react.",
"Something's not right here. I read situations for a living and this is off.",
],
"proud": [
"This neighborhood doesn't get enough credit. But I've always known.",
"I've been protecting this block for years. Nice to see others noticing.",
],
"embarrassed": [
"I... that was unprofessional. Let's move on.",
"Nobody saw that. Nobody. *straightens uniform*",
],
},
"Luca Bell": {
"happy": [
"OH MY GOSH THIS IS THE BEST DAY EVER! I need to document everything!",
"I KNEW something amazing would happen! My notebook predicted this!",
],
"stressed": [
"This is TERRIBLE! This is the worst thing that has EVER happened on this block!",
"I'm writing this down as a DISASTER. My notebook needs a new chapter!",
],
"bored": [
"That's it? I expected more. My notebook is disappointed.",
"I've been waiting for something cool and THIS is what we get?",
],
"excited": [
"THIS IS THE MOST IMPORTANT THING THAT HAS EVER HAPPENED! I'M SHAKING!",
"I need to record this IMMEDIATELY! Future historians will thank me!",
],
"hungry": [
"I'd be more excited but I skipped lunch to wait for something cool to happen.",
"This is amazing but I'm literally starving. Can we celebrate with snacks?",
],
"tired": [
"I want to care about this but my eyes won't stay open.",
"Can... can this wait? I was up until 2am researching neighborhood legends.",
],
"nostalgic": [
"This reminds me of the time I found that old map in Marta's shop! Similar vibes!",
"You know, this is exactly the kind of thing I've been writing about for months!",
],
"curious": [
"Wait WHAT?! I need to investigate this IMMEDIATELY! Where's my notebook?!",
"This is suspicious. Very suspicious. I'm going to get to the bottom of this!",
],
"proud": [
"I TOLD everyone something amazing would happen here! Check my notebook!",
"This is proof! This neighborhood is SPECIAL and I've been saying it all along!",
],
"embarrassed": [
"I just screamed really loud didn't I? Please tell me nobody heard that.",
"I'm going to go hide in my room now. For several days.",
],
},
"Priya Raman": {
"happy": [
"Well, this is a welcome surprise. I'll add it to the positive column.",
"Good. This block could use some good news for a change.",
],
"stressed": [
"First, let's assess the situation. Second, let's make a plan. Third, PANIC.",
"I'm making a list. Of everything that could go wrong. It's a long list.",
],
"bored": [
"I'll believe it when I see it. Until then, I have zoning reports to review.",
"Everyone calm down. This probably isn't what it seems.",
],
"excited": [
"Okay, I'll admit it — this is genuinely exciting. I'm making an exception to my skepticism.",
"This changes the whole dynamic of the block! I need to recalculate everything!",
],
"hungry": [
"I hate to admit it, but I'm too hungry to think clearly right now.",
"Can we table this discussion until after lunch? I'm not functioning.",
],
"tired": [
"I've got my eye on this, but I'm too exhausted to process it fully.",
"This is significant, but I need coffee before I can formulate a proper response.",
],
"nostalgic": [
"There's something beautiful about chaos, isn't there? Even organized chaos.",
"I hate to admit it, but this is kind of poetic. In a messy sort of way.",
],
"curious": [
"Something doesn't add up here. I'm going to look into this officially.",
"I need more data before I form an opinion. This feels... incomplete.",
],
"proud": [
"I've spent years trying to improve this block. Nice to see it getting attention.",
"This is exactly the kind of thing I've been working toward. You're welcome.",
],
"embarrassed": [
"I just got excited about something unprofessional didn't I? Let's never speak of this.",
"My carefully maintained composure just cracked. Wonderful.",
],
},
}
MOVEMENT_HINTS = [
"If you'd naturally move somewhere during this event, end your reaction with [GOTO: hotspot_name].",
"If this event would make you walk to a different part of the neighborhood, end with [GOTO: hotspot_name].",
"Stay put unless the event strongly draws you somewhere else. If you move, end with [GOTO: hotspot_name].",
]
HOTSPOT_KEYS = [
"marta_home", "cafe", "park", "square", "jay_home",
"school", "nia_clinic", "priya_office",
]
def _build_mood_context(mood):
return MOOD_CONTEXT.get(mood, "")
def _hotspots(world):
"""Destinations a character can choose. Prefer board tiles (what the app maps
and the renderer draws); fall back to the legacy top-level dict. Robust for
every world — starhaven/old_town only define board['hotspots_tile']."""
return (world.get("board", {}) or {}).get("hotspots_tile") or world.get("hotspots") or {}
def build_agent_prompt(character, event, mood, memory, relationships, world):
seed = random.choice(SURPRISE_SEEDS)
movement = random.choice(MOVEMENT_HINTS)
memory_str = ""
if memory:
memory_str = "Your recent memories: " + "; ".join(memory[-4:]) + "."
reflection = world_state.get_reflection(world["id"], character["name"])
reflection_str = ""
if reflection:
reflection_str = f"Inner reflection: {reflection}"
hotspots = ", ".join(_hotspots(world).keys())
persona = (
f"You are {character['name']}, a {character['age']}-year-old {character['job']}. "
f"Traits: {', '.join(character['traits'])}. "
f"Backstory: {character['backstory']}. "
f"Catchphrase style: {character.get('catchphrase_hint', '')}. "
f"{relationships} "
f"{memory_str} "
f"{reflection_str}"
)
first = character["name"].split()[0]
prompt = (
f"{persona}\n\n"
f"Your mood right now: {mood}. {_build_mood_context(mood)}\n\n"
f"Something just happened on your street: \"{event}\"\n\n"
f"React the way {first} truly would. In 1 to 2 short sentences, speak OUT LOUD in first person "
f"— your own voice — showing you understood what happened and what you mean to do about it. "
f"{seed} "
f"Speak ONLY as {first}: no narration, no stage directions, no notes to yourself, no labels, no quotation marks."
)
return prompt
# scratch / meta lines a weak model leaks instead of staying in character
_META_RE = re.compile(
r"^(let me\b|i need to\b|i should\b|i'll (incorporate|write|make|ensure)|now,?\s*i\b|"
r"but the format\b|i think that works\b|first,?\s*i\b|okay,?\s*(so|let)\b|here('s| is)\b|"
r"in the (do|think|say)\b|for (the )?(say|do|think)\b|the format\b|as an ai\b|sure,?\s|note:)",
re.IGNORECASE,
)
# task-referential phrases that mean the model is talking about the assignment, not the street
_META_CONTAINS = re.compile(
r"(sentence|i'?ll count|let'?s (write|count|ensure)|1 to 2|the format|in character|"
r"the prompt|stage direction|first sentence|i (will|'?ll) (say|write))",
re.IGNORECASE,
)
def _clean_line(raw):
"""Pull a clean, in-character spoken line out of a messy small-model reply."""
raw = (raw or "").strip()
m = re.search(r"say\s*:\s*(.+)", raw, re.IGNORECASE) # honor old SAY: format if used
cand = m.group(1).strip() if m else raw
cand = re.sub(r"\b(think|do|say|goto)\s*:\s*", " ", cand, flags=re.IGNORECASE)
cand = re.sub(r"\[[^\]]*\]", "", cand) # [GOTO: ...] / brackets
cand = re.sub(r"\*[^*]*\*", "", cand) # *stage directions*
cand = cand.replace("\n", " ").strip()
parts = re.split(r"(?<=[.!?])\s+", cand)
keep = [p.strip(" \"'") for p in parts
if p.strip() and not _META_RE.match(p.strip()) and not _META_CONTAINS.search(p)]
# empty => nothing in-character survived; caller falls back to a persona line
return " ".join(keep[:2]).strip(" \"'")
def _parse_goto(text):
match = re.search(r'\[GOTO:\s*(\w+)\]', text)
if match:
hotspot = match.group(1)
clean_text = re.sub(r'\[GOTO:\s*\w+\]', '', text).strip()
if hotspot in HOTSPOT_KEYS:
return clean_text, hotspot
return clean_text, None
return text, None
def _field(text, key):
m = re.search(rf'^\s*{key}\s*:\s*(.+?)\s*$', text, re.IGNORECASE | re.MULTILINE)
return m.group(1).strip() if m else ""
def _parse_structured(raw, world):
"""Pull THINK / DO / SAY / GOTO out of a reasoning reply. Falls back to the
older [GOTO] / free-text shape so a model that ignores the format still works."""
think = _field(raw, "THINK")
do = _field(raw, "DO")
say = _field(raw, "SAY")
goto_raw = _field(raw, "GOTO")
goto = None
keys = list(_hotspots(world).keys())
if goto_raw:
cand = re.sub(r'[^a-z_]', '', goto_raw.lower().replace(" ", "_"))
if cand in keys:
goto = cand
else:
for k in keys:
if k in cand or cand in k:
goto = k
break
if not say:
# model didn't follow the format — treat the whole thing as the spoken line
say, legacy_goto = _parse_goto(raw.strip())
goto = goto or legacy_goto
# trim a stray DO that leaked into the spoken line
say = re.sub(r'^(THINK|DO|SAY|GOTO)\s*:\s*', '', say, flags=re.IGNORECASE).strip()
return think, do, say or raw.strip(), goto
def _compute_drama(text):
drama = random.uniform(0.2, 0.9)
exclamations = text.count("!")
caps_words = sum(1 for w in text.split() if w.isupper() and len(w) > 2)
drama = min(1.0, drama + exclamations * 0.05 + caps_words * 0.05)
return round(drama, 3)
def _compute_vibe_delta(mood):
base = {"energy": 0.0, "social": 0.0}
mood_energy = {
"happy": 0.08, "excited": 0.12, "proud": 0.06, "curious": 0.04,
"stressed": -0.1, "bored": -0.08, "tired": -0.15, "hungry": -0.05,
"nostalgic": 0.02, "embarrassed": -0.03,
}
mood_social = {
"happy": 0.1, "excited": 0.08, "curious": 0.06, "proud": 0.02,
"nostalgic": 0.04, "stressed": -0.06, "bored": -0.1, "tired": -0.08,
"hungry": -0.04, "embarrassed": -0.05,
}
base["energy"] = mood_energy.get(mood, 0.0) + random.uniform(-0.02, 0.02)
base["social"] = mood_social.get(mood, 0.0) + random.uniform(-0.02, 0.02)
return base
def _compute_affinity_deltas(name, character, mood):
deltas = {}
for other_name, rel in character.get("relationships", {}).items():
if rel == "friend":
deltas[other_name] = random.uniform(0.01, 0.04)
elif rel == "rival":
deltas[other_name] = random.uniform(-0.04, -0.01)
elif rel == "crush":
deltas[other_name] = random.uniform(0.02, 0.06)
elif rel == "family":
deltas[other_name] = random.uniform(0.01, 0.03)
if mood in ("stressed", "angry"):
deltas[other_name] = deltas.get(other_name, 0) - 0.02
elif mood in ("happy", "excited"):
deltas[other_name] = deltas.get(other_name, 0) + 0.01
return deltas
# keyword -> where a sensible person would head to deal with that kind of situation
_EVENT_HOTSPOT = [
(("power", "outage", "lights", "electric"), "priya_office"),
(("school", "kids", "class", "student"), "school"),
(("hurt", "injur", "accident", "sick", "fire", "emergency", "storm"), "nia_clinic"),
(("food", "truck", "hungry", "coffee", "cafe", "café"), "cafe"),
(("park", "tree", "object", "glow", "mural", "cat", "kitten", "animal"), "park"),
(("meeting", "everyone", "gather", "crowd", "news", "vote"), "square"),
]
def _mock_brain(character, event, world):
"""Cheap stand-in for real reasoning: a relevant destination + a concrete action,
so even the offline demo shows characters engaging the situation, not just emoting."""
e = event.lower()
hs = _hotspots(world)
goto = None
for keys, spot in _EVENT_HOTSPOT:
if any(k in e for k in keys) and spot in hs:
goto = spot
break
if not goto:
goto = "square" if "square" in hs else (random.choice(list(hs.keys())) if hs else None)
# spread the cast out: about half deal with it on their own turf instead of all piling onto one spot
home = character.get("home")
if home in hs and random.random() < 0.5:
goto = home
job = character.get("job", "neighbor")
action = random.choice([
f"head to the {goto.replace('_', ' ')} to see it firsthand",
f"go to the {goto.replace('_', ' ')} and do something about it",
f"use what they know as a {job} to help",
f"rally a neighbor and act on it together",
f"check on the people most affected first",
])
understanding = "This changes things on the block — better to deal with it than ignore it."
return understanding, action, goto
def _mock_react(character, event, mood, world):
char_name = character["name"]
mood_reactions = MOCK_REACTIONS.get(char_name, GENERIC_MOCK)
pool = mood_reactions.get(mood, mood_reactions.get("bored", ["Hmm. Interesting."]))
text = random.choice(pool)
drama = _compute_drama(text)
text, goto = _parse_goto(text)
understanding, action, brain_goto = _mock_brain(character, event, world)
if not goto:
goto = brain_goto
return {
"name": char_name,
"mood": mood,
"text": text,
"understanding": understanding,
"action": action,
"model": character["model"],
"drama": drama,
"moved_to": goto,
"vibe_delta": _compute_vibe_delta(mood),
"affinity_deltas": _compute_affinity_deltas(char_name, character, mood),
}
def _real_react(character, event, mood, memory, relationships, world):
global _FAILURES, _CIRCUIT_OPEN_UNTIL
prompt = build_agent_prompt(character, event, mood, memory, relationships, world)
try:
import httpx
now = time.time()
if _CIRCUIT_OPEN_UNTIL > now:
_set_runtime_status("error", character["model"], "LLM error", None, "circuit breaker cooling down")
return _error_reaction(
character, mood,
f"model unreachable — retry after {int(_CIRCUIT_OPEN_UNTIL - now)}s",
)
payload = {
"event": event,
"characters": [character],
"prompts": {character["name"]: prompt},
}
data = None
last_error = None
for attempt in range(2):
try:
timeout = 180.0 if attempt == 0 else 45.0
_set_runtime_status("waking", character["model"], "Waking the model (~90s)", None)
start = time.time()
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.post(MODAL_REACT_URL, json=payload)
resp.raise_for_status()
data = resp.json()
latency = round(time.time() - start, 2)
_FAILURES = 0
_set_runtime_status("live", character["model"], "Live", latency)
break
except Exception as e:
last_error = e
if attempt == 0:
time.sleep(1.0)
if data is None:
raise last_error or RuntimeError("Modal request failed")
reactions = data.get("reactions", [])
if reactions and not reactions[0].get("error"):
raw = reactions[0]["text"]
text = _clean_line(raw) # real LLM dialogue, de-noised
if len(text) < 4:
return _error_reaction(character, mood, "model returned unusable text")
# movement + action are decided by the engine so a weak model can't break them
understanding, action, goto = _mock_brain(character, event, world)
drama = _compute_drama(text + " " + action)
return {
"name": character["name"],
"mood": mood,
"text": text,
"understanding": understanding,
"action": action,
"model": character["model"],
"drama": drama,
"moved_to": goto,
"vibe_delta": _compute_vibe_delta(mood),
"affinity_deltas": _compute_affinity_deltas(character["name"], character, mood),
"error": False,
}
message = reactions[0].get("text", "model unreachable") if reactions else "model unreachable"
raise RuntimeError(message)
except Exception as e:
_FAILURES += 1
if _FAILURES >= 3:
_CIRCUIT_OPEN_UNTIL = time.time() + 60.0
print(f"[agents] Modal call failed for {character['name']}: {e}")
_set_runtime_status("error", character["model"], "LLM error", None, str(e))
return _error_reaction(character, mood, f"model unreachable — retrying ({e})")
def _set_runtime_status(mode, model, message, latency=None, error=None):
_RUNTIME_STATUS.update({
"mode": "mock" if MOCK else mode,
"model": "offline demo" if MOCK else (model or PRIMARY_MODEL),
"message": "Offline demo (mock)" if MOCK else message,
"latency": latency,
"error": error,
})
def get_runtime_status():
if MOCK:
return {
"mode": "mock",
"model": "offline demo",
"message": "Offline demo (mock)",
"latency": None,
"error": None,
}
return dict(_RUNTIME_STATUS)
def _error_reaction(character, mood, message):
return {
"name": character["name"],
"mood": mood,
"text": f"[{message}]",
"understanding": "The model call failed, so no in-character decision was produced.",
"action": "wait for the model to become reachable",
"model": character["model"],
"drama": 0.05,
"moved_to": None,
"vibe_delta": {"energy": 0.0, "social": 0.0},
"affinity_deltas": {},
"error": True,
}
def _needs_from_vibes(world_id, name):
vibes = world_state.get_vibes(world_id).get(name, {"energy": 0.5, "social": 0.5})
return {
"energy": int(vibes.get("energy", 0.5) * 100),
"hunger": 45,
"social": int(vibes.get("social", 0.5) * 100),
}
def _reaction_from_decision(character, decision):
vibe_delta = _compute_vibe_delta(decision.mood)
vibe_delta["energy"] += decision.need_deltas.get("energy", 0) / 100.0
vibe_delta["social"] += decision.need_deltas.get("social", 0) / 100.0
goto = None if decision.goto == "stay" else decision.goto
text = decision.say
if decision.degraded and decision.warning:
text = f"{text} [decision degraded: {decision.warning}]"
return {
"name": character["name"],
"mood": decision.mood,
"text": text,
"understanding": decision.think,
"action": decision.action,
"model": character["model"],
"drama": _compute_drama(f"{decision.say} {decision.action}"),
"moved_to": goto,
"vibe_delta": vibe_delta,
"affinity_deltas": _compute_affinity_deltas(character["name"], character, decision.mood),
"error": decision.error,
"degraded": decision.degraded,
}
def _backend():
"""Which inference backend to use: 'mock' (offline), 'local' (ZeroGPU in-process),
or 'modal' (HTTP to Modal, the default)."""
if MOCK:
return "mock"
return os.environ.get("TINYWORLD_INFER", "modal").lower()
def _char_context(character, world):
wid = world["id"]
name = character["name"]
return {
"mood": world_state.get_mood(wid, name),
"memory": world_state.get_memory(wid, name),
"relationships": _format_relationships(character),
"position": world_state.get_position(wid, name) or character.get("home", "square"),
"needs": _needs_from_vibes(wid, name),
}
def _finalize_reaction(character, world, result, route, event):
"""Apply directed-command forcing, then persist mood + memory. Shared by every
backend so the engine behaves identically however the dialogue was produced."""
wid = world["id"]
forced_goto = (route or {}).get("goto")
hotspots = decide.allowed_hotspots(world)
if (route or {}).get("type") == "directed_command" and not result.get("error"):
if forced_goto in hotspots:
result["moved_to"] = forced_goto
# Surface the player's own instruction into the action so Crisis Mode's
# resolver can read what was ordered and obeyed — the small dialogue model
# often paraphrases the order and drops the keyword the resolver looks for.
instr = (route or {}).get("instruction", "")
base = result.get("action") or "follow the instruction"
if instr and instr.lower() not in base.lower():
result["action"] = f"{base} ({instr})"
elif forced_goto in hotspots and not result.get("error"):
result["moved_to"] = forced_goto
world_state.set_mood(wid, character["name"], result["mood"])
# learn from what they DID, not just what they said, so future reactions build on it
learned = result.get("action") or result["text"]
world_state.add_memory(wid, character["name"], event, learned)
return result
def _react_one(character, event, world, route=None):
"""One character via mock or Modal (per-call). The ZeroGPU path batches instead
— see _react_local."""
global _FAILURES, _CIRCUIT_OPEN_UNTIL
ctx = _char_context(character, world)
mood = ctx["mood"]
if MOCK:
decision_obj = decide.mock_decision(character, event, mood, world, route)
result = _reaction_from_decision(character, decision_obj)
else:
try:
now = time.time()
if _CIRCUIT_OPEN_UNTIL > now:
_set_runtime_status("error", character["model"], "LLM error", None, "circuit breaker cooling down")
return _error_reaction(
character, mood,
f"model unreachable — retry after {int(_CIRCUIT_OPEN_UNTIL - now)}s",
)
_set_runtime_status("waking", character["model"], "Waking the model (~90s)", None)
decision_obj = decide.decide_real(
character, event, mood, ctx["memory"], ctx["relationships"], world,
ctx["position"], ctx["needs"], route
)
_FAILURES = 0
_set_runtime_status("live", character["model"], "Live", decision_obj.latency)
result = _reaction_from_decision(character, decision_obj)
except Exception as e:
_FAILURES += 1
if _FAILURES >= 3:
_CIRCUIT_OPEN_UNTIL = time.time() + 60.0
print(f"[agents] Decision failed for {character['name']}: {e}")
_set_runtime_status("error", character["model"], "LLM error", None, str(e))
result = _error_reaction(character, mood, f"model unreachable — retrying ({e})")
return _finalize_reaction(character, world, result, route, event)
def _react_local(cast, event, world, route=None):
"""All characters in ONE ZeroGPU allocation: build every prompt, run a single
batched generate, then parse + finalize each. Avoids 5 separate GPU grabs."""
import inference # heavy (torch/transformers); imported only on the local backend
contexts, prompts = {}, []
for ch in cast:
ctx = _char_context(ch, world)
contexts[ch["name"]] = ctx
prompts.append(decide.build_decision_prompt(
ch, event, ctx["mood"], ctx["memory"], ctx["relationships"],
world, ctx["position"], ctx["needs"], route))
try:
_set_runtime_status("waking", PRIMARY_MODEL, "Running on ZeroGPU…", None)
start = time.time()
raws = inference.generate_batch(prompts)
latency = round(time.time() - start, 2)
_set_runtime_status("live", PRIMARY_MODEL, "Live (ZeroGPU)", latency)
except Exception as e:
print(f"[agents] ZeroGPU batch failed: {e}")
_set_runtime_status("error", PRIMARY_MODEL, "GPU error", None, str(e))
return [_error_reaction(ch, contexts[ch["name"]]["mood"], f"GPU error ({e})") for ch in cast]
reactions = []
for ch, raw in zip(cast, raws):
decision = decide.parse_decision(raw, world, previous_mood=contexts[ch["name"]]["mood"])
result = _reaction_from_decision(ch, decision)
reactions.append(_finalize_reaction(ch, world, result, route, event))
return reactions
def _format_relationships(character):
relationships = character.get("relationships", {})
if not relationships:
return "Relationships: nobody notable in this neighborhood yet."
parts = [f"{other_name} is your {relationship}" for other_name, relationship in relationships.items()]
return "Relationships: " + "; ".join(parts) + "."
def react(world_id, event, mode="solo", route=None):
world = get_world(world_id)
if not world:
raise ValueError(f"Unknown world: {world_id}")
state = world_state.get_state(world_id)
world_state.init_cast(world)
world_state.increment_event(world_id)
target_names = set((route or {}).get("addressees") or [])
cast = [c for c in world["cast"] if not target_names or c["name"] in target_names]
if not cast:
cast = world["cast"]
if _backend() == "local":
# ZeroGPU: one batched GPU call for the whole cast.
reactions = _react_local(cast, event, world, route)
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(_react_one, char, event, world, route): char
for char in cast
}
reactions = []
for future in concurrent.futures.as_completed(futures):
try:
reactions.append(future.result())
except Exception as e:
char = futures[future]
print(f"[agents] error for {char['name']}: {e}")
mood = world_state.get_mood(world_id, char["name"])
reactions.append({
"name": char["name"],
"mood": mood,
"text": f"[Something went wrong, but {char['name']} is still thinking...]",
"model": char["model"],
"drama": 0.1,
"moved_to": None,
"vibe_delta": {"energy": 0, "social": 0},
"affinity_deltas": {},
})
order = {c["name"]: i for i, c in enumerate(world["cast"])}
reactions.sort(key=lambda r: order.get(r["name"], 99))
for r in reactions:
world_state.apply_vibe_delta(world_id, r["name"], r["vibe_delta"])
world_state.apply_affinity_delta(world_id, r["name"], r["affinity_deltas"])
if r["moved_to"]:
world_state.set_position(world_id, r["name"], r["moved_to"])
town_delta = sum(r["vibe_delta"]["energy"] for r in reactions) / len(reactions)
current_town = world_state.get_town_mood(world_id)
world_state.set_town_mood(world_id, current_town + town_delta)
for c in cast:
if state["event_count"] % 3 == 0:
world_state.maybe_form_reflection(world_id, c["name"])
return {
"reactions": reactions,
"town_mood_delta": round(town_delta, 4),
"runtime": get_runtime_status(),
}
def generate_followup(reactions, event):
if random.random() > 0.4:
return None
source = random.choice(reactions)
others = [r for r in reactions if r["name"] != source["name"]]
if not others:
return None
target = random.choice(others)
templates = [
f"Because {source['name']} reacted that way, {target['name']} quietly decides to keep an eye on them.",
f"{target['name']} overheard {source['name']}'s reaction and can't stop thinking about it.",
f"After hearing {source['name']}, {target['name']} changes their mind about the whole thing.",
f"{target['name']} wonders if {source['name']} knows something they're not saying.",
f"The look on {source['name']}'s face tells {target['name']} everything they need to know.",
f"{target['name']} files away {source['name']}'s reaction for later. That was... telling.",
f"While everyone else moves on, {target['name']} notices {source['name']} is still upset.",
f"{target['name']} wants to ask {source['name']} what they really think, but not in front of everyone.",
]
return {
"type": "followup",
"source": source["name"],
"target": target["name"],
"text": random.choice(templates),
"mood": target["mood"],
}
if __name__ == "__main__":
from worlds import get_world
w = get_world("maple_street")
world_state.init_cast(w)
result = react("maple_street", "A mysterious glowing object lands in the park")
for r in result["reactions"]:
print(f"{r['name']} | {r['mood']} | goto={r['moved_to']} | {r['text']}")
print(f"\nTown mood delta: {result['town_mood_delta']}")
followup = generate_followup(result["reactions"], "A mysterious glowing object lands in the park")
if followup:
print(f"\n[FOLLOWUP] {followup['text']}")