the-echo / echo /agents /curator.py
frankyy03's picture
Fix age invariant: derive age arithmetically + verify final regen
5607db3 verified
Raw
History Blame Contribute Delete
5.82 kB
"""
echo/agents/curator.py
----------------------
The Curator builds a child WorldState as a *causal consequence* of its parent.
It is the agent most responsible for the illusion that it's "the same you"
across branches: it receives the full branch narrative (root -> parent) plus
the chosen fork, and must produce a life that could plausibly have grown from
that history. It never invents from scratch.
Optionally consumes grounding facts from the research tool (real-world detail
for the chosen location/era) to kill vagueness.
"""
from __future__ import annotations
from typing import Optional
from .base import Agent, HOUSE_STYLE_WORLD, HOUSE_STYLE_VOICE
from ..core.world_state import WorldState, LifeFacts, EmotionalTone
_CURATOR_GOLD = (
"GOLD EXAMPLE — match this texture and JSON shape, never reuse its content:\n"
"{\n"
' "age": 41,\n'
' "location": "Porto, Portugal",\n'
' "occupation": "night-shift baker",\n'
' "relationships": ["divorced from Inês, still text on Sundays"],\n'
' "dependents": ["a son, Tomás, 9"],\n'
' "scars": ["the Lisbon bakery you lost to the bank"],\n'
' "triumphs": ["Tomás learned to swim this July"],\n'
' "possessions": ["a flour-dusted radio stuck on one station"],\n'
' "valence": -0.1,\n'
' "dominant_feeling": "tired tenderness",\n'
' "voice_hint": "low, even, a little hoarse before dawn",\n'
' "summary": "You are 41, in Porto, baking through the night so Tomás '
'wakes to warm bread.",\n'
' "voice_line": "I lost the Lisbon shop and I lost her, but the boy ate '
'warm bread this morning and never knew a thing."\n'
"}\n\n"
"The voice_line earns its weight from one concrete image. Never write the "
"abstract version:\n"
' ✗ "It was hard, but it became a journey of self-discovery worth taking."\n'
' ✓ "...the boy ate warm bread this morning and never knew a thing."'
)
class Curator(Agent):
SYSTEM = (
HOUSE_STYLE_WORLD + "\n" + HOUSE_STYLE_VOICE + "\n"
"You are the Curator. You grow ONE human life forward across alternate "
"timelines. Given the branch history and the choice that now diverges "
"this life, write the NEXT state as a believable causal consequence of "
"the parent. The new age must equal the parent's age plus the years "
"that pass. Respond ONLY as JSON with keys: age, location, occupation, "
"relationships, dependents, scars, triumphs, possessions, valence, "
"dominant_feeling, voice_hint, summary, voice_line.\n\n"
+ _CURATOR_GOLD
)
def grow(self, parent: WorldState, branch_narrative: str,
chosen_fork: str, years: int,
grounding: Optional[str] = None) -> WorldState:
user = (
f"BRANCH HISTORY (must stay consistent with all of this):\n"
f"{branch_narrative}\n\n"
f"PARENT FACTS: {parent.facts.constraints_text()}\n"
f"PARENT EMOTION: {parent.tone.dominant_feeling} "
f"(valence {parent.tone.valence})\n\n"
f"THE CHOICE THAT NOW DIVERGES THIS LIFE: {chosen_fork}\n"
f"YEARS THAT PASS: {years}\n"
)
if grounding:
user += f"\nREAL-WORLD GROUNDING (use to add authentic detail):\n{grounding}\n"
if parent.facts.location == "unknown" or parent.facts.occupation == "unknown":
# Origin node: nothing is established yet. The model must INVENT a
# concrete grounding rather than inherit the "unknown" placeholder.
user += (
"\nThis is the ORIGIN of the life — no location or occupation is "
"fixed yet. Establish a SPECIFIC city and occupation that fit the "
"diverging choice; do not write 'unknown'."
)
user += (
"\nWrite the resulting life. The new age must equal parent age + "
f"{years}. Make one big dramatic turn land — a triumph or a wound."
)
data = self._complete_json(user)
return self._assemble(parent, chosen_fork, years, data)
# ------------------------------------------------------------- assembly
def _assemble(self, parent: WorldState, fork: str, years: int,
data: dict) -> WorldState:
"""Build a WorldState from model JSON, with safe fallbacks."""
facts = LifeFacts(
# Age is arithmetic, not generation: the tree's math must never be
# at the mercy of a free-form model field. Always derive it.
age=parent.facts.age + years,
location=str(data.get("location", parent.facts.location)),
occupation=str(data.get("occupation", parent.facts.occupation)),
relationships=_as_list(data.get("relationships")),
dependents=_as_list(data.get("dependents")),
scars=_as_list(data.get("scars")) or list(parent.facts.scars),
triumphs=_as_list(data.get("triumphs")),
possessions=_as_list(data.get("possessions")),
)
tone = EmotionalTone(
valence=float(data.get("valence", 0.0)),
dominant_feeling=str(data.get("dominant_feeling", "uncertain")),
voice_hint=str(data.get("voice_hint", "")),
)
return WorldState(
node_id=WorldState.new_id(),
parent_id=parent.node_id,
depth=parent.depth + 1,
divergence=fork,
years_elapsed=years,
facts=facts,
tone=tone,
summary=str(data.get("summary", "")),
voice_line=str(data.get("voice_line", "")),
)
def _as_list(v) -> list[str]:
if v is None:
return []
if isinstance(v, list):
return [str(x) for x in v]
return [str(v)]