Spaces:
Running
Running
Download backend/src/agents/Short_term.py from Agmmm/valhalla: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/Agmmm/valhalla/resolve/main/backend/src/agents/Short_term.py
- Command line
-
hf download hf://spaces/Agmmm/valhalla/backend/src/agents/Short_term.py
-
curl -L -o Short_term.py https://huggingface.co/spaces/Agmmm/valhalla/resolve/main/backend/src/agents/Short_term.py
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() | |
| 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) | |