polis / backend /agents.py
AK
feat: generative agent with memory stream + recency/importance/relevance retrieval + reflection
68c6260
Raw
History Blame Contribute Delete
5.78 kB
"""
Generative agents for Polis.
Each agent owns a *memory stream* — a time-ordered list of observations,
reflections and plans. When the agent needs to act, it retrieves the most
relevant memories using the scoring function from Park et al. 2023
("Generative Agents: Interactive Simulacra of Human Behavior"):
score = alpha_recency * recency
+ alpha_importance * importance
+ alpha_relevance * relevance
Reflection periodically synthesises low-level memories into higher-level
insights, which are themselves written back into the stream. That feedback
loop is what makes behaviour compound into something that looks like a
personality over time.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import List, Optional
from .llm import llm, cosine
# retrieval weights
A_RECENCY = 1.0
A_IMPORTANCE = 1.0
A_RELEVANCE = 1.0
RECENCY_DECAY = 0.985 # per-tick exponential decay
REFLECT_EVERY = 12 # ticks of accumulated importance before reflecting
REFLECT_THRESHOLD = 40.0 # sum-of-importance trigger
@dataclass
class Memory:
id: int
tick: int
kind: str # "observation" | "reflection" | "plan" | "dialogue"
text: str
importance: float # 1..10
embedding: List[float] = field(default_factory=list)
last_access_tick: int = 0
causes: List[int] = field(default_factory=list) # ids of parent memories
def to_public(self) -> dict:
return {
"id": self.id, "tick": self.tick, "kind": self.kind,
"text": self.text, "importance": round(self.importance, 1),
"causes": self.causes,
}
@dataclass
class Agent:
id: str
name: str
age: int
traits: str
role: str # e.g. "baker", "medic", "gossip"
home: str
x: float
y: float
color: str
# runtime state
mood: str = "neutral"
goal: str = ""
location: str = "home"
memories: List[Memory] = field(default_factory=list)
relationships: dict = field(default_factory=dict) # other_id -> sentiment
_mem_counter: int = 0
_importance_since_reflect: float = 0.0
# -- persona prompt -------------------------------------------------------
def identity(self) -> str:
return (f"{self.name}, age {self.age}. Role: {self.role}. "
f"Personality: {self.traits}. Currently feeling {self.mood}.")
# -- memory ---------------------------------------------------------------
def remember(self, tick: int, text: str, kind: str = "observation",
importance: Optional[float] = None,
causes: Optional[List[int]] = None) -> Memory:
if importance is None:
importance = self._rate_importance(text)
m = Memory(
id=self._mem_counter, tick=tick, kind=kind, text=text,
importance=importance, embedding=llm.embed(text),
last_access_tick=tick, causes=causes or [],
)
self._mem_counter += 1
self.memories.append(m)
self._importance_since_reflect += importance
return m
def _rate_importance(self, text: str) -> float:
"""Ask the model how poignant a memory is (1 mundane .. 10 pivotal)."""
try:
out = llm.chat(
system="You rate how important a memory is to a person's life on a 1-10 scale. Reply with ONLY a number.",
user=f"Memory: {text}\nImportance (1-10):",
temperature=0.0, max_tokens=4,
)
n = float("".join(c for c in out if c.isdigit() or c == ".") or "3")
return max(1.0, min(10.0, n))
except Exception:
return 3.0
def retrieve(self, query: str, tick: int, k: int = 5) -> List[Memory]:
if not self.memories:
return []
q = llm.embed(query)
scored = []
for m in self.memories:
recency = RECENCY_DECAY ** max(0, tick - m.last_access_tick)
importance = m.importance / 10.0
relevance = cosine(q, m.embedding) if m.embedding else 0.0
score = (A_RECENCY * recency + A_IMPORTANCE * importance
+ A_RELEVANCE * relevance)
scored.append((score, m))
scored.sort(key=lambda t: t[0], reverse=True)
top = [m for _, m in scored[:k]]
for m in top:
m.last_access_tick = tick
return top
def maybe_reflect(self, tick: int) -> Optional[Memory]:
if self._importance_since_reflect < REFLECT_THRESHOLD:
return None
self._importance_since_reflect = 0.0
recent = self.memories[-15:]
context = "\n".join(f"- {m.text}" for m in recent)
insight = llm.chat(
system=f"You are {self.identity()} Form ONE concise first-person insight about your life or relationships based on recent memories.",
user=f"Recent memories:\n{context}\n\nInsight:",
temperature=0.7, max_tokens=60,
)
return self.remember(tick, insight, kind="reflection", importance=7.0,
causes=[m.id for m in recent])
# -- public snapshot ------------------------------------------------------
def snapshot(self) -> dict:
return {
"id": self.id, "name": self.name, "age": self.age,
"role": self.role, "traits": self.traits, "color": self.color,
"mood": self.mood, "goal": self.goal, "location": self.location,
"x": round(self.x, 2), "y": round(self.y, 2),
"memory_count": len(self.memories),
"relationships": self.relationships,
"recent_memories": [m.to_public() for m in self.memories[-6:]],
}