Skip to content
This repository was archived by the owner on Jun 21, 2025. It is now read-only.

Commit 4821998

Browse files
authored
feat: Add JSON file memory service used in e2e testing (#20)
1 parent a766065 commit 4821998

11 files changed

Lines changed: 1135 additions & 30 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Custom MemoryService implementation that persists sessions to a JSON file."""
2+
3+
import ast # Import ast for literal_eval
4+
import json
5+
import logging
6+
import os
7+
from dataclasses import dataclass # Import dataclass
8+
from typing import Any, Dict, List, Tuple
9+
10+
from google.adk.memory import BaseMemoryService
11+
from google.adk.sessions import Session
12+
13+
logger = logging.getLogger(__name__)
14+
15+
# Define type for the internal session storage key
16+
SessionKey = Tuple[str, str, str] # (app_name, user_id, session_id)
17+
18+
19+
# Define a simple response structure for load_memory/search_memory
20+
@dataclass
21+
class MemoryServiceResponse:
22+
memories: List[Dict[str, Any]]
23+
24+
25+
class JsonFileMemoryService(BaseMemoryService):
26+
"""
27+
An implementation of BaseMemoryService that stores sessions in memory
28+
and persists them to/loads them from a JSON file.
29+
30+
The load_memory implementation is a basic substring search.
31+
"""
32+
33+
def __init__(self, filepath: str):
34+
"""
35+
Initializes the service, loading existing data from the JSON file if it exists.
36+
37+
Args:
38+
filepath: The path to the JSON file for persistence.
39+
"""
40+
super().__init__()
41+
self.filepath = filepath
42+
# Store Session objects directly, keyed by the tuple
43+
self._sessions: Dict[SessionKey, Session] = {}
44+
self._load_from_json()
45+
46+
def _get_session_key(self, session: Session) -> SessionKey:
47+
"""Helper to generate the dictionary key for a session."""
48+
# Use the correct attribute name 'id' based on Session model
49+
return (session.app_name, session.user_id, session.id)
50+
51+
def _load_from_json(self):
52+
"""Loads session data from the JSON file into the internal dictionary."""
53+
if not os.path.exists(self.filepath):
54+
logger.info(f"Memory file not found at {self.filepath}. Starting with empty memory.")
55+
self._sessions = {}
56+
return
57+
58+
logger.info(f"Loading memory from {self.filepath}...")
59+
try:
60+
with open(self.filepath, "r", encoding="utf-8") as f:
61+
serialized_sessions: Dict[str, Dict[str, Any]] = json.load(f)
62+
63+
# Store validated Session objects
64+
loaded_sessions: Dict[SessionKey, Session] = {}
65+
for key_str, session_data in serialized_sessions.items():
66+
try:
67+
# Convert string key back to tuple using safe evaluation
68+
key_tuple = ast.literal_eval(key_str)
69+
if not isinstance(key_tuple, tuple) or len(key_tuple) != 3:
70+
logger.warning(f"Invalid key format loaded from JSON: {key_str}. Skipping.")
71+
continue
72+
key: SessionKey = key_tuple # Cast to type hint
73+
74+
# Recreate Session object from stored data
75+
session = Session.model_validate(session_data)
76+
# Store the Session object with the correct tuple key
77+
loaded_sessions[key] = session
78+
except (ValueError, SyntaxError, TypeError) as e:
79+
logger.warning(f"Failed to parse key {key_str}: {e}. Skipping.")
80+
except Exception as e:
81+
logger.warning(f"Failed to validate session data for key {key_str}: {e}. Skipping.")
82+
self._sessions = loaded_sessions
83+
logger.info(f"Successfully loaded {len(self._sessions)} sessions from {self.filepath}.")
84+
85+
except FileNotFoundError:
86+
logger.info(f"Memory file not found at {self.filepath}. Starting with empty memory.")
87+
self._sessions = {}
88+
except json.JSONDecodeError as e:
89+
logger.error(f"Error decoding JSON from {self.filepath}: {e}. Starting with empty memory.")
90+
self._sessions = {}
91+
except Exception as e:
92+
logger.error(f"Unexpected error loading memory from {self.filepath}: {e}. Starting with empty memory.")
93+
self._sessions = {}
94+
95+
def _save_to_json(self):
96+
"""Saves the current internal session dictionary to the JSON file."""
97+
logger.info(f"Saving memory ({len(self._sessions)} sessions) to {self.filepath}...")
98+
# Serialize Session objects using Pydantic's model_dump
99+
# Use a string representation of the tuple key for JSON compatibility
100+
serialized_sessions: Dict[str, Dict[str, Any]] = {str(key): session.model_dump(mode="json") for key, session in self._sessions.items()}
101+
102+
try:
103+
# Ensure directory exists
104+
os.makedirs(os.path.dirname(self.filepath), exist_ok=True)
105+
with open(self.filepath, "w", encoding="utf-8") as f:
106+
json.dump(serialized_sessions, f, indent=4)
107+
except IOError as e:
108+
logger.error(f"Error saving memory to {self.filepath}: {e}")
109+
except TypeError as e:
110+
logger.error(f"Unexpected error saving memory to {self.filepath}: {e}")
111+
112+
def add_session_to_memory(self, session: Session):
113+
"""
114+
Adds a completed session to the memory store and persists to JSON.
115+
116+
Args:
117+
session: The Session object to add.
118+
"""
119+
# Add logging to see if runner calls this
120+
logger.info(f"JsonFileMemoryService.add_session_to_memory called by Runner? Session ID: {getattr(session, 'session_id', 'N/A')}")
121+
122+
if not isinstance(session, Session):
123+
logger.warning(f"Attempted to add non-Session object to memory: {type(session)}")
124+
return
125+
126+
key = self._get_session_key(session)
127+
logger.debug(f"Adding session with key {key} to memory.")
128+
self._sessions[key] = session # Store the session object
129+
self._save_to_json() # Persist after adding
130+
131+
def load_memory(self, query: str, **kwargs) -> MemoryServiceResponse:
132+
"""
133+
Retrieves relevant information based on a query.
134+
135+
Args:
136+
query: The natural language query string.
137+
**kwargs: Additional keyword arguments (currently ignored).
138+
139+
Returns:
140+
A MemoryServiceResponse containing a list of dictionaries,
141+
each representing a relevant message's session data.
142+
"""
143+
logger.info(f"Loading memory with query: '{query}'")
144+
results: List[Dict[str, Any]] = []
145+
query_lower = query.lower()
146+
147+
for session in self._sessions.values(): # Iterate over Session objects
148+
session_matched = False
149+
# Access history directly from the Session object
150+
if session.history:
151+
for message in session.history:
152+
# Access parts directly from the Content object in history
153+
message_text = ""
154+
if message.parts:
155+
message_text = "".join([part.text for part in message.parts if hasattr(part, "text") and part.text is not None]).lower()
156+
157+
if query_lower in message_text:
158+
session_matched = True
159+
break # Found a match in this session's history
160+
161+
if session_matched:
162+
# Add relevant session data (e.g., the whole session dump)
163+
results.append(session.model_dump(mode="json"))
164+
165+
logger.info(f"Found {len(results)} relevant session(s) for query: '{query}'")
166+
# Return as an instance of the dataclass
167+
return MemoryServiceResponse(memories=results)
168+
169+
# Implementing the abstract method required by BaseMemoryService
170+
# Type hint reflects Base class, but implementation delegates to load_memory which returns MemoryServiceResponse
171+
def search_memory(self, query: str, **kwargs) -> List[Dict[str, Any]]:
172+
"""
173+
Searches the stored sessions for relevant information based on a query.
174+
This method fulfills the abstract requirement from BaseMemoryService.
175+
"""
176+
# For this implementation, search_memory simply delegates to load_memory.
177+
# A more sophisticated implementation might differ.
178+
logger.debug(f"search_memory called, delegating to load_memory for query: '{query}'")
179+
response = self.load_memory(query, **kwargs)
180+
# Base class expects List[Dict], extract from dataclass
181+
return response.memories
182+
183+
def get_memory_service_info(self) -> Dict[str, Any]:
184+
"""
185+
Returns information about this memory service.
186+
"""
187+
return {
188+
"service_type": "JsonFileMemoryService",
189+
"description": "Stores session memory in a local JSON file.",
190+
"filepath": self.filepath,
191+
"current_session_count": len(self._sessions),
192+
"capabilities": {"persistence": True, "search_type": "basic_substring"},
193+
}

code_agent/agent/software_engineer/software_engineer/agent.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from google.adk.agents import Agent
88

9-
# from google.adk.runtime.executors import SequentialExecutor
10-
# from google.adk.runtime.planner import SequentialPlanner
9+
# Import built-in load_memory tool
10+
from google.adk.tools import load_memory
11+
1112
# Use relative imports from the 'software_engineer' sibling directory
1213
from . import prompt
1314
from .sub_agents.code_quality.agent import code_quality_agent
@@ -17,35 +18,65 @@
1718
from .sub_agents.devops.agent import devops_agent
1819
from .sub_agents.documentation.agent import documentation_agent
1920
from .sub_agents.testing.agent import testing_agent
20-
21-
# Import the code search tool from the new location
22-
from .tools import codebase_search_tool
23-
from .tools.filesystem import (
24-
configure_approval_tool as configure_edit_approval_tool,
25-
)
26-
from .tools.filesystem import (
27-
edit_file_tool,
28-
list_dir_tool,
29-
read_file_tool,
30-
)
31-
32-
# Import memory tools
33-
# from .tools.memory import forget_tool, memorize_list_tool, memorize_tool
34-
from .tools.project_context import load_project_context
35-
from .tools.search import google_search_grounding
36-
37-
# Updated import for shell command tools
38-
from .tools.shell_command import (
21+
from .tools import (
3922
check_command_exists_tool,
4023
check_shell_command_safety_tool,
24+
codebase_search_tool,
4125
configure_shell_approval_tool,
4226
configure_shell_whitelist_tool,
27+
edit_file_tool,
4328
execute_vetted_shell_command_tool,
29+
get_os_info_tool,
30+
google_search_grounding,
31+
list_dir_tool,
32+
load_memory_from_file_tool,
33+
read_file_tool,
34+
# Placeholder manual persistence tools
35+
save_current_session_to_file_tool,
4436
)
45-
from .tools.system_info import get_os_info_tool
37+
38+
# Import tools via the tools package __init__
39+
from .tools import (
40+
configure_approval_tool as configure_edit_approval_tool, # Keep alias for now
41+
)
42+
from .tools.project_context import load_project_context
4643

4744
logger = logging.getLogger(__name__)
4845

46+
47+
# --- Memory Initialization ---
48+
def initialize_session_memory(tool_context):
49+
"""Initializes the session memory in tool_context if it doesn't exist."""
50+
if not hasattr(tool_context, "session_state"):
51+
logger.warning("Tool context does not have session_state. Cannot initialize memory.")
52+
# In a real scenario, might need to initialize session_state itself
53+
# For now, we assume session_state exists but memory might not.
54+
return
55+
56+
if "memory" not in tool_context.session_state:
57+
logger.info("Initializing agent session memory.")
58+
tool_context.session_state["memory"] = {
59+
"context": {
60+
"project_path": None, # Will be populated by load_project_context
61+
"current_file": None,
62+
},
63+
"tasks": {
64+
"active_task": None,
65+
"completed_tasks": [],
66+
},
67+
"history": {
68+
"last_read_file": None,
69+
"last_search_query": None,
70+
"last_error": None,
71+
},
72+
"user_preferences": {},
73+
# Add other relevant fields as needed based on agent interactions
74+
}
75+
# else: memory already exists, do nothing
76+
77+
78+
# --- Agent Definition ---
79+
4980
# Note: Using custom ripgrep-based codebase search in tools/code_search.py
5081

5182
# REF: https://ai.google.dev/gemini-api/docs/rate-limits
@@ -76,10 +107,13 @@
76107
google_search_grounding,
77108
codebase_search_tool,
78109
get_os_info_tool,
79-
# memorize_tool,
80-
# memorize_list_tool,
81-
# forget_tool,
110+
# Add built-in ADK memory tool
111+
load_memory,
112+
# Add placeholder tools (active but non-functional)
113+
save_current_session_to_file_tool,
114+
load_memory_from_file_tool,
82115
],
116+
# Pass the function directly, not as a list
83117
before_agent_callback=load_project_context,
84118
output_key="software_engineer",
85119
)

code_agent/agent/software_engineer/software_engineer/prompt.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,32 @@
5151
- If the user asks for help with documentation, transfer to the agent `documentation_agent`
5252
- If the user asks about deployment, CI/CD, or DevOps practices, transfer to the agent `devops_agent`
5353
54+
## Long-Term Memory Access:
55+
- Your conversations are periodically saved to a long-term memory store, **containing facts, decisions, and context from previous sessions.**
56+
- **You MUST use the `load_memory` tool to answer questions about information from past interactions or sessions.**
57+
- Provide a natural language `query` to the `load_memory` tool describing the information you need (e.g., `load_memory(query="discussion about Project Alpha last week")`, `load_memory(query="user's favorite language")`).
58+
- The tool will search the memory and return relevant snippets from past interactions.
59+
- Use this tool when the user asks questions that require recalling information beyond the current immediate conversation (e.g., "What did we decide about the API design yesterday?", "Remind me about the goals for feature X", "What is my favorite language?").
60+
- **Do not guess or state that you cannot remember past information. Use the `load_memory` tool.**
61+
- Note: This is for retrieving past information. Context within the *current* session (like the most recently read file) should be tracked via your reasoning and the conversation history.
62+
63+
# --- Placeholder: Manual Memory Persistence Tools (Not Implemented) ---
64+
# - TODO: The following tools are placeholders for a potential future feature
65+
# - TODO: allowing manual persistence if the standard MemoryService is insufficient
66+
# - TODO: for the 'adk run' environment. DO NOT USE THEM unless explicitly told
67+
# - TODO: that they have been fully implemented.
68+
#
69+
# - `save_current_session_to_file(filepath: str)`: (Placeholder) Manually saves the state
70+
# - of the *current* session to a JSON file (default: ./.manual_agent_memory.json).
71+
# - Useful if you need to explicitly persist the current context for later use
72+
# - outside the standard memory service.
73+
#
74+
# - `load_memory_from_file(query: str, filepath: str)`: (Placeholder) Manually loads
75+
# - sessions from a JSON file (default: ./.manual_agent_memory.json) and searches
76+
# - them based on the query. Use this *instead* of `load_memory` if specifically
77+
# - instructed to load from the manual file.
78+
# --- End Placeholder ---
79+
5480
## Other Tools:
5581
- If you cannot delegate the request to a sub-agent, or if the query is about a general topic you don't know, use the `google_search_grounding` tool to find the information.
5682

0 commit comments

Comments
 (0)