valhalla / backend /src /agents /Short_term.py
Dontcryx_07
final
d660fa9
Raw History Blame Contribute Delete
21.7 kB
"""Short_term — per-agent, per-day operational memory.
Stores the full day (plan, events, conversations, world snapshots, LLM
daily summary) as one JSON file per persona per date, with atomic writes,
and archives the day to Qdrant long-term memory at handoff.
Architecture: the memory layer between the engine and long-term storage;
written by world_engine.py, read by brain.py and day_planner.py.
Design: short-term files are the operational record of the active day
and are removed after successful archival.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel
from src.config import DATA_DIR
from src.core.log import get_logger
logger = get_logger(__name__)
# Directory for short-term memory files
SHORT_TERM_DIR = DATA_DIR / "Short_term_db"
_DAY_LOCKS_GUARD = threading.Lock()
_DAY_LOCKS: dict[Path, threading.RLock] = {}
# ---------------------------------------------------------------------------
# Data Models (internal)
# ---------------------------------------------------------------------------
class DayEvent(BaseModel):
"""One event that happened during the day."""
timestamp: str # ISO format with timezone
type: str # action_started, action_completed, conversation, world_snapshot, observation
action: Optional[str] = None
outcome: Optional[str] = None
success: Optional[bool] = None
location: Optional[str] = None
with_agent: Optional[str] = None
participants: Optional[list[str]] = None
topic: Optional[str] = None
summary: Optional[str] = None
messages: Optional[list[dict]] = None
details: Optional[dict] = None
class ConversationEntry(BaseModel):
"""A conversation with another agent."""
timestamp: str
participants: list[str]
messages: list[dict]
summary: str
class WorldSnapshotEntry(BaseModel):
"""Periodic world state snapshot."""
timestamp: str
location: str
nearby_agents: list[str]
weather: Optional[str] = None
details: Optional[dict] = None
class ShortTermDayData(BaseModel):
"""Complete day memory for one persona on one simulation date."""
persona_name: str
date: str
day_plan: list[dict] = []
events: list[DayEvent] = []
conversations: list[ConversationEntry] = []
world_snapshots: list[WorldSnapshotEntry] = []
daily_summary: Optional[str] = None
archived_to_long_term: bool = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _persona_dir(persona_name: str) -> Path:
"""Get the directory for a persona's short-term files."""
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", persona_name.strip()).strip("._-").lower() or "unknown"
dir_path = SHORT_TERM_DIR / safe_name
dir_path.mkdir(parents=True, exist_ok=True)
return dir_path
def _day_file_path(persona_name: str, date_str: str) -> Path:
"""Get the file path for a specific persona and date."""
return _persona_dir(persona_name) / f"{date_str}.json"
def _day_lock(persona_name: str, date_str: str) -> threading.RLock:
path = _day_file_path(persona_name, date_str).resolve()
with _DAY_LOCKS_GUARD:
return _DAY_LOCKS.setdefault(path, threading.RLock())
def _mutate_day_data(persona_name: str, date_str: str, mutate) -> None:
"""Serialize a complete same-day read/modify/write transaction."""
with _day_lock(persona_name, date_str):
data = _load_day_data(persona_name, date_str)
if data is None:
data = ShortTermDayData(persona_name=persona_name, date=date_str)
mutate(data)
_save_day_data(persona_name, date_str, data)
def date_from_simulation_time(time_str: str) -> str:
"""
Extract simulation date from a time string like '2026-07-03 06:00'.
Returns 'YYYY-MM-DD'.
"""
# Expected format: "YYYY-MM-DD HH:MM" or just "YYYY-MM-DD HH:MM:SS"
match = re.match(r"(\d{4}-\d{2}-\d{2})", time_str)
if match:
return match.group(1)
# Fallback: try parsing
try:
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
return dt.date().isoformat()
except Exception:
# Last resort: today's date (real)
return datetime.now(timezone.utc).date().isoformat()
def _load_day_data(persona_name: str, date_str: str) -> Optional[ShortTermDayData]:
"""Load day data from JSON file."""
path = _day_file_path(persona_name, date_str)
if not path.exists():
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
return ShortTermDayData.model_validate(raw)
except Exception as e:
logger.error("Failed to load short-term data for %s on %s: %s", persona_name, date_str, e)
return None
def _save_day_data(persona_name: str, date_str: str, data: ShortTermDayData) -> None:
"""Atomically replace a day file so a process crash cannot leave partial JSON."""
path = _day_file_path(persona_name, date_str)
path.parent.mkdir(parents=True, exist_ok=True)
payload = data.model_dump_json(indent=2)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{path.stem}.", suffix=".tmp", dir=path.parent, text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as temporary:
temporary.write(payload)
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_name, path)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
logger.debug("Saved short-term data for %s on %s to %s", persona_name, date_str, path)
# ---------------------------------------------------------------------------
# Public API -- MemoryStreamProtocol (for tick_graph.py)
# ---------------------------------------------------------------------------
def add_memory(agent_id: str, content: str, importance: Optional[int] = None, date_str: Optional[str] = None) -> None:
"""
Add a memory entry for an agent.
Stores as an event in the short-term file for the given (or current) date.
"""
if date_str is None:
date_str = datetime.now(timezone.utc).date().isoformat()
data = _load_day_data(agent_id, date_str)
if data is None:
data = ShortTermDayData(persona_name=agent_id, date=date_str)
data.events.append(DayEvent(
timestamp=datetime.now(timezone.utc).isoformat(),
type="memory",
details={"content": content, "importance": importance}
))
_save_day_data(agent_id, date_str, data)
def retrieve_memories(agent_id: str, query: str, k: int = 5, date_str: Optional[str] = None) -> list[str]:
"""Retrieve durable context through the Qdrant semantic-memory RAG layer."""
from src.agents.Long_term import get_retriever
return get_retriever().retrieve(agent_id, query, k)
# ---------------------------------------------------------------------------
# Public API -- Day Planner Integration
# ---------------------------------------------------------------------------
def save_day_plan(persona_name: str, date_str: str, plan: list[dict]) -> None:
"""Save the generated day plan at the start of the day."""
data = _load_day_data(persona_name, date_str)
if data is None:
data = ShortTermDayData(persona_name=persona_name, date=date_str)
data.day_plan = plan
_save_day_data(persona_name, date_str, data)
def load_day_plan(persona_name: str, date_str: str) -> list[dict]:
"""Load the day plan for a given date."""
data = _load_day_data(persona_name, date_str)
return data.day_plan if data else []
# ---------------------------------------------------------------------------
# Single-file hygiene (one short-term file per agent; clean start each run)
# ---------------------------------------------------------------------------
def is_pristine_plan(persona_name: str, date_str: str) -> bool:
"""True if the day file holds a plan but none of the run-time record yet
(no events, conversations, or world snapshots)."""
data = _load_day_data(persona_name, date_str)
return bool(
data and data.day_plan
and not data.events and not data.conversations and not data.world_snapshots
)
def reset_day_runtime(persona_name: str, date_str: str) -> None:
"""Clear the run-time record (events / conversations / snapshots) but keep
the plan, so a day starts fresh — the file holds only the plan again."""
data = _load_day_data(persona_name, date_str)
if data is None:
return
data.events = []
data.conversations = []
data.world_snapshots = []
data.archived_to_long_term = False
_save_day_data(persona_name, date_str, data)
def list_day_files(persona_name: str) -> list[str]:
"""All simulation dates that currently have a short-term file for this agent."""
d = _persona_dir(persona_name)
return sorted(p.stem for p in d.glob("*.json"))
def archive_copy_no_llm(persona_name: str, date_str: str) -> bool:
"""Index a completed operational day without making a summary LLM call.
This is used only to clean up stray short-term files. It never writes a
local long-term JSON archive, and it retains the source file on failure.
"""
data = _load_day_data(persona_name, date_str)
if data is None:
return False
try:
from src.agents.Long_term import get_retriever
return bool(get_retriever().index_archive(persona_name, data.model_dump()))
except Exception as exc:
logger.warning("[Short_term] unable to index stray day %s for %s: %s", date_str, persona_name, exc)
return False
def consolidate_to_single_day(persona_name: str, keep_date: str) -> list[str]:
"""Ensure the agent keeps only ONE short-term file (for `keep_date`).
Any other day files are indexed into Qdrant (no LLM) and removed only
after Qdrant confirms the write. Returns the dates safely removed.
"""
moved: list[str] = []
for date_str in list_day_files(persona_name):
if date_str == keep_date:
continue
if archive_copy_no_llm(persona_name, date_str):
clear_short_term_data(persona_name, date_str)
moved.append(date_str)
if moved:
logger.info(
"[Short_term] consolidated %s: indexed %d stray day file(s) into Qdrant (%s)",
persona_name, len(moved), ", ".join(moved),
)
return moved
def append_event(persona_name: str, date_str: str, event: dict) -> None:
"""
Append an event during the day.
event dict should have: type, action, outcome, success, location, details, etc.
"""
entry = dict(event)
entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
_mutate_day_data(
persona_name, date_str,
lambda data: data.events.append(DayEvent(**entry)),
)
def append_conversation(persona_name: str, date_str: str, conversation: dict) -> None:
"""
Append a conversation during the day.
conversation dict: participants, messages, summary
"""
entry = dict(conversation)
entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
_mutate_day_data(
persona_name, date_str,
lambda data: data.conversations.append(ConversationEntry(**entry)),
)
def append_world_snapshot(persona_name: str, date_str: str, snapshot: dict) -> None:
"""
Append a world state snapshot during the day.
snapshot dict: location, nearby_agents, weather, details
"""
entry = dict(snapshot)
entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
_mutate_day_data(
persona_name, date_str,
lambda data: data.world_snapshots.append(WorldSnapshotEntry(**entry)),
)
def get_yesterday_data(persona_name: str, today_date: str) -> Optional[ShortTermDayData]:
"""
Get the full yesterday's data for a persona.
today_date: 'YYYY-MM-DD' (simulation date)
Returns yesterday's ShortTermDayData or None.
"""
try:
today_dt = datetime.fromisoformat(today_date).date()
yesterday = (today_dt - timedelta(days=1)).isoformat()
except Exception:
# Fallback to real date
yesterday = (datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat()
return _load_day_data(persona_name, yesterday)
def get_yesterday_summary(persona_name: str, today_date: str) -> Optional[str]:
"""
Get a text summary of yesterday for the day planner prompt.
Returns None if no yesterday data exists.
"""
from src.agents.Long_term import get_retriever
return get_retriever().rolling_summary(persona_name, days=1, before_date=today_date)
def get_relevant_memories(persona_name: str, today_date: str, query: str, k: int = 10) -> list[str]:
"""
Get relevant long-term memories for model prompts through semantic search.
"""
return retrieve_memories(persona_name, query, k, date_str=today_date)
# ---------------------------------------------------------------------------
# Public API -- End of Day Lifecycle
# ---------------------------------------------------------------------------
async def generate_daily_summary(persona_name: str, date_str: str) -> str:
"""
Generate an LLM summary of the day's events for long-term storage.
Uses the same gemini_client as day_planner.
"""
from src.llm.gemini_client import call_gemini
from pydantic import BaseModel
from typing import List
class SummaryOutput(BaseModel):
summary: str
key_events: List[str]
data = _load_day_data(persona_name, date_str)
if data is None or not data.events:
return f"No events recorded for {persona_name} on {date_str}."
# Build event text
event_lines = []
for e in data.events:
if e.type == "action_completed":
event_lines.append(f"- {e.action} at {e.location or 'unknown'} (outcome: {e.outcome}, success: {e.success})")
elif e.type == "conversation":
event_lines.append(f"- Conversation with {e.with_agent or ', '.join(e.participants) if e.participants else 'unknown'}: {e.summary or e.topic}")
elif e.type == "observation":
event_lines.append(f"- Observed: {e.details}")
event_text = "\n".join(event_lines)
system_prompt = (
"You are summarizing one day in the life of a simulated college student agent. "
"You will be provided the Initial day plan, the conversations the agent had throughout and some extra stuff. "
"Write a concise summary of about 100 words prioritizing the most important:\n"
"- High-priority tasks and whether they were completed\n"
"- Important events and social interactions\n"
"- Significant conversations and decisions\n"
"- Anything likely to matter in future days\n"
"Only focus on routine low-value chores unless no important event happened that day.\n"
"Also list 3-5 key events as bullet points."
)
user_prompt = (
f"Persona: {persona_name}\n"
f"Date: {date_str}\n"
f"Day plan: {len(data.day_plan)} scheduled actions\n\n"
f"Initial day plan:\n" +
"\n".join(f" - {a.get('start','?')}-{a.get('end','?')} {a.get('action','?')} at {a.get('location_id','?')}" for a in data.day_plan[:15]) +
f"\n\nEvents:\n{event_text}\n\n"
f"Conversations ({len(data.conversations)}):\n" +
"\n".join(f"- {c.summary} (with {', '.join(c.participants)})" for c in data.conversations[:5]) +
"\n\nGenerate the daily summary (~100 words) and key events list."
)
try:
from src.config import SUMMARY_TEMPERATURE
result = call_gemini(system_prompt, user_prompt, SummaryOutput, "default", temperature=SUMMARY_TEMPERATURE)
# Format for long-term storage
key_events_text = "\n".join(f"- {evt}" for evt in result.key_events)
return f"Summary: {result.summary}\nKey events:\n{key_events_text}"
except Exception as e:
logger.error("Failed to generate LLM daily summary for %s on %s: %s", persona_name, date_str, e)
# Fallback: programmatic summary
parts = [f"{persona_name} on {date_str}:"]
for e in data.events:
if e.type == "action_completed":
parts.append(f" - {e.action} ({e.outcome}, success={e.success})")
return "\n".join(parts)
async def finalize_day(persona_name: str, date_str: str) -> dict:
"""
End-of-day processing:
1. Generate LLM summary of the day
2. Mark as archived
3. Return data ready for long-term storage
"""
data = _load_day_data(persona_name, date_str)
if data is None:
return {"summary": f"No data for {persona_name} on {date_str}", "full_data": None}
# Generate summary
daily_summary = await generate_daily_summary(persona_name, date_str)
data.daily_summary = daily_summary
data.archived_to_long_term = True
# Save final version with summary
_save_day_data(persona_name, date_str, data)
# Return data for long-term storage
return {
"summary": daily_summary,
"full_data": data.model_dump()
}
async def archive_to_long_term(persona_name: str, date_str: str) -> dict:
"""
End-of-day archival: generate a daily summary and persist its durable
records directly to Qdrant. No Long_term_db JSON file is created.
Returns the archive payload dict with keys: summary, persona_name, date,
event_count, conversation_count.
"""
result = await finalize_day(persona_name, date_str)
data = result.get("full_data")
if data is None:
return {"summary": f"No data for {persona_name} on {date_str}"}
# Offline/local runs intentionally disable the Qdrant-backed long-term
# store. Treat this as an explicit, successful skip rather than trying a
# known-unavailable retriever and turning every midnight handoff into an
# archive failure.
from src import config as _cfg
if not _cfg.SEMANTIC_MEMORY_ENABLED:
logger.info("[Short_term] semantic archival disabled; skipped %s on %s", persona_name, date_str)
return {
"summary": result.get("summary", ""),
"persona_name": persona_name,
"date": date_str,
"event_count": len(data.get("events", [])),
"conversation_count": len(data.get("conversations", [])),
"skipped": True,
}
try:
from src.agents.Long_term import get_retriever
retriever = get_retriever()
indexed = await asyncio.to_thread(retriever.index_archive, persona_name, data)
if indexed <= 0 and data.get("daily_summary"):
raise RuntimeError("Qdrant did not acknowledge any durable memory records")
except Exception as exc:
# The short-term source remains in place so an operator can retry. Do
# not silently claim archival success when Qdrant is the only durable
# long-term store.
logger.error("[Short_term] Qdrant archive failed for %s: %s", persona_name, exc)
raise
logger.info(
"[Short_term] archived %s on %s into Qdrant (%d events, %d conversations)",
persona_name, date_str,
len(data.get("events", [])), len(data.get("conversations", [])),
)
return {
"summary": result.get("summary", ""),
"persona_name": persona_name,
"date": date_str,
"event_count": len(data.get("events", [])),
"conversation_count": len(data.get("conversations", [])),
}
def clear_short_term_data(persona_name: str, date_str: str) -> bool:
"""Delete the short-term file after successful long-term archival.
Not called automatically — archival keeps the short-term copy in place so
the running day is not disturbed. Exposed for maintenance/cleanup use.
"""
path = _day_file_path(persona_name, date_str)
if path.exists():
path.unlink()
logger.info("Cleared short-term data for %s on %s", persona_name, date_str)
return True
return False
# ---------------------------------------------------------------------------
# MemoryStreamProtocol adapter
# ---------------------------------------------------------------------------
class ShortTermMemoryStream:
"""
Adapter that wraps Short_term.py's module-level functions as
MemoryStreamProtocol (defined in tick_graph.py) with sim-date awareness.
Usage:
stream = ShortTermMemoryStream("2026-07-03")
# Tick graph nodes call:
stream.add_memory(agent_id, content, importance)
stream.retrieve_memories(agent_id, query, k)
"""
def __init__(self, initial_date: str = "") -> None:
self._date_str: str = initial_date or datetime.now(timezone.utc).date().isoformat()
@property
def date_str(self) -> str:
return self._date_str
def set_date(self, date_str: str) -> None:
"""Update the simulation date (called before each tick)."""
self._date_str = date_str
def add_memory(self, agent_id: str, content: str, importance: Optional[int] = None) -> None:
add_memory(agent_id, content, importance, date_str=self._date_str)
def retrieve_memories(self, agent_id: str, query: str, k: int = 5) -> list[str]:
return retrieve_memories(agent_id, query, k, date_str=self._date_str)