Build a Long-Term Memory Agent with Python and pgvector
Source: mortalapps.comThis guide provides a comprehensive, end-to-end tutorial on building a sophisticated long term memory agent python vector database solution. We'll construct an AI agent capable of leveraging a multi-layered memory architecture: working memory for immediate context, episodic memory using pgvector for past interactions, and semantic memory represented as a knowledge graph for consolidated facts. This architecture directly addresses the limitations of traditional AI agents, which often struggle with context window exhaustion, poor recall of historical interactions, and the inability to learn and generalize knowledge over extended periods.
This project is ideal for AI engineers, machine learning practitioners, and architects looking to move beyond basic RAG implementations and develop agents with true persistent learning capabilities. By following this guide, you will gain practical experience with advanced LangGraph patterns, vector database integration, and knowledge graph construction.
The business problem solved here is the need for more intelligent, context-aware AI assistants that can provide consistent, accurate responses based on a deep understanding of past events and generalized knowledge. This is crucial for applications like advanced customer support, personalized assistants, or complex data analysis agents where historical context and continuous learning are paramount.
At the completion of this tutorial, you will have a fully functional, deployable AI agent system. This includes a LangGraph-orchestrated agent, a PostgreSQL database with pgvector configured for episodic and semantic memory, a separate consolidation job that promotes important facts to the knowledge graph, and a robust setup with observability and production hardening considerations. This layered memory architecture is chosen over simpler alternatives because it explicitly models different types of recall, allowing the agent to efficiently access the most relevant information at the right time, while also facilitating continuous learning and knowledge generalization.
What You Will Build
You will build a Python-based AI agent that uses LangGraph for orchestrating its decision-making and interactions with a multi-layered memory system. The agent will manage three distinct types of memory:
- Working Memory: This is the immediate conversational context held within the LLM's context window, used for short-term reasoning and current turn management.
- Episodic Memory: Implemented using a
pgvectordatabase, this layer stores detailed records of past interactions, observations, and events. It allows the agent to recall specific historical moments through semantic search. - Semantic Memory: A knowledge graph, also backed by PostgreSQL, stores consolidated, high-level facts and relationships derived from episodic memories. This provides the agent with generalized, long-term knowledge.
The agent will communicate through a simple command-line interface (which can be extended to an API). It will use LangChain tools to interact with both the pgvector (episodic) and knowledge graph (semantic) memory layers for retrieval and storage. A separate, asynchronous Python script will serve as a nightly consolidation job. This script will review recently added episodic memories, use an LLM to extract generalizable facts, and then store these facts as triples in the semantic knowledge graph. The entire system will be containerized with Docker, ready for deployment to a production environment.
The final architecture is a reactive agent loop built with LangGraph, which dynamically decides whether to respond directly, retrieve information from episodic memory, or retrieve from semantic memory, based on the user's query and internal state. A background process continuously refines the agent's semantic understanding by promoting relevant episodic details.
Technology Stack
| Component | Technology | Why This Choice |
|---|---|---|
| LLM | OpenAI GPT-4o |
Advanced reasoning, strong general knowledge, robust function calling for tool use, and high performance. |
| Orchestration | LangGraph |
Enables complex state machines, conditional routing, and cyclic execution for dynamic agent behavior and memory interaction. |
| Episodic Memory | pgvector (PostgreSQL) |
Scalable, open-source vector database for semantic search of past events and observations, integrated with existing relational database. |
| Semantic Memory | PostgreSQL (Knowledge Graph) |
Stores structured facts (triples) for generalized, long-term knowledge, leveraging existing database infrastructure. |
| Working Memory | LLM Context Window |
Utilizes the LLM's inherent ability to manage immediate conversational context and short-term reasoning. |
| Tool Execution | LangChain Tools |
Standardized interface for the agent to interact with external systems, including memory retrieval and storage functions. |
| Observability | OpenTelemetry, LangSmith |
Provides comprehensive tracing, metrics, and logging for debugging, performance monitoring, and cost analysis of LLM interactions. |
| Deployment | Docker, Kubernetes |
Containerization for reproducible environments and orchestration for scalable, fault-tolerant production deployment. |
Our choice of OpenAI GPT-4o for the Large Language Model is driven by its exceptional reasoning capabilities and robust function calling, which are critical for an agent that dynamically interacts with multiple memory layers. While other powerful models like Anthropic's Claude or open-source alternatives such as Llama 3 exist, GPT-4o provides a high baseline of intelligence and reliability, simplifying the development of complex decision-making logic.
LangGraph is the backbone of our agent's orchestration. Its ability to define state machines, manage state transitions, and implement conditional routing is indispensable for an agent that must intelligently decide which memory layer to query or update. This graph-based approach offers superior control and visibility over the agent's execution flow compared to simpler sequential chains, making it ideal for the intricate logic required by a multi-layered memory system.
For episodic memory, we opted for pgvector with PostgreSQL. This solution provides a robust, scalable, and open-source vector database that integrates seamlessly with a familiar relational database. Leveraging PostgreSQL for both vector storage and the knowledge graph simplifies our infrastructure, reducing operational overhead compared to managing separate dedicated vector stores like Pinecone or Qdrant. Its maturity and flexibility make it an excellent choice for persistent memory.
LangChain Tools serve as the standardized interface for our agent to interact with its memory systems. This abstraction allows the LLM to dynamically select and execute functions for adding, retrieving, and updating information in both episodic and semantic memory. LangChain's extensive ecosystem and ease of custom tool creation accelerate development and ensure consistency in tool interactions.
Finally, OpenTelemetry and LangSmith are integrated for comprehensive observability. OpenTelemetry provides a vendor-neutral standard for collecting distributed traces, allowing us to deeply understand the agent's internal workings, identify bottlenecks, and monitor LLM token usage. LangSmith complements this by offering specialized tracing and evaluation tools for LLM applications, which are invaluable for debugging multi-step reasoning processes and evaluating the effectiveness of memory retrievals. This dual approach ensures we have the necessary insights for production readiness and continuous improvement.
Project Structure
.
├── Dockerfile
├── README.md
├── agent_app.py
├── components
│ ├── __init__.py
│ ├── agent.py
│ ├── memory.py
│ └── tools.py
├── config.py
├── db
│ └── init_db.sql
├── requirements.txt
├── scripts
│ ├── __init__.py
│ └── consolidate_memory.py
└── tests
├── __init__.py
└── test_agent.py
The root directory contains essential project files like the Dockerfile for containerization and README.md for project documentation. agent_app.py serves as the main entry point for running the interactive agent. The components/ directory encapsulates the modular building blocks of our system: agent.py defines the LangGraph agent's state and workflow, memory.py handles all database interactions for episodic and semantic memory, and tools.py exposes these memory functions as LangChain tools. config.py centralizes environment variables and application settings, ensuring secure access to credentials. The db/ directory holds init_db.sql, which defines the PostgreSQL schema for both memory layers. requirements.txt lists all Python dependencies. The scripts/ directory contains consolidate_memory.py, the standalone process for the nightly memory consolidation. Finally, tests/ is dedicated to unit and integration tests, ensuring the reliability of our agent components.
Implementation
Phase 1: Environment Setup & Core Components
This phase focuses on establishing the foundational infrastructure. We will set up the project structure, define environment variables for secure configuration, create the necessary PostgreSQL database schema for both episodic and semantic memory, and specify all Python dependencies. This ensures a stable and reproducible environment for subsequent development.
# config.py
import os
import logging
from dotenv import load_dotenv
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Database Configuration
POSTGRES_HOST = os.environ.get("POSTGRES_HOST", "localhost")
POSTGRES_PORT = os.environ.get("POSTGRES_PORT", "5432")
POSTGRES_DB = os.environ.get("POSTGRES_DB", "agent_memory_db")
POSTGRES_USER = os.environ.get("POSTGRES_USER", "user")
POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "password")
POSTGRES_URI = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
# LLM Configuration
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
OPENAI_MODEL_NAME = os.environ.get("OPENAI_MODEL_NAME", "gpt-4o")
# LangSmith Configuration (Optional but recommended for observability)
LANGCHAIN_TRACING_V2 = os.environ.get("LANGCHAIN_TRACING_V2", "false").lower() == "true"
LANGCHAIN_API_KEY = os.environ.get("LANGCHAIN_API_KEY")
LANGCHAIN_PROJECT = os.environ.get("LANGCHAIN_PROJECT", "long-term-memory-agent")
if not OPENAI_API_KEY:
logger.error("OPENAI_API_KEY not set. Please set it in your environment or .env file.")
raise ValueError("OPENAI_API_KEY is required.")
logger.info("Configuration loaded successfully.")
# db/init_db.sql
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Table for Episodic Memory
CREATE TABLE IF NOT EXISTS episodic_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMPTZ DEFAULT NOW(),
event_type VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
embedding VECTOR(1536) NOT NULL, -- text-embedding-3-small / text-embedding-ada-002 produce 1536-dim vectors
metadata JSONB DEFAULT '{}'
);
-- Table for Semantic Memory (Knowledge Graph triples)
CREATE TABLE IF NOT EXISTS semantic_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
source_episodic_id UUID, -- Link to original episodic memory if consolidated
timestamp TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_source_episodic
FOREIGN KEY(source_episodic_id)
REFERENCES episodic_memory(id)
ON DELETE SET NULL
);
-- Index for efficient semantic memory retrieval
CREATE INDEX IF NOT EXISTS idx_semantic_subject ON semantic_memory (subject);
CREATE INDEX IF NOT EXISTS idx_semantic_predicate ON semantic_memory (predicate);
CREATE INDEX IF NOT EXISTS idx_semantic_object ON semantic_memory (object);
-- Index for efficient episodic memory search
CREATE INDEX IF NOT EXISTS idx_episodic_timestamp ON episodic_memory (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_episodic_event_type ON episodic_memory (event_type);
config.py centralizes environment variable loading and basic logging, ensuring sensitive data like API keys and database credentials are never hardcoded and are fetched securely using os.environ.get(). This promotes good security practices and makes the application portable across different environments. The db/init_db.sql script defines the PostgreSQL schema for both episodic and semantic memory. The episodic_memory table stores raw events along with their vector embeddings, enabling semantic search. The semantic_memory table stores knowledge graph triples (subject-predicate-object), linking back to the original episodic event for traceability. Indexes are added to optimize retrieval performance for both memory types. requirements.txt specifies all necessary Python packages, including langchain-openai, langgraph, psycopg2-binary for PostgreSQL, pgvector, and python-dotenv. Finally, the Dockerfile creates a reproducible container image, installing dependencies and copying the application code, ready for deployment. This structured setup ensures consistency and ease of development.
```python
# requirements.txt
langchain-openai==0.1.8
langchain==0.2.1
langgraph==0.2.0
psycopg2-binary==2.9.9
six==1.16.0 # Required by some dependencies
sqlalchemy==2.0.30
pgvector==0.2.4
python-dotenv==1.0.1
networkx==3.2.1
tiktoken==0.6.0
# OpenTelemetry
opentelemetry-sdk==1.24.0
opentelemetry-exporter-otlp-proto-http==1.24.0
opentelemetry-instrumentation-langchain
opentelemetry-instrumentation-openai
```
```dockerfile
# Dockerfile
FROM python:3.10-slim-buster
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# Default command for the agent app
CMD ["python", "agent_app.py"]
```
Ensure you have Docker and Docker Compose installed. Create a docker-compose.yml (see Deployment section for an example) to run PostgreSQL with pgvector. Start the PostgreSQL container (docker-compose up -d postgres). Then, connect to the database (e.g., docker exec -it <postgres_container_id> psql -U user -d agent_memory_db) and run SELECT 'pgvector'::regclass; to verify the extension. Also, check if episodic_memory and semantic_memory tables exist and have the correct column structure. Locally, run pip install -r requirements.txt and verify python -c "import config; print(config.POSTGRES_URI)" executes without errors (after setting environment variables in a .env file).
Phase 2: Memory Management & Embedding
This phase implements the core memory interaction logic. We will create a MemoryManager class to encapsulate all database operations, including adding and retrieving episodic memories with vector embeddings, and storing/querying semantic knowledge graph triples. This establishes the agent's ability to store and recall information from its long-term memory.
# components/memory.py
import os
import logging
import uuid
from typing import List, Dict, Any, Optional
from datetime import datetime
import psycopg2
from psycopg2.extras import DictCursor, register_uuid
from sqlalchemy import create_engine, text
from langchain_openai import OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_community.vectorstores import PGVector
from config import POSTGRES_URI, OPENAI_API_KEY
logger = logging.getLogger(__name__)
register_uuid() # Enable UUID support for psycopg2
class MemoryManager:
"""Manages interactions with episodic and semantic memory."""
def __init__(self):
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY is not set. Cannot initialize embeddings.")
self.embedding_model = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY, model="text-embedding-3-small")
self.pg_engine = create_engine(POSTGRES_URI)
# Initialize PGVector for episodic memory
self.episodic_vector_store = PGVector(
connection_string=POSTGRES_URI,
embedding_function=self.embedding_model,
collection_name="episodic_memory",
table_name="episodic_memory", # Ensure this matches our table name
pre_delete_collection=False # Do not delete existing table on init
)
logger.info("MemoryManager initialized with PostgreSQL and OpenAIEmbeddings.")
def _get_db_connection(self):
"""Establishes a new database connection."""
try:
conn = psycopg2.connect(POSTGRES_URI.replace("postgresql+psycopg2", "postgresql"))
conn.autocommit = True
return conn
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
raise
def add_episodic_memory(self, event_type: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Adds an event to episodic memory, generates an embedding, and stores it."""
try:
embedding = self.embedding_model.embed_query(content)
with self._get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO episodic_memory (event_type, content, embedding, metadata)
VALUES (%s, %s, %s, %s) RETURNING id;
""",
(event_type, content, embedding, metadata if metadata else {})
)
memory_id = cur.fetchone()[0]
logger.info(f"Added episodic memory (ID: {memory_id}, Type: {event_type})")
return str(memory_id)
except Exception as e:
logger.error(f"Error adding episodic memory: {e}")
raise
def retrieve_episodic_memory(self, query: str, k: int = 5) -> List[Document]:
"""Retrieves relevant episodic memories based on a semantic query."""
try:
results = self.episodic_vector_store.similarity_search(query, k=k)
logger.info(f"Retrieved {len(results)} episodic memories for query: '{query}'")
return results
except Exception as e:
logger.error(f"Error retrieving episodic memory for query '{query}': {e}")
return []
def add_semantic_triple(self, subject: str, predicate: str, object: str, source_episodic_id: Optional[str] = None) -> str:
"""Adds a semantic triple (subject, predicate, object) to the knowledge graph."""
try:
with self._get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO semantic_memory (subject, predicate, object, source_episodic_id)
VALUES (%s, %s, %s, %s) RETURNING id;
""",
(subject, predicate, object, uuid.UUID(source_episodic_id) if source_episodic_id else None)
)
triple_id = cur.fetchone()[0]
logger.info(f"Added semantic triple (ID: {triple_id}): {subject} - {predicate} - {object}")
return str(triple_id)
except Exception as e:
logger.error(f"Error adding semantic triple '{subject} {predicate} {object}': {e}")
raise
def retrieve_semantic_memory(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
"""Retrieves semantic memory triples based on a keyword query.
Can extend to full knowledge graph traversal if needed."""
try:
with self.pg_engine.connect() as conn:
# Simple keyword search for demonstration; can be enhanced with graph queries
search_query = f"%{query.lower()}%"
result = conn.execute(
text("""
SELECT id, subject, predicate, object, source_episodic_id, timestamp
FROM semantic_memory
WHERE LOWER(subject) LIKE :search_query
OR LOWER(predicate) LIKE :search_query
OR LOWER(object) LIKE :search_query
LIMIT :k;
"""),
{"search_query": search_query, "k": k}
).fetchall()
triples = []
for row in result:
triples.append({
"id": str(row[0]),
"subject": row[1],
"predicate": row[2],
"object": row[3],
"source_episodic_id": str(row[4]) if row[4] else None,
"timestamp": row[5].isoformat()
})
logger.info(f"Retrieved {len(triples)} semantic memories for query: '{query}'")
return triples
except Exception as e:
logger.error(f"Error retrieving semantic memory for query '{query}': {e}")
return []
def get_all_episodic_memories(self, limit: int = 1000) -> List[Dict[str, Any]]:
"""Retrieves all episodic memories up to a limit. Useful for consolidation."""
try:
with self._get_db_connection() as conn:
with conn.cursor(cursor_factory=DictCursor) as cur:
cur.execute("SELECT id, content, timestamp, event_type, metadata FROM episodic_memory ORDER BY timestamp DESC LIMIT %s;", (limit,))
memories = cur.fetchall()
logger.info(f"Retrieved {len(memories)} episodic memories for consolidation.")
return [dict(m) for m in memories]
except Exception as e:
logger.error(f"Error retrieving all episodic memories: {e}")
return []
MemoryManager class in components/memory.py centralizes all interactions with our PostgreSQL-backed memory systems. It initializes an OpenAIEmbeddings model for generating vector representations of text, which is crucial for semantic search in episodic memory. The pgvector library is integrated through langchain_community.vectorstores.PGVector, simplifying the process of adding and retrieving vector-based episodic memories. This abstraction handles the embedding generation and database interaction for us. For semantic memory, direct psycopg2 and SQLAlchemy connections are used to manage the knowledge graph triples, allowing for precise control over the data structure and relationships. The add_episodic_memory method takes an event and its content, embeds it, and stores it in the episodic_memory table. retrieve_episodic_memory performs a similarity search using the vector store. add_semantic_triple stores structured facts, optionally linking them to their source episodic memory for provenance. retrieve_semantic_memory demonstrates a basic keyword-based retrieval for semantic facts, which can be extended to more complex graph traversals. Finally, get_all_episodic_memories provides a utility to fetch recent episodic events, essential for the consolidation process. Robust error handling and logging are included in each method to ensure operational reliability and debuggability.
Ensure your PostgreSQL container is running and accessible. Create a Python script (e.g., test_memory.py) in your project root. Set OPENAI_API_KEY and POSTGRES_URI in your environment or .env file. Then, run the following code:
import os
from components.memory import MemoryManager
from config import OPENAI_API_KEY, POSTGRES_URI # Ensure these are set in .env or environment
if __name__ == "__main__":
# These lines are for local testing convenience, in production they'd be set in the environment
os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY
os.environ["POSTGRES_URI"] = POSTGRES_URI
memory_manager = MemoryManager()
# Test adding episodic memory
episodic_id = memory_manager.add_episodic_memory(
event_type="conversation",
content="The user asked about the capital of France and I told them it was Paris.",
metadata={"user_id": "user123", "topic": "geography"}
)
print(f"Added episodic memory with ID: {episodic_id}")
# Test retrieving episodic memory
retrieved_episodes = memory_manager.retrieve_episodic_memory("What did the user ask about France?")
for doc in retrieved_episodes:
print(f"Retrieved episodic: {doc.page_content} (Source ID: {doc.metadata.get('id')})")
# Test adding semantic memory
semantic_id = memory_manager.add_semantic_triple(
subject="Paris",
predicate="is_capital_of",
object="France",
source_episodic_id=episodic_id
)
print(f"Added semantic triple with ID: {semantic_id}")
# Test retrieving semantic memory
retrieved_triples = memory_manager.retrieve_semantic_memory("capital of France")
for triple in retrieved_triples:
print(f"Retrieved semantic: {triple['subject']} {triple['predicate']} {triple['object']}")
# Test getting all episodic memories
all_episodes = memory_manager.get_all_episodic_memories(limit=1)
print(f"Retrieved {len(all_episodes)} recent episodic memories.")Verify that all print statements show successful operations and no errors are logged.
Phase 3: Agent Core with LangGraph & Tool Integration
This phase integrates the memory management into the agent's decision-making loop. We will define LangChain tools for each memory operation, build the core LangGraph workflow, and configure the LLM with a prompt that guides its use of these memory tools. This creates an intelligent agent capable of dynamically interacting with its layered memory.
# components/tools.py
import logging
from typing import List, Dict, Any, Optional
from langchain.tools import tool
from langchain_core.documents import Document
from components.memory import MemoryManager
logger = logging.getLogger(__name__)
class AgentTools:
"""A collection of tools for the agent to interact with its memory systems."""
def __init__(self, memory_manager: MemoryManager):
self.memory_manager = memory_manager
@tool
def add_episodic_memory_tool(self, event_type: str, content: str, metadata: Dict[str, Any] = None) -> str:
"""
Adds a new event or observation to the agent's episodic memory.
Use this tool to record important interactions, observations, or facts learned during a conversation.
The `event_type` should categorize the memory (e.g., "conversation", "observation", "action").
The `content` is the detailed description of the event.
Optional `metadata` can include context like user_id, topic, etc.
Returns the ID of the added memory.
"""
try:
memory_id = self.memory_manager.add_episodic_memory(event_type, content, metadata)
logger.info(f"Tool: Successfully added episodic memory with ID {memory_id}")
return f"Episodic memory added with ID: {memory_id}"
except Exception as e:
logger.error(f"Tool: Failed to add episodic memory: {e}")
return f"Error: Failed to add episodic memory - {e}"
@tool
def retrieve_episodic_memory_tool(self, query: str, k: int = 3) -> List[str]:
"""
Retrieves relevant past events or observations from episodic memory based on a semantic query.
Use this tool when you need to recall specific past interactions or details.
`query` is the natural language question or description of what to retrieve.
`k` is the maximum number of relevant memories to retrieve (default 3).
Returns a list of strings, where each string is the content of a retrieved memory.
"""
try:
results: List[Document] = self.memory_manager.retrieve_episodic_memory(query, k)
if not results:
return ["No relevant episodic memories found."]
formatted_results = [
f"Memory (ID: {doc.metadata.get('id')}, Timestamp: {doc.metadata.get('timestamp')}): {doc.page_content}"
for doc in results
]
logger.info(f"Tool: Successfully retrieved {len(formatted_results)} episodic memories.")
return formatted_results
except Exception as e:
logger.error(f"Tool: Failed to retrieve episodic memory: {e}")
return [f"Error: Failed to retrieve episodic memory - {e}"]
@tool
def add_semantic_triple_tool(self, subject: str, predicate: str, object: str, source_episodic_id: Optional[str] = None) -> str:
"""
Adds a structured fact (subject, predicate, object) to the agent's semantic memory (knowledge graph).
Use this tool to store important, consolidated facts that represent long-term knowledge.
`subject`, `predicate`, `object` define the fact.
`source_episodic_id` is an optional ID linking to the episodic memory from which this fact was derived.
Returns the ID of the added semantic triple.
"""
try:
triple_id = self.memory_manager.add_semantic_triple(subject, predicate, object, source_episodic_id)
logger.info(f"Tool: Successfully added semantic triple with ID {triple_id}")
return f"Semantic triple added with ID: {triple_id}"
except Exception as e:
logger.error(f"Tool: Failed to add semantic triple: {e}")
return f"Error: Failed to add semantic triple - {e}"
@tool
def retrieve_semantic_memory_tool(self, query: str, k: int = 3) -> List[str]:
"""
Retrieves consolidated facts from the agent's semantic memory (knowledge graph) based on a keyword query.
Use this tool to access high-level, generalized knowledge.
`query` is a keyword or phrase to search for in subjects, predicates, or objects.
`k` is the maximum number of relevant triples to retrieve (default 3).
Returns a list of strings, where each string represents a retrieved semantic triple.
"""
try:
results: List[Dict[str, Any]] = self.memory_manager.retrieve_semantic_memory(query, k)
if not results:
return ["No relevant semantic memories found."]
formatted_results = [
f"Fact (ID: {triple['id']}, Source: {triple['source_episodic_id'] if triple['source_episodic_id'] else 'N/A'}): {triple['subject']} {triple['predicate']} {triple['object']}"
for triple in results
]
logger.info(f"Tool: Successfully retrieved {len(formatted_results)} semantic memories.")
return formatted_results
except Exception as e:
logger.error(f"Tool: Failed to retrieve semantic memory: {e}")
return [f"Error: Failed to retrieve semantic memory - {e}"]
# components/agent.py
import logging
import os
from typing import TypedDict, List, Annotated, Sequence, Union, Dict, Any
from operator import add
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langgraph.graph import StateGraph, END
from components.memory import MemoryManager
from components.tools import AgentTools
from config import OPENAI_API_KEY, OPENAI_MODEL_NAME, LANGCHAIN_TRACING_V2, LANGCHAIN_API_KEY, LANGCHAIN_PROJECT
logger = logging.getLogger(__name__)
# Set up LangSmith for tracing if enabled
if LANGCHAIN_TRACING_V2:
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = LANGCHAIN_API_KEY
os.environ["LANGCHAIN_PROJECT"] = LANGCHAIN_PROJECT
logger.info(f"LangSmith tracing enabled for project: {LANGCHAIN_PROJECT}")
class AgentState(TypedDict):
"""
Represents the state of our agent in LangGraph.
- messages: A list of messages making up the conversation history.
"""
messages: Annotated[Sequence[BaseMessage], add]
class LongTermMemoryAgent:
def __init__(self):
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY is not set. Cannot initialize LLM.")
self.memory_manager = MemoryManager()
self.agent_tools = AgentTools(self.memory_manager)
self.tools = [
self.agent_tools.add_episodic_memory_tool,
self.agent_tools.retrieve_episodic_memory_tool,
self.agent_tools.add_semantic_triple_tool,
self.agent_tools.retrieve_semantic_memory_tool,
]
self.llm = ChatOpenAI(model=OPENAI_MODEL_NAME, temperature=0, api_key=OPENAI_API_KEY)
self.agent_executor = self._create_agent_executor()
self.graph = self._build_graph()
logger.info("LongTermMemoryAgent initialized.")
def _create_agent_executor(self) -> AgentExecutor:
"""Creates an AgentExecutor with the LLM and tools."""
# Define the prompt for the agent
prompt = ChatPromptTemplate.from_messages(
[
("system", """You are a sophisticated AI assistant capable of managing and utilizing a multi-layered memory system.
Your memory consists of:
1. Working Memory (current conversation context).
2. Episodic Memory (vector database for past events, interactions, and observations).
3. Semantic Memory (knowledge graph for consolidated, high-level facts).
**When to use tools:**
- Use `add_episodic_memory_tool` to record important new information from the conversation or observations you make that might be useful later. This is for storing specific events.
- Use `retrieve_episodic_memory_tool` when the user asks about past interactions, specific events, or details that might have occurred previously.
- Use `add_semantic_triple_tool` *only* if you identify a highly generalized, factual piece of information that is worth consolidating into long-term knowledge. This tool is primarily used by the nightly consolidation job, but you can use it if a new, universally true fact is established in the conversation.
- Use `retrieve_semantic_memory_tool` when the user asks for general facts, definitions, or high-level knowledge that would likely be stored in a knowledge graph.
Always strive to provide helpful and accurate information. If a question requires recalling specific past events, use episodic memory. If it requires general knowledge, use semantic memory. If you learn something new and specific, record it episodically. If you confirm a general truth, record it semantically.
"""),
MessagesPlaceholder(variable_name="chat_history"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
]
)
# Create the LangChain agent
agent = create_openai_tools_agent(self.llm, self.tools, prompt)
return AgentExecutor(agent=agent, tools=self.tools, verbose=True)
def _agent_node(self, state: AgentState) -> Dict[str, Any]:
"""The main agent node that invokes the LLM and its tools."""
logger.info("Agent node: Invoking agent executor.")
result = self.agent_executor.invoke({"chat_history": state["messages"]})
# AgentExecutor handles tool calls internally and always returns {output: final_answer}
if "output" in result:
return {"messages": [AIMessage(content=result["output"])]}
else:
logger.warning("Agent executor returned an unexpected format.")
return {"messages": [AIMessage(content="An unexpected error occurred during agent execution.")]}
def _tool_node(self, state: AgentState) -> Dict[str, Any]:
"""The tool node that executes tools."""
logger.info("Tool node: Executing tools.")
last_message = state["messages"][-1]
tool_outputs = []
if isinstance(last_message, AIMessage) and last_message.tool_calls:
for tool_call in last_message.tool_calls:
try:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
# Find and execute the tool
selected_tool = next((t for t in self.tools if t.name == tool_name), None)
if selected_tool:
output = selected_tool.invoke(tool_args)
tool_outputs.append(ToolMessage(content=str(output), tool_call_id=tool_call["id"]))
else:
tool_outputs.append(ToolMessage(content=f"Error: Tool '{tool_name}' not found.", tool_call_id=tool_call["id"]))
logger.error(f"Tool '{tool_name}' not found for execution.")
except Exception as e:
tool_outputs.append(ToolMessage(content=f"Error executing tool '{tool_call.get('name', 'unknown')}': {e}", tool_call_id=tool_call["id"]))
logger.error(f"Error executing tool '{tool_call.get('name', 'unknown')}': {e}")
else:
logger.warning("Tool node received a message without tool calls.")
return {"messages": tool_outputs}
def _should_continue(self, state: AgentState) -> str:
"""Determines whether the agent should continue or end."""
last_message = state["messages"][-1]
if isinstance(last_message, AIMessage) and not last_message.tool_calls:
logger.info("Should continue: Ending conversation (AI message, no tool calls).")
return "end"
logger.info("Should continue: Continuing with tool execution.")
return "continue"
def _build_graph(self):
"""Builds the LangGraph state machine."""
workflow = StateGraph(AgentState)
# Define nodes
workflow.add_node("agent", self._agent_node)
workflow.add_node("tools", self._tool_node)
# Define edges
workflow.set_entry_point("agent")
workflow.add_conditional_edges(
"agent",
self._should_continue,
{
"continue": "tools",
"end": END
}
)
workflow.add_edge("tools", "agent")
app = workflow.compile()
logger.info("LangGraph workflow compiled successfully.")
return app
def run(self, input_message: str, chat_history: List[BaseMessage] = None) -> str:
"""Runs the agent with a given input message."""
if chat_history is None:
chat_history = []
# Add the human message to the chat history
current_chat_history = chat_history + [HumanMessage(content=input_message)]
final_state = None
try:
for s in self.graph.stream({"messages": current_chat_history}):
final_state = s
if final_state and "messages" in final_state:
ai_messages = [m for m in final_state["messages"] if isinstance(m, AIMessage)]
if ai_messages:
final_response_message = next((m for m in reversed(ai_messages) if not m.tool_calls), None)
if final_response_message:
logger.info(f"Agent finished with response: {final_response_message.content}")
return final_response_message.content
logger.warning("Agent finished without a clear final AI message.")
return "I'm sorry, I couldn't process that request fully."
except Exception as e:
logger.error(f"Error running agent: {e}")
return f"An error occurred: {e}"
# agent_app.py
import logging
import os
from typing import List
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from components.agent import LongTermMemoryAgent
from config import LANGCHAIN_TRACING_V2, LANGCHAIN_API_KEY, LANGCHAIN_PROJECT, OPENAI_API_KEY # Import OPENAI_API_KEY for OTEL check
# OpenTelemetry imports (add to requirements.txt)
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
logger = logging.getLogger(__name__)
# Configure LangSmith if enabled
if LANGCHAIN_TRACING_V2:
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = LANGCHAIN_API_KEY
os.environ["LANGCHAIN_PROJECT"] = LANGCHAIN_PROJECT
# --- OpenTelemetry Configuration ---
def configure_opentelemetry():
"""Configures OpenTelemetry for the application."""
logger.info("Configuring OpenTelemetry...")
resource = Resource.create({
"service.name": "long-term-memory-agent",
"service.version": "1.0.0",
"deployment.environment": os.environ.get("ENV", "development")
})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
span_processor = BatchSpanProcessor(exporter)
provider.add_span_processor(span_processor)
LangchainInstrumentor().instrument()
OpenAIInstrumentor().instrument()
logger.info(f"OpenTelemetry configured with OTLP exporter to {otlp_endpoint}")
if OPENAI_API_KEY:
configure_opentelemetry()
else:
logger.warning("OPENAI_API_KEY not set. OpenTelemetry for OpenAI/LangChain will not be fully functional.")
# --- Main application logic ---
def main():
"""Main function to run the conversational agent."""
logger.info("Starting Long Term Memory Agent application...")
agent = LongTermMemoryAgent() # This will also set up LangSmith if enabled
chat_history: List[BaseMessage] = []
print("Hello! I am an AI agent with long-term memory. How can I help you today?")
print("Type 'exit' to end the conversation.")
while True:
user_input = input("You: ")
if user_input.lower() == 'exit':
print("Goodbye!")
break
# Create a new trace for each user interaction
with trace.get_tracer(__name__).start_as_current_span("user_interaction") as span:
span.set_attribute("user.input", user_input)
response = agent.run(user_input, chat_history)
span.set_attribute("agent.response", response)
print(f"Agent: {response}")
# Update chat history with user input and agent response for the next turn
chat_history.append(HumanMessage(content=user_input))
chat_history.append(AIMessage(content=response))
if __name__ == "__main__":
main()
components/tools.py wraps our MemoryManager methods into LangChain @tool functions, making them accessible to the LLM. Each tool has a clear docstring describing its purpose, which the LLM uses for tool selection. The components/agent.py file defines the LongTermMemoryAgent class. It sets up the AgentState using TypedDict, which is crucial for LangGraph's state management. The _create_agent_executor method configures the LLM (OpenAI GPT-4o) and the agent's prompt, instructing it on when and how to use the memory tools. The system prompt is carefully crafted to guide the LLM's decision-making regarding which memory layer to consult or update. The _agent_node invokes the AgentExecutor, which handles the entire LLM+tool execution loop internally - it selects tools, runs them, and continues reasoning until it produces a final output. AgentExecutor.invoke() always returns {"output": "..."} after completing all tool calls internally, so _tool_node is included for architectural clarity but is not reached in practice. The _should_continue function implements conditional routing, deciding whether to loop back to the agent for further reasoning after a tool call or to END the current turn. The _build_graph method assembles these nodes and edges into a LangGraph workflow. Finally, agent_app.py provides a simple command-line interface to interact with the agent, demonstrating how the agent can engage in a conversation, leverage its memory tools, and maintain chat history. OpenTelemetry tracing is also integrated here for deep observability into the agent's thought process, ensuring that each interaction is traceable and debuggable.
Ensure your PostgreSQL container is running and environment variables (OPENAI_API_KEY, POSTGRES_URI, LANGCHAIN_API_KEY if using LangSmith, OTEL_EXPORTER_OTLP_ENDPOINT if using OpenTelemetry) are set. Run python agent_app.py. Interact with the agent using various queries:
- Ask: "My name is Alice and I work as a software engineer." (Observe logs for
add_episodic_memory_tool). - Ask: "What is the capital of France?" (Observe logs for
retrieve_semantic_memory_tool). - Ask: "What was my name again?" (Observe logs for
retrieve_episodic_memory_tool). - Ask: "Please record that the sky is blue as a general fact." (Observe logs for
add_semantic_triple_tool).
Check the console logs for tool calls and verify that the agent's responses are contextually appropriate based on memory interactions. If using OpenTelemetry, check your Jaeger UI (e.g., http://localhost:16686) for traces of agent interactions.
Phase 4: Memory Consolidation Job
This phase implements the background learning process. We will create a separate script responsible for periodically reviewing recent episodic memories, using an LLM to extract generalizable facts, and promoting these facts as structured triples into the semantic memory (knowledge graph). This automates the agent's ability to learn and generalize knowledge over time.
# scripts/consolidate_memory.py
import logging
import os
import time
from typing import Dict, Any, List
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.output_parsers import JsonOutputParser
from components.memory import MemoryManager
from config import OPENAI_API_KEY, OPENAI_MODEL_NAME, LANGCHAIN_TRACING_V2, LANGCHAIN_API_KEY, LANGCHAIN_PROJECT
# OpenTelemetry imports
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
logger = logging.getLogger(__name__)
# Configure LangSmith for tracing if enabled
if LANGCHAIN_TRACING_V2:
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = LANGCHAIN_API_KEY
os.environ["LANGCHAIN_PROJECT"] = LANGCHAIN_PROJECT
logger.info(f"LangSmith tracing enabled for project: {LANGCHAIN_PROJECT}")
# --- OpenTelemetry Configuration for the script ---
def configure_opentelemetry_script():
"""Configures OpenTelemetry for the consolidation script."""
logger.info("Configuring OpenTelemetry for consolidation script...")
resource = Resource.create({
"service.name": "memory-consolidation-job",
"service.version": "1.0.0",
"deployment.environment": os.environ.get("ENV", "development")
})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
span_processor = BatchSpanProcessor(exporter)
provider.add_span_processor(span_processor)
LangchainInstrumentor().instrument()
OpenAIInstrumentor().instrument()
logger.info(f"OpenTelemetry configured for script with OTLP exporter to {otlp_endpoint}")
if OPENAI_API_KEY:
configure_opentelemetry_script()
else:
logger.warning("OPENAI_API_KEY not set. OpenTelemetry for OpenAI/LangChain in consolidator will not be fully functional.")
class MemoryConsolidator:
"""
A service that reviews episodic memories and promotes important facts
to semantic memory (knowledge graph).
"""
def __init__(self, memory_manager: MemoryManager):
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY is not set. Cannot initialize LLM for consolidation.")
self.memory_manager = memory_manager
self.llm = ChatOpenAI(model=OPENAI_MODEL_NAME, temperature=0.3, api_key=OPENAI_API_KEY)
self.consolidation_prompt = ChatPromptTemplate.from_messages([
SystemMessage(content="""You are an expert knowledge engineer. Your task is to review recent episodic memories (raw events or observations) and extract general, important, and universally true facts that should be stored in a long-term knowledge graph.
Represent these facts as (subject, predicate, object) triples.
**Rules for extraction:**
1. Focus on generalizable knowledge, not specific temporary conversational turns.
2. Only extract facts that are universally true or highly significant for long-term understanding.
3. If a memory describes a user's personal detail (e.g., "My name is Alice"), consider if it's a fact about the user that should be remembered, or just a temporary context. For this exercise, prioritize facts about the world or persistent entities.
4. Each fact must be a complete (subject, predicate, object) triple.
5. Output only a JSON list of triples, like: `[{"subject": "X", "predicate": "Y", "object": "Z"}, ...]`. If no generalizable facts are found, return an empty JSON list: `[]`.
"""),
HumanMessage(content="""Review the following episodic memories and extract semantic triples (subject, predicate, object) in JSON format.
Memories:
{memories_content}
Output JSON:
""")
])
logger.info("MemoryConsolidator initialized.")
def consolidate_recent_memories(self, limit: int = 100) -> int:
"""
Fetches recent episodic memories, uses an LLM to extract semantic triples,
and stores them in semantic memory.
"""
logger.info(f"Starting memory consolidation job for the last {limit} episodic memories.")
episodic_memories = self.memory_manager.get_all_episodic_memories(limit=limit)
if not episodic_memories:
logger.info("No new episodic memories to consolidate.")
return 0
memories_content = "
".join([
f"ID: {m['id']}, Timestamp: {m['timestamp'].isoformat()}, Content: {m['content']}"
for m in episodic_memories
])
try:
# Wrap consolidation logic in a span for better tracing
with trace.get_tracer(__name__).start_as_current_span("consolidate_memories_llm_extraction") as span:
span.set_attribute("memory.count", len(episodic_memories))
chain = self.consolidation_prompt | self.llm | JsonOutputParser()
extracted_triples = chain.invoke({"memories_content": memories_content})
span.set_attribute("extracted_triples.count", len(extracted_triples))
if not extracted_triples:
logger.info("LLM extracted no semantic triples from recent memories.")
return 0
added_count = 0
for triple_data in extracted_triples:
subject = triple_data.get("subject")
predicate = triple_data.get("predicate")
object_val = triple_data.get("object")
if subject and predicate and object_val:
# For simplicity, we link to the first memory from the batch as source
source_id = str(episodic_memories[0]['id']) if episodic_memories else None
self.memory_manager.add_semantic_triple(subject, predicate, object_val, source_id)
added_count += 1
else:
logger.warning(f"Skipping malformed triple: {triple_data}")
logger.info(f"Consolidation complete. Added {added_count} new semantic triples.")
return added_count
except Exception as e:
logger.error(f"Error during memory consolidation: {e}")
return 0
if __name__ == "__main__":
with trace.get_tracer(__name__).start_as_current_span("memory_consolidation_job_run"):
memory_manager_instance = MemoryManager()
consolidator = MemoryConsolidator(memory_manager_instance)
logger.info("Running memory consolidation script...")
consolidated_triples_count = consolidator.consolidate_recent_memories(limit=50)
logger.info(f"Successfully consolidated {consolidated_triples_count} triples.")
print("
Verifying semantic memory after consolidation:")
retrieved_facts = memory_manager_instance.retrieve_semantic_memory("general knowledge")
for fact in retrieved_facts:
print(f" - {fact['subject']} {fact['predicate']} {fact['object']}")
# Give exporter time to send spans
time.sleep(2)
scripts/consolidate_memory.py file implements the crucial learning and generalization component of our long-term memory agent. The MemoryConsolidator class is responsible for this process. It takes an instance of MemoryManager to interact with both episodic and semantic memory. The core logic resides in consolidate_recent_memories, which first fetches a batch of recent episodic memories. It then uses a specialized ChatOpenAI LLM, guided by a carefully crafted consolidation_prompt, to identify and extract generalizable facts from these raw events. The prompt explicitly instructs the LLM to output these facts as structured (subject, predicate, object) JSON triples, ensuring a consistent format for storage. JsonOutputParser() parses the LLM output as a JSON list, making the extracted triples robust and directly iterable. The extracted triples are then added to the semantic memory via memory_manager.add_semantic_triple. This process effectively promotes specific experiences into generalized knowledge, preventing the agent from relying solely on its working memory or needing to re-derive facts repeatedly. Error handling and logging are integrated to monitor the consolidation process and troubleshoot any issues. The if __name__ == "__main__": block demonstrates how this script would be executed, typically as a scheduled job, and includes OpenTelemetry instrumentation for observability.
- First, interact with the
agent_app.pyfor a few turns, ensuring some episodic memories are recorded (e.g., "My name is Bob", "The sun is a star"). - Then, stop the
agent_app.pyand runpython scripts/consolidate_memory.py. Ensure your OpenTelemetry collector is running if you want to see traces. - Observe the logs from the consolidator, which should indicate the number of episodic memories processed and semantic triples added. Check the Jaeger UI for traces under the "memory-consolidation-job" service.
- Restart
agent_app.pyand ask questions that would benefit from the newly consolidated semantic memories (e.g., "What is the sun?" if that was consolidated). You should seeretrieve_semantic_memory_toolbeing used and providing the consolidated fact.
Testing & Evaluation
Robust testing and evaluation are paramount for an AI agent with complex memory systems, especially one intended for production. We need to ensure not only that the code functions correctly but also that the agent's reasoning, memory retrieval, and consolidation processes are effective and reliable.
Functional Testing
Functional tests should cover each component: MemoryManager methods (add/retrieve episodic, add/retrieve semantic), AgentTools invocations, and the LongTermMemoryAgent's conversational flow. Unit tests for MemoryManager should mock database interactions to test embedding generation, storage, and retrieval logic in isolation. Integration tests for AgentTools should ensure that tools correctly call MemoryManager and return expected outputs. For the LongTermMemoryAgent, end-to-end tests should simulate user conversations and assert that the agent uses the correct memory tools and generates appropriate responses. This includes testing scenarios where the agent needs to access specific past events (episodic memory) versus general knowledge (semantic memory).
LLM-as-a-Judge Evaluation
Evaluating the quality of LLM-driven decisions, such as which memory tool to use or whether to consolidate a fact, requires qualitative assessment. LLM-as-a-Judge frameworks can automate this. We can create a dataset of prompts designed to test specific memory interactions (e.g., "Tell me what I said five minutes ago" vs. "What is the capital of Japan?"). For each prompt, we provide the agent's response and a reference answer. A separate, powerful LLM (the 'judge') then scores the agent's response based on correctness, relevance, and adherence to instructions, including the appropriate use of memory tools. For consolidation, the judge LLM can evaluate if extracted semantic triples are indeed generalizable and accurate representations of the episodic memories.
# tests/evaluate_agent.py
import os
import logging
from typing import List, Dict, Any
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langchain_openai import ChatOpenAI
from langchain.evaluation import load_evaluator
from components.agent import LongTermMemoryAgent
from config import OPENAI_API_KEY, OPENAI_MODEL_NAME # Assuming these are configured
logger = logging.getLogger(__name__)
class AgentEvaluator:
def __init__(self):
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY is not set for evaluation.")
self.agent = LongTermMemoryAgent()
self.eval_llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=OPENAI_API_KEY)
self.evaluator = load_evaluator("labeled_score_string", llm=self.eval_llm, criteria={"accuracy": "Is the agent's response factually correct and relevant to the user's query, considering its memory access?", "memory_usage": "Did the agent correctly utilize its episodic or semantic memory tools when appropriate?"})
logger.info("AgentEvaluator initialized with LabeledScoreString evaluator.")
def evaluate_scenario(self, input_query: str, expected_response_substring: str, expected_memory_tool_use: str = "any") -> Dict[str, Any]:
"""Evaluates a single agent interaction scenario."""
logger.info(f"Evaluating scenario: '{input_query}'")
chat_history: List[BaseMessage] = []
agent_response = self.agent.run(input_query, chat_history)
# Simulate tool use detection from logs or by parsing agent_response if tool_outputs were exposed.
# For this example, we'll rely on string matching for expected output and assume successful tool use if the output is relevant.
# In a real system, you'd parse LangGraph trace logs for explicit tool call events.
is_correct = expected_response_substring.lower() in agent_response.lower()
# Simplified check for memory tool use. A real system would parse LangGraph traces.
# If the expected_memory_tool_use is 'episodic' or 'semantic', we'd look for specific tool call indicators in logs/traces.
# For now, we assume if the response is correct and memory is expected, it was used.
feedback = self.evaluator.evaluate_strings(
prediction=agent_response,
input=input_query,
reference=expected_response_substring # Using expected substring as a simple reference
)
logger.info(f"Evaluation results: {feedback}")
return {"query": input_query, "response": agent_response, "expected": expected_response_substring, "feedback": feedback}
def run_evaluations(self):
"""Runs a set of predefined evaluation scenarios."""
scenarios = [
{
"query": "My favorite color is blue. Please remember that.",
"expected_response_substring": "episodic memory added",
"expected_memory_tool_use": "add_episodic_memory_tool"
},
{
"query": "What is the capital of Germany?",
"expected_response_substring": "Berlin",
"expected_memory_tool_use": "retrieve_semantic_memory_tool"
},
{
"query": "What is my favorite color?",
"expected_response_substring": "blue",
"expected_memory_tool_use": "retrieve_episodic_memory_tool"
},
{
"query": "Tell me a general fact about the Earth.",
"expected_response_substring": "Earth is the third planet", # Assuming this might be in semantic memory
"expected_memory_tool_use": "retrieve_semantic_memory_tool"
}
]
results = []
for scenario in scenarios:
result = self.evaluate_scenario(scenario["query"], scenario["expected_response_substring"], scenario.get("expected_memory_tool_use"))
results.append(result)
print(f"
Scenario: {result['query']}
Agent: {result['response']}
Feedback: {result['feedback']}
")
return results
if __name__ == "__main__":
# Ensure environment variables are loaded for config and agent init
from dotenv import load_dotenv
load_dotenv()
# Ensure OPENAI_API_KEY is available
if os.environ.get("OPENAI_API_KEY") is None:
print("Error: OPENAI_API_KEY not set. Please set it to run evaluations.")
else:
evaluator = AgentEvaluator()
evaluation_results = evaluator.run_evaluations()
print("
--- Overall Evaluation Summary ---")
for res in evaluation_results:
print(f"Query: {res['query']}
Response: {res['response']}
Score: {res['feedback'].get('score')}, Reasoning: {res['feedback'].get('reasoning')}
")Failure Scenario Testing
We need to test how the agent behaves under adverse conditions: database connection failures, LLM API timeouts, malformed tool outputs, or excessive token usage. Implement retry mechanisms with exponential backoff for external API calls (LLM, DB) and robust error handling in all MemoryManager and AgentTools methods. Test these by temporarily disabling database access or simulating API errors to ensure graceful degradation or appropriate error messages.
Edge Case Coverage
Consider edge cases for memory: querying for non-existent memories, adding duplicate facts, or memories with very short or very long content. For consolidation, test with an empty set of episodic memories, or memories that contain no generalizable facts. Ensure the agent doesn't hallucinate or get stuck in loops when memory retrieval yields no results.
Performance Validation
Monitor response latency, token usage, and database query times. OpenTelemetry traces are crucial here. Evaluate how k (number of retrieved documents) affects performance and relevance for episodic and semantic memory searches. For the consolidation job, measure its execution time and resource consumption, especially as the volume of episodic memories grows. This helps optimize batch sizes and scheduling.
Regression Testing
As the agent evolves, new features or prompt changes can introduce regressions. A comprehensive test suite, including functional, LLM-as-a-Judge, and performance tests, should be run as part of a CI/CD pipeline. This ensures that changes to the agent's logic or memory interaction don't negatively impact its overall performance or reliability.
Deployment
Deploying the Long-Term Memory Agent involves containerizing the application and its components, setting up a robust PostgreSQL database, and orchestrating them for scalability and reliability, typically using Kubernetes or a similar container orchestration platform.
1. Dockerfile
The Dockerfile provided in Phase 1 ensures that our application is self-contained and reproducible. It installs all Python dependencies, including OpenTelemetry instrumentation, and sets the entry point for the agent application. For the consolidation script, you would run it as a separate container or a scheduled job within your orchestration system.
2. Docker Compose (Local Development)
For local development and testing, docker-compose.yml is invaluable for spinning up the PostgreSQL database and the agent application:
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg15
restart: always
environment:
POSTGRES_DB: agent_memory_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
- ./db/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql
agent-app:
build: .
restart: on-failure
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
POSTGRES_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
LANGCHAIN_TRACING_V2: ${LANGCHAIN_TRACING_V2}
LANGCHAIN_API_KEY: ${LANGCHAIN_API_KEY}
LANGCHAIN_PROJECT: ${LANGCHAIN_PROJECT}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4318/v1/traces}
ENV: development
ports:
- "8000:8000"
depends_on:
- postgres
- jaeger # If running Jaeger locally
consolidation-job:
build: .
restart: "no" # This should be run on schedule, not always restarting
command: python scripts/consolidate_memory.py
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
POSTGRES_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
LANGCHAIN_TRACING_V2: ${LANGCHAIN_TRACING_V2}
LANGCHAIN_API_KEY: ${LANGCHAIN_API_KEY}
LANGCHAIN_PROJECT: ${LANGCHAIN_PROJECT}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4318/v1/traces}
ENV: development
depends_on:
- postgres
- jaeger
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
- "4318:4318"
environment:
COLLECTOR_OTLP_ENABLED: "true"
volumes:
pg_data:Run with docker-compose up --build. The consolidation-job should be run manually or scheduled via docker-compose run consolidation-job.
3. Kubernetes Manifest (Production Example)
For production, Kubernetes provides robust orchestration. Here's a simplified example for the agent deployment. The consolidation job would be a CronJob.
apiVersion: apps/v1
kind: Deployment
metadata:
name: long-term-memory-agent
labels:
app: long-term-memory-agent
spec:
replicas: 2
selector:
matchLabels:
app: long-term-memory-agent
template:
metadata:
labels:
app: long-term-memory-agent
spec:
containers:
- name: agent
image: your-repo/long-term-memory-agent:latest # Push your Docker image here
ports:
- containerPort: 8000
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: agent-secrets
key: openai-api-key
- name: POSTGRES_HOST
value: postgres-service # Name of your PostgreSQL service in K8s
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-db
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-password
- name: LANGCHAIN_TRACING_V2
value: "true"
- name: LANGCHAIN_API_KEY
valueFrom:
secretKeyRef:
name: agent-secrets
key: langchain-api-key
- name: LANGCHAIN_PROJECT
value: "long-term-memory-agent"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector:4318/v1/traces" # Your OpenTelemetry collector endpoint
- name: ENV
value: production
livenessProbe:
httpGet:
path: /health # Implement a simple /health endpoint in agent_app.py
port: 8000
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
httpGet:
path: /ready # Implement a /ready endpoint (e.g., checks DB connection)
port: 8000
initialDelaySeconds: 15
periodSeconds: 10
--- # Kubernetes Service for the agent
apiVersion: v1
kind: Service
metadata:
name: long-term-memory-agent-service
spec:
selector:
app: long-term-memory-agent
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: ClusterIP
--- # Kubernetes CronJob for consolidation (simplified)
apiVersion: batch/v1
kind: CronJob
metadata:
name: memory-consolidation-cronjob
spec:
schedule: "0 3 * * *" # Run daily at 3 AM UTC
jobTemplate:
spec:
template:
spec:
containers:
- name: consolidator
image: your-repo/long-term-memory-agent:latest
command: ["python", "scripts/consolidate_memory.py"]
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: agent-secrets
key: openai-api-key
- name: POSTGRES_HOST
value: postgres-service
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-db
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: agent-secrets
key: postgres-password
- name: LANGCHAIN_TRACING_V2
value: "true"
- name: LANGCHAIN_API_KEY
valueFrom:
secretKeyRef:
name: agent-secrets
key: langchain-api-key
- name: LANGCHAIN_PROJECT
value: "memory-consolidation-job"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector:4318/v1/traces"
restartPolicy: OnFailure4. Secrets Management
Never hardcode sensitive values. In Kubernetes, use Secrets to store OPENAI_API_KEY, POSTGRES_PASSWORD, etc. These are then mounted as environment variables into the pods, as shown in the Kubernetes manifest. For local development, use a .env file with python-dotenv.
5. Health Check and Readiness Probe
Implement /health and /ready HTTP endpoints in agent_app.py. The /health endpoint should return 200 OK if the application process is running. The /ready endpoint should perform deeper checks, like verifying connectivity to the PostgreSQL database and LLM provider. Kubernetes uses these probes to determine if a pod is healthy and ready to receive traffic.
6. Scaling Considerations
Since LLM calls can be latency-sensitive and consume significant resources, scale the agent deployment horizontally based on CPU utilization or request queue depth. The pgvector database can be scaled vertically (larger instance) or horizontally (read replicas) depending on read/write patterns. The consolidation job is typically a batch process, so its scaling might involve increasing CPU/memory limits or running multiple instances for different data partitions.
7. Rollback Strategy
Use immutable Docker images tagged with version numbers (e.g., v1.0.0, v1.0.1). If a new deployment introduces issues, Kubernetes allows for easy rollback to a previous, stable version of the deployment using kubectl rollout undo deployment/long-term-memory-agent. This ensures minimal downtime and a quick recovery from faulty deployments.
Production Hardening
Moving the long-term memory agent to production requires careful attention to robustness, security, and operational efficiency. Here’s a checklist for hardening:
Security
- Secrets Management: Never hardcode API keys or database credentials. Use Kubernetes Secrets (as shown in Deployment) or a dedicated secret management service (e.g., HashiCorp Vault, AWS Secrets Manager) for production. Rotate secrets regularly.
- Input Validation & Sanitization: Sanitize all user inputs before feeding them to the LLM or storing them in the database to prevent prompt injection, SQL injection, and other OWASP Top 10 for Agentic AI vulnerabilities. Implement strict schema validation for tool inputs.
- Least Privilege: Ensure the agent's container and the database user have only the minimum necessary permissions. For example, the agent might only need read/write access to its specific memory tables, not admin access to the entire database.
- Dependency Scanning: Regularly scan your
requirements.txtand Docker image for known vulnerabilities using tools like Snyk or Trivy. Keep dependencies updated. - Network Segmentation: Deploy the agent and database in private networks, accessible only through necessary ingress controllers or internal services. Limit external exposure.
Observability
- Comprehensive Logging: Ensure all critical operations, LLM calls, tool invocations, and errors are logged with appropriate severity levels. Use structured logging (JSON) for easier parsing by log aggregation systems (e.g., ELK stack, Splunk, Datadog).
- Distributed Tracing (OpenTelemetry): As implemented, use OpenTelemetry to trace requests across the agent, memory manager, LLM calls, and consolidation job. This is crucial for debugging complex multi-step workflows and understanding latency.
- Metrics & Alerts: Collect key metrics like LLM token usage, response latency, tool success/failure rates, database query times, and consolidation job duration. Set up alerts for anomalies (e.g., high error rates, increased latency, excessive token usage).
- LangSmith Integration: Leverage LangSmith for detailed LLM trace analysis, prompt versioning, A/B testing, and automated evaluation of agent responses and tool usage correctness.
Reliability
- Retry Mechanisms: Implement exponential backoff and retry logic for all external API calls (LLM, database) to handle transient failures gracefully. Define clear retry limits.
- Circuit Breakers: For critical external dependencies, implement circuit breakers to prevent cascading failures if a service becomes unresponsive. This can temporarily stop calls to a failing service.
- Idempotency: Design memory write operations to be idempotent where possible, preventing unintended side effects if a request is retried.
- Database Backups & Recovery: Establish a robust backup strategy for your PostgreSQL database, including point-in-time recovery, to prevent data loss.
Rate Limiting
- LLM API Rate Limits: Implement client-side rate limiting to respect LLM provider API limits and prevent throttling. Use libraries like
tenacityfor this. The agent should gracefully handleTooManyRequestserrors. - Internal Rate Limiting: If the agent exposes an API, implement rate limiting to protect your service from abuse and ensure fair usage.
Authentication
- User Authentication (if applicable): If the agent interacts with end-users, integrate with an identity provider (e.g., OAuth2, OpenID Connect) to authenticate users and enforce access control to agent features or personalized memories. The current setup assumes a single-user or internal system.
- Service-to-Service Authentication: Secure communications between the agent application, the database, and any other microservices using mTLS or API keys/tokens.
Governance
- Prompt Versioning: Store and version all system prompts, tool descriptions, and consolidation prompts in a version control system. This allows for auditing changes and rolling back problematic prompts.
- Data Retention Policies: Define and enforce data retention policies for episodic and semantic memories, especially for personally identifiable information (PII), to comply with regulations like GDPR or CCPA.
Cost Optimisation
- Token Usage Monitoring: Continuously monitor and optimize LLM token usage through prompt engineering, efficient RAG strategies, and effective summarization before consolidation. OpenTelemetry and LangSmith are key for this.
- Resource Scaling: Implement autoscaling for Kubernetes deployments based on demand to optimize infrastructure costs. Configure appropriate resource limits and requests for pods.
- Consolidation Job Efficiency: Optimize the consolidation job's frequency, batch size, and LLM prompt to balance knowledge generalization with cost-effectiveness.
Compliance
- Data Residency: Understand and configure where your
pgvectorand PostgreSQL instances store data to comply with data residency requirements. - Audit Trails: Maintain detailed audit trails of agent decisions, tool usage, and memory modifications for compliance and debugging purposes.
- By the end of this project, you will be able to implement a multi-layered memory architecture (working, episodic, semantic) for an AI agent.
- By the end of this project, you will be able to integrate a
pgvectordatabase for efficient episodic memory storage and retrieval. - By the end of this project, you will be able to design and populate a semantic knowledge graph for long-term knowledge retention.
- By the end of this project, you will be able to develop and schedule a nightly consolidation job to promote episodic facts to semantic memory.
- By the end of this project, you will be able to orchestrate complex agentic workflows using LangGraph for state management and conditional routing.
- By the end of this project, you will be able to apply production-grade observability practices using OpenTelemetry for agent tracing.