Merge pull request #1135 from oraios/read_only_memories

Feature: configure read-only memories through regex patterns
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-03-07 00:41:41 +01:00
commit 66cbe31045
10 files changed
+105 -52

No files matched your search

+7 -4
View File
@@ -96,17 +96,16 @@ initial_prompt: |
rather than mere functions (i.e. use the strategy pattern, for example).
You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate.
For simple data storage, you use dataclasses instead of dictionaries or tuples.
You structure function implementations into functional blocks that are separated by blank lines.
Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the
block in a concise manner.
Docstrings: You consistently use reStructuredText.
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: utf-8
@@ -143,3 +142,7 @@ symbol_info_budget:
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
+3 -2
View File
@@ -31,8 +31,9 @@ Global memories use the top-level topic `global`, i.e. whenever a memory name st
it is stored in the global memories directory and is shared across all projects.
By default, deletion and editing of global memories is allowed.
If you want to primarily manage such memories yourself and protect them from accidental modification by the agent,
set `edit_global_memories: False` in Serena's [global configuration](050_configuration).
If you want to protect them from accidental modification by the agent,
you can add regex patterns to `read_only_memory_patterns` in your global or
project-level [configuration](050_configuration). For example, setting "global/.*" will mark all global memories as read-only. The agent will be informed which memories are read-only.
Since global memories are not versioned alongside your project files,
it can be helpful to track global memories with git (i.e. to make `~/.serena/memories/` a git repository)
+6 -4
View File
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar
from sensai.util import logging
from sensai.util.logging import LogTime
from sensai.util.string import TextBuilder, list_string
from sensai.util.string import TextBuilder, dict_string
from interprompt.jinja_template import JinjaTemplate
from serena import serena_version
@@ -603,15 +603,17 @@ class SerenaAgent:
def create_system_prompt(self) -> str:
available_tools = self._active_tools
available_markers = available_tools.tool_marker_names
global_memory_names = MemoriesManager.list_global_memories()
global_memories_list = list_string(global_memory_names) if global_memory_names else ""
global_memories = MemoriesManager(
serena_data_folder=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns
).list_global_memories()
global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 else ""
log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers)
system_prompt = self.prompt_factory.create_system_prompt(
context_system_prompt=self._format_prompt(self._context.prompt),
mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()],
available_tools=available_tools.tool_names,
available_markers=available_markers,
global_memories_list=global_memories_list,
global_memories_list=global_memories_str,
)
# If a project is active at startup, append its activation message
+10 -3
View File
@@ -191,6 +191,7 @@ class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMix
symbol_info_budget: float | None = None
language_backend: LanguageBackend | None = None
read_only_memory_patterns: list[str] = field(default_factory=list)
class SerenaConfigError(Exception):
@@ -395,6 +396,7 @@ class ProjectConfig(SharedConfig):
fixed_tools=data["fixed_tools"],
included_optional_tools=data["included_optional_tools"],
read_only=data["read_only"],
read_only_memory_patterns=data.get("read_only_memory_patterns", []),
ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"],
initial_prompt=data["initial_prompt"],
encoding=data["encoding"],
@@ -579,9 +581,6 @@ class SerenaConfig(SharedConfig):
"""List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **.
These patterns are merged additively with each project's own ignored_paths."""
edit_global_memories: bool = True
"""Whether global memories are allowed to be deleted or edited."""
project_serena_folder_location: str = DEFAULT_PROJECT_SERENA_FOLDER_LOCATION
"""
Template for the location of the per-project .serena data folder (memories, caches, etc.).
@@ -754,6 +753,14 @@ class SerenaConfig(SharedConfig):
instance.log_level = loaded_commented_yaml["gui_log_level"]
del loaded_commented_yaml["gui_log_level"]
# migrate "edit_global_memories"
if "edit_global_memories" in loaded_commented_yaml:
num_migrations += 1
edit_global_memories = loaded_commented_yaml["edit_global_memories"]
if not edit_global_memories:
instance.read_only_memory_patterns.append("global/.*")
del loaded_commented_yaml["edit_global_memories"]
# re-save the configuration file if any migrations were performed
if num_migrations > 0:
log.info("Legacy configuration was migrated; re-saving configuration file")
+1 -1
View File
@@ -501,7 +501,7 @@ class SerenaDashboardAPI:
# Get available memories if ReadMemoryTool is active
available_memories = None
if self._agent.tool_is_active("read_memory") and project is not None:
available_memories = project.memories_manager.list_memories()
available_memories = project.memories_manager.list_memories().get_full_list()
# Get list of languages for the active project
languages = []
+67 -27
View File
@@ -1,8 +1,10 @@
import json
import logging
import os
import re
import shutil
import threading
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Literal
@@ -30,15 +32,23 @@ class MemoriesManager:
GLOBAL_TOPIC = "global"
_global_memory_dir = SerenaPaths().global_memories_path
def __init__(self, serena_data_folder: str | Path, global_memory_tool_write_access: bool = False):
def __init__(self, serena_data_folder: str | Path | None, read_only_memory_patterns: Sequence[str] = ()):
"""
:param serena_data_folder: the absolute path to the project's .serena data folder
:param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts
:param read_only_memory_patterns: whether to allow writing global memories in tool execution contexts
"""
self._project_memory_dir = Path(serena_data_folder) / "memories"
self._project_memory_dir.mkdir(parents=True, exist_ok=True)
self._global_memory_tool_write_access = global_memory_tool_write_access
self._project_memory_dir: Path | None = None
if serena_data_folder is not None:
self._project_memory_dir = Path(serena_data_folder) / "memories"
self._project_memory_dir.mkdir(parents=True, exist_ok=True)
self._encoding = SERENA_FILE_ENCODING
self._read_only_memory_patterns = [re.compile(pattern) for pattern in set(read_only_memory_patterns)]
def _is_read_only_memory(self, name: str) -> bool:
for pattern in self._read_only_memory_patterns:
if pattern.fullmatch(name):
return True
return False
def _is_global(self, name: str) -> bool:
return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/")
@@ -64,6 +74,7 @@ class MemoriesManager:
return self._global_memory_dir / filename
# Project-local memory
assert self._project_memory_dir is not None, "Project dir was not passed at initialization"
parts = name.split("/")
filename = f"{parts[-1]}.md"
@@ -76,10 +87,9 @@ class MemoriesManager:
return self._project_memory_dir / filename
def _check_write_access(self, name: str, is_tool_context: bool) -> None:
# in tool context, global memory write access can be disabled
if is_tool_context:
if self._is_global(name) and not self._global_memory_tool_write_access:
raise PermissionError(f"Writing to global memories is not allowed (attempted to write to '{name}')")
# in tool context, memories can be read-only
if is_tool_context and self._is_read_only_memory(name):
raise PermissionError(f"Attempted to write to read_only memory: '{name}')")
def load_memory(self, name: str) -> str:
memory_file_path = self.get_memory_file_path(name)
@@ -95,35 +105,64 @@ class MemoriesManager:
f.write(content)
return f"Memory {name} written."
@staticmethod
def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]:
class MemoriesList:
def __init__(self) -> None:
self.memories: list[str] = []
self.read_only_memories: list[str] = []
def __len__(self) -> int:
return len(self.memories) + len(self.read_only_memories)
def add(self, memory_name: str, is_read_only: bool) -> None:
if is_read_only:
self.read_only_memories.append(memory_name)
else:
self.memories.append(memory_name)
def extend(self, other: "MemoriesManager.MemoriesList") -> None:
self.memories.extend(other.memories)
self.read_only_memories.extend(other.read_only_memories)
def to_dict(self) -> dict[str, list[str]]:
result = {}
if self.memories:
result["memories"] = sorted(self.memories)
if self.read_only_memories:
result["read_only_memories"] = sorted(self.read_only_memories)
return result
def get_full_list(self) -> list[str]:
return sorted(self.memories + self.read_only_memories)
def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> MemoriesList:
result = self.MemoriesList()
if not search_dir.exists():
return []
results = []
return result
for md_file in search_dir.rglob("*.md"):
rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/")
results.append(prefix + rel)
return results
memory_name = prefix + rel
result.add(memory_name, is_read_only=self._is_read_only_memory(memory_name))
return result
@classmethod
def list_global_memories(cls, subtopic: str = "") -> list[str]:
dir_path = cls._global_memory_dir
def list_global_memories(self, subtopic: str = "") -> MemoriesList:
dir_path = self._global_memory_dir
if subtopic:
dir_path = dir_path / subtopic.replace("/", os.sep)
return cls._list_memories(dir_path, cls._global_memory_dir, cls.GLOBAL_TOPIC + "/")
return self._list_memories(dir_path, self._global_memory_dir, self.GLOBAL_TOPIC + "/")
def list_project_memories(self, topic: str = "") -> list[str]:
def list_project_memories(self, topic: str = "") -> MemoriesList:
assert self._project_memory_dir is not None, "Project dir was not passed at initialization"
dir_path = self._project_memory_dir
if topic:
dir_path = dir_path / topic.replace("/", os.sep)
return self._list_memories(dir_path, self._project_memory_dir)
def list_memories(self, topic: str = "") -> list[str]:
def list_memories(self, topic: str = "") -> MemoriesList:
"""
Lists all memories, optionally filtered by topic.
If the topic is omitted, both global and project-specific memories are returned.
"""
memories: list[str]
memories: MemoriesManager.MemoriesList
if topic:
if self._is_global(topic):
@@ -133,9 +172,10 @@ class MemoriesManager:
else:
memories = self.list_project_memories(topic=topic)
else:
memories = self.list_project_memories() + self.list_global_memories()
memories = self.list_project_memories()
memories.extend(self.list_global_memories())
return sorted(memories)
return memories
def delete_memory(self, name: str, is_tool_context: bool) -> str:
self._check_write_access(name, is_tool_context)
@@ -206,8 +246,8 @@ class Project(ToStringMixin):
self._serena_data_folder = serena_config.get_project_serena_folder(self.project_root)
log.info("Serena project data folder: %s", self._serena_data_folder)
global_memory_write_access = serena_config.edit_global_memories if serena_config else False
self.memories_manager = MemoriesManager(self._serena_data_folder, global_memory_write_access)
read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns
self.memories_manager = MemoriesManager(self._serena_data_folder, read_only_memory_patterns=read_only_memory_patterns)
self.language_server_manager: LanguageServerManager | None = None
self._is_newly_created = is_newly_created
@@ -308,7 +348,7 @@ class Project(ToStringMixin):
project_memories = self.memories_manager.list_project_memories()
if project_memories:
msg += (
f"\nAvailable project memories: {json.dumps(project_memories)}\n"
f"\nAvailable project memories: {json.dumps(project_memories.to_dict())}\n"
+ "Use the `read_memory` tool to read these memories later if they are relevant to the task."
)
if self.project_config.initial_prompt:
@@ -123,3 +123,7 @@ initial_prompt: ""
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
@@ -68,11 +68,10 @@ ls_specific_settings: {}
# These patterns are merged additively with each project's own ignored_paths.
ignored_paths: []
# whether global memories (the "global" topic) are allowed to be deleted or edited by the agent.
# Global memories are stored in ~/.serena/memories/global/ and shared across all projects.
# Set to false to protect global memories from accidental modification by agents.
# Note: We recommend tracking global memories with git.
edit_global_memories: True
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# For example, "global/.*" will mark all global memories as read-only.
# You can extend the list on a per-project basis in the project.yml configuration file.
read_only_memory_patterns: []
# timeout, in seconds, after which tool executions are terminated
tool_timeout: 240
+2 -5
View File
@@ -51,14 +51,11 @@ class ListMemoriesTool(Tool):
List available memories. Any memory can be read using the `read_memory` tool.
"""
def list_memories(self, topic: str = "") -> list[str]:
return self.memories_manager.list_memories(topic)
def apply(self, topic: str = "") -> str:
"""
List available memories, optionally filtered by topic.
Lists available memories, optionally filtered by topic.
"""
return self._to_json(self.memories_manager.list_memories(topic))
return self._to_json(self.memories_manager.list_memories(topic).to_dict())
class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
+1 -1
View File
@@ -519,4 +519,4 @@ class TestMemoriesManagerCustomPath:
manager.save_memory("topic_a", "content a", is_tool_context=False)
manager.save_memory("topic_b", "content b", is_tool_context=False)
memories = manager.list_project_memories()
assert sorted(memories) == ["topic_a", "topic_b"]
assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"]