forked from cardosofelipe/fast-next-template
Implements the episodic memory service for storing and retrieving agent task execution experiences. This enables learning from past successes and failures. Components: - EpisodicMemory: Main service class combining recording and retrieval - EpisodeRecorder: Handles episode creation, importance scoring - EpisodeRetriever: Multiple retrieval strategies (recency, semantic, outcome, importance, task type) Key features: - Records task completions with context, actions, outcomes - Calculates importance scores based on outcome, duration, lessons - Semantic search with fallback to recency when embeddings unavailable - Full CRUD operations with statistics and summarization - Comprehensive unit tests (50 tests, all passing) Closes #90 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
491 lines
14 KiB
Python
491 lines
14 KiB
Python
# app/services/memory/episodic/memory.py
|
|
"""
|
|
Episodic Memory Implementation.
|
|
|
|
Provides experiential memory storage and retrieval for agent learning.
|
|
Combines episode recording and retrieval into a unified interface.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.services.memory.types import Episode, EpisodeCreate, Outcome, RetrievalResult
|
|
|
|
from .recorder import EpisodeRecorder
|
|
from .retrieval import EpisodeRetriever, RetrievalStrategy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EpisodicMemory:
|
|
"""
|
|
Episodic Memory Service.
|
|
|
|
Provides experiential memory for agent learning:
|
|
- Record task completions with context
|
|
- Store failures with error context
|
|
- Retrieve by semantic similarity
|
|
- Retrieve by recency, outcome, task type
|
|
- Track importance scores
|
|
- Extract lessons learned
|
|
|
|
Performance target: <100ms P95 for retrieval
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
embedding_generator: Any | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize episodic memory.
|
|
|
|
Args:
|
|
session: Database session
|
|
embedding_generator: Optional embedding generator for semantic search
|
|
"""
|
|
self._session = session
|
|
self._embedding_generator = embedding_generator
|
|
self._recorder = EpisodeRecorder(session, embedding_generator)
|
|
self._retriever = EpisodeRetriever(session, embedding_generator)
|
|
|
|
@classmethod
|
|
async def create(
|
|
cls,
|
|
session: AsyncSession,
|
|
embedding_generator: Any | None = None,
|
|
) -> "EpisodicMemory":
|
|
"""
|
|
Factory method to create EpisodicMemory.
|
|
|
|
Args:
|
|
session: Database session
|
|
embedding_generator: Optional embedding generator
|
|
|
|
Returns:
|
|
Configured EpisodicMemory instance
|
|
"""
|
|
return cls(session=session, embedding_generator=embedding_generator)
|
|
|
|
# =========================================================================
|
|
# Recording Operations
|
|
# =========================================================================
|
|
|
|
async def record_episode(self, episode: EpisodeCreate) -> Episode:
|
|
"""
|
|
Record a new episode.
|
|
|
|
Args:
|
|
episode: Episode data to record
|
|
|
|
Returns:
|
|
The created episode with assigned ID
|
|
"""
|
|
return await self._recorder.record(episode)
|
|
|
|
async def record_success(
|
|
self,
|
|
project_id: UUID,
|
|
session_id: str,
|
|
task_type: str,
|
|
task_description: str,
|
|
actions: list[dict[str, Any]],
|
|
context_summary: str,
|
|
outcome_details: str = "",
|
|
duration_seconds: float = 0.0,
|
|
tokens_used: int = 0,
|
|
lessons_learned: list[str] | None = None,
|
|
agent_instance_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
) -> Episode:
|
|
"""
|
|
Convenience method to record a successful episode.
|
|
|
|
Args:
|
|
project_id: Project ID
|
|
session_id: Session ID
|
|
task_type: Type of task
|
|
task_description: Task description
|
|
actions: Actions taken
|
|
context_summary: Context summary
|
|
outcome_details: Optional outcome details
|
|
duration_seconds: Task duration
|
|
tokens_used: Tokens consumed
|
|
lessons_learned: Optional lessons
|
|
agent_instance_id: Optional agent instance
|
|
agent_type_id: Optional agent type
|
|
|
|
Returns:
|
|
The created episode
|
|
"""
|
|
episode_data = EpisodeCreate(
|
|
project_id=project_id,
|
|
session_id=session_id,
|
|
task_type=task_type,
|
|
task_description=task_description,
|
|
actions=actions,
|
|
context_summary=context_summary,
|
|
outcome=Outcome.SUCCESS,
|
|
outcome_details=outcome_details,
|
|
duration_seconds=duration_seconds,
|
|
tokens_used=tokens_used,
|
|
lessons_learned=lessons_learned or [],
|
|
agent_instance_id=agent_instance_id,
|
|
agent_type_id=agent_type_id,
|
|
)
|
|
return await self.record_episode(episode_data)
|
|
|
|
async def record_failure(
|
|
self,
|
|
project_id: UUID,
|
|
session_id: str,
|
|
task_type: str,
|
|
task_description: str,
|
|
actions: list[dict[str, Any]],
|
|
context_summary: str,
|
|
error_details: str,
|
|
duration_seconds: float = 0.0,
|
|
tokens_used: int = 0,
|
|
lessons_learned: list[str] | None = None,
|
|
agent_instance_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
) -> Episode:
|
|
"""
|
|
Convenience method to record a failed episode.
|
|
|
|
Args:
|
|
project_id: Project ID
|
|
session_id: Session ID
|
|
task_type: Type of task
|
|
task_description: Task description
|
|
actions: Actions taken before failure
|
|
context_summary: Context summary
|
|
error_details: Error details
|
|
duration_seconds: Task duration
|
|
tokens_used: Tokens consumed
|
|
lessons_learned: Optional lessons from failure
|
|
agent_instance_id: Optional agent instance
|
|
agent_type_id: Optional agent type
|
|
|
|
Returns:
|
|
The created episode
|
|
"""
|
|
episode_data = EpisodeCreate(
|
|
project_id=project_id,
|
|
session_id=session_id,
|
|
task_type=task_type,
|
|
task_description=task_description,
|
|
actions=actions,
|
|
context_summary=context_summary,
|
|
outcome=Outcome.FAILURE,
|
|
outcome_details=error_details,
|
|
duration_seconds=duration_seconds,
|
|
tokens_used=tokens_used,
|
|
lessons_learned=lessons_learned or [],
|
|
agent_instance_id=agent_instance_id,
|
|
agent_type_id=agent_type_id,
|
|
)
|
|
return await self.record_episode(episode_data)
|
|
|
|
# =========================================================================
|
|
# Retrieval Operations
|
|
# =========================================================================
|
|
|
|
async def search_similar(
|
|
self,
|
|
project_id: UUID,
|
|
query: str,
|
|
limit: int = 10,
|
|
agent_instance_id: UUID | None = None,
|
|
) -> list[Episode]:
|
|
"""
|
|
Search for semantically similar episodes.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
query: Search query
|
|
limit: Maximum results
|
|
agent_instance_id: Optional filter by agent instance
|
|
|
|
Returns:
|
|
List of similar episodes
|
|
"""
|
|
result = await self._retriever.search_similar(
|
|
project_id, query, limit, agent_instance_id
|
|
)
|
|
return result.items
|
|
|
|
async def get_recent(
|
|
self,
|
|
project_id: UUID,
|
|
limit: int = 10,
|
|
since: datetime | None = None,
|
|
agent_instance_id: UUID | None = None,
|
|
) -> list[Episode]:
|
|
"""
|
|
Get recent episodes.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
limit: Maximum results
|
|
since: Optional time filter
|
|
agent_instance_id: Optional filter by agent instance
|
|
|
|
Returns:
|
|
List of recent episodes
|
|
"""
|
|
result = await self._retriever.get_recent(
|
|
project_id, limit, since, agent_instance_id
|
|
)
|
|
return result.items
|
|
|
|
async def get_by_outcome(
|
|
self,
|
|
project_id: UUID,
|
|
outcome: Outcome,
|
|
limit: int = 10,
|
|
agent_instance_id: UUID | None = None,
|
|
) -> list[Episode]:
|
|
"""
|
|
Get episodes by outcome.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
outcome: Outcome to filter by
|
|
limit: Maximum results
|
|
agent_instance_id: Optional filter by agent instance
|
|
|
|
Returns:
|
|
List of episodes with specified outcome
|
|
"""
|
|
result = await self._retriever.get_by_outcome(
|
|
project_id, outcome, limit, agent_instance_id
|
|
)
|
|
return result.items
|
|
|
|
async def get_by_task_type(
|
|
self,
|
|
project_id: UUID,
|
|
task_type: str,
|
|
limit: int = 10,
|
|
agent_instance_id: UUID | None = None,
|
|
) -> list[Episode]:
|
|
"""
|
|
Get episodes by task type.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
task_type: Task type to filter by
|
|
limit: Maximum results
|
|
agent_instance_id: Optional filter by agent instance
|
|
|
|
Returns:
|
|
List of episodes with specified task type
|
|
"""
|
|
result = await self._retriever.get_by_task_type(
|
|
project_id, task_type, limit, agent_instance_id
|
|
)
|
|
return result.items
|
|
|
|
async def get_important(
|
|
self,
|
|
project_id: UUID,
|
|
limit: int = 10,
|
|
min_importance: float = 0.7,
|
|
agent_instance_id: UUID | None = None,
|
|
) -> list[Episode]:
|
|
"""
|
|
Get high-importance episodes.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
limit: Maximum results
|
|
min_importance: Minimum importance score
|
|
agent_instance_id: Optional filter by agent instance
|
|
|
|
Returns:
|
|
List of important episodes
|
|
"""
|
|
result = await self._retriever.get_important(
|
|
project_id, limit, min_importance, agent_instance_id
|
|
)
|
|
return result.items
|
|
|
|
async def retrieve(
|
|
self,
|
|
project_id: UUID,
|
|
strategy: RetrievalStrategy = RetrievalStrategy.RECENCY,
|
|
limit: int = 10,
|
|
**kwargs: Any,
|
|
) -> RetrievalResult[Episode]:
|
|
"""
|
|
Retrieve episodes with full result metadata.
|
|
|
|
Args:
|
|
project_id: Project to search within
|
|
strategy: Retrieval strategy
|
|
limit: Maximum results
|
|
**kwargs: Strategy-specific parameters
|
|
|
|
Returns:
|
|
RetrievalResult with episodes and metadata
|
|
"""
|
|
return await self._retriever.retrieve(project_id, strategy, limit, **kwargs)
|
|
|
|
# =========================================================================
|
|
# Modification Operations
|
|
# =========================================================================
|
|
|
|
async def get_by_id(self, episode_id: UUID) -> Episode | None:
|
|
"""Get an episode by ID."""
|
|
return await self._recorder.get_by_id(episode_id)
|
|
|
|
async def update_importance(
|
|
self,
|
|
episode_id: UUID,
|
|
importance_score: float,
|
|
) -> Episode | None:
|
|
"""
|
|
Update an episode's importance score.
|
|
|
|
Args:
|
|
episode_id: Episode to update
|
|
importance_score: New importance score (0.0 to 1.0)
|
|
|
|
Returns:
|
|
Updated episode or None if not found
|
|
"""
|
|
return await self._recorder.update_importance(episode_id, importance_score)
|
|
|
|
async def add_lessons(
|
|
self,
|
|
episode_id: UUID,
|
|
lessons: list[str],
|
|
) -> Episode | None:
|
|
"""
|
|
Add lessons learned to an episode.
|
|
|
|
Args:
|
|
episode_id: Episode to update
|
|
lessons: Lessons to add
|
|
|
|
Returns:
|
|
Updated episode or None if not found
|
|
"""
|
|
return await self._recorder.add_lessons(episode_id, lessons)
|
|
|
|
async def delete(self, episode_id: UUID) -> bool:
|
|
"""
|
|
Delete an episode.
|
|
|
|
Args:
|
|
episode_id: Episode to delete
|
|
|
|
Returns:
|
|
True if deleted
|
|
"""
|
|
return await self._recorder.delete(episode_id)
|
|
|
|
# =========================================================================
|
|
# Summarization
|
|
# =========================================================================
|
|
|
|
async def summarize_episodes(
|
|
self,
|
|
episode_ids: list[UUID],
|
|
) -> str:
|
|
"""
|
|
Summarize multiple episodes into a consolidated view.
|
|
|
|
Args:
|
|
episode_ids: Episodes to summarize
|
|
|
|
Returns:
|
|
Summary text
|
|
"""
|
|
if not episode_ids:
|
|
return "No episodes to summarize."
|
|
|
|
episodes: list[Episode] = []
|
|
for episode_id in episode_ids:
|
|
episode = await self.get_by_id(episode_id)
|
|
if episode:
|
|
episodes.append(episode)
|
|
|
|
if not episodes:
|
|
return "No episodes found."
|
|
|
|
# Build summary
|
|
lines = [f"Summary of {len(episodes)} episodes:", ""]
|
|
|
|
# Outcome breakdown
|
|
success = sum(1 for e in episodes if e.outcome == Outcome.SUCCESS)
|
|
failure = sum(1 for e in episodes if e.outcome == Outcome.FAILURE)
|
|
partial = sum(1 for e in episodes if e.outcome == Outcome.PARTIAL)
|
|
lines.append(
|
|
f"Outcomes: {success} success, {failure} failure, {partial} partial"
|
|
)
|
|
|
|
# Task types
|
|
task_types = {e.task_type for e in episodes}
|
|
lines.append(f"Task types: {', '.join(sorted(task_types))}")
|
|
|
|
# Aggregate lessons
|
|
all_lessons: list[str] = []
|
|
for e in episodes:
|
|
all_lessons.extend(e.lessons_learned)
|
|
|
|
if all_lessons:
|
|
lines.append("")
|
|
lines.append("Key lessons learned:")
|
|
# Deduplicate lessons
|
|
unique_lessons = list(dict.fromkeys(all_lessons))
|
|
for lesson in unique_lessons[:10]: # Top 10
|
|
lines.append(f" - {lesson}")
|
|
|
|
# Duration and tokens
|
|
total_duration = sum(e.duration_seconds for e in episodes)
|
|
total_tokens = sum(e.tokens_used for e in episodes)
|
|
lines.append("")
|
|
lines.append(f"Total duration: {total_duration:.1f}s")
|
|
lines.append(f"Total tokens: {total_tokens:,}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
# =========================================================================
|
|
# Statistics
|
|
# =========================================================================
|
|
|
|
async def get_stats(self, project_id: UUID) -> dict[str, Any]:
|
|
"""
|
|
Get episode statistics for a project.
|
|
|
|
Args:
|
|
project_id: Project to get stats for
|
|
|
|
Returns:
|
|
Dictionary with episode statistics
|
|
"""
|
|
return await self._recorder.get_stats(project_id)
|
|
|
|
async def count(
|
|
self,
|
|
project_id: UUID,
|
|
since: datetime | None = None,
|
|
) -> int:
|
|
"""
|
|
Count episodes for a project.
|
|
|
|
Args:
|
|
project_id: Project to count for
|
|
since: Optional time filter
|
|
|
|
Returns:
|
|
Number of episodes
|
|
"""
|
|
return await self._recorder.count_by_project(project_id, since)
|