From 89340ef19733ab243883d56c275524dc881498d8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 17 Oct 2025 13:40:57 +0200 Subject: [PATCH] Fix project-specific information (prompt, encoding, etc.) not being provided when the project is activated at startup (ActivateProjectTool not applied) Peripheral change: Move MemoriesManager into module 'project', moving the instance into the Project instance, because memories are strictly associated with the project Fixes #681 --- src/serena/agent.py | 47 ++++-------------------- src/serena/project.py | 61 ++++++++++++++++++++++++++++++-- src/serena/tools/config_tools.py | 24 ++----------- src/serena/tools/tools_base.py | 7 ++-- 4 files changed, 70 insertions(+), 69 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 9358db1d..3c15b8fb 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,7 +11,6 @@ import webbrowser from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor from logging import Logger -from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, TypeVar from sensai.util import logging @@ -21,8 +20,7 @@ from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet, get_serena_managed_in_project_dir -from serena.constants import SERENA_FILE_ENCODING +from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet from serena.dashboard import SerenaDashboardAPI from serena.project import Project from serena.prompt_factory import SerenaPromptFactory @@ -44,40 +42,6 @@ class ProjectNotFoundError(Exception): pass -class MemoriesManager: - def __init__(self, project_root: str): - self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" - self._memory_dir.mkdir(parents=True, exist_ok=True) - self._encoding = SERENA_FILE_ENCODING - - def _get_memory_file_path(self, name: str) -> Path: - # strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not. - name = name.replace(".md", "") - filename = f"{name}.md" - return self._memory_dir / filename - - def load_memory(self, name: str) -> str: - memory_file_path = self._get_memory_file_path(name) - if not memory_file_path.exists(): - return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." - with open(memory_file_path, encoding=self._encoding) as f: - return f.read() - - def save_memory(self, name: str, content: str) -> str: - memory_file_path = self._get_memory_file_path(name) - with open(memory_file_path, "w", encoding=self._encoding) as f: - f.write(content) - return f"Memory {name} written." - - def list_memories(self) -> list[str]: - return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()] - - def delete_memory(self, name: str) -> str: - memory_file_path = self._get_memory_file_path(name) - memory_file_path.unlink() - return f"Memory {name} deleted." - - class AvailableTools: def __init__(self, tools: list[Tool]): """ @@ -123,7 +87,6 @@ class SerenaAgent: # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None self.language_server: SolidLanguageServer | None = None - self.memories_manager: MemoriesManager | None = None # adjust log level serena_log_level = self.serena_config.log_level @@ -361,6 +324,11 @@ class SerenaAgent: available_tools=self._exposed_tools.tool_names, available_markers=available_markers, ) + + # If a project is active at startup, append its activation message + if self._active_project is not None: + system_prompt += "\n\n" + self._active_project.get_activation_message() + log.info("System prompt:\n%s", system_prompt) return system_prompt @@ -426,9 +394,6 @@ class SerenaAgent: self._active_project = project self._update_active_tools() - # initialize project-specific instances which do not depend on the language server - self.memories_manager = MemoriesManager(project.project_root) - def init_language_server() -> None: # start the language server with LogTime("Language server initialization", logger=log): diff --git a/src/serena/project.py b/src/serena/project.py index b326f5db..4a0fae8c 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,3 +1,4 @@ +import json import logging import os from pathlib import Path @@ -5,8 +6,8 @@ from typing import Any import pathspec -from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig -from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir +from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path from solidlsp import SolidLanguageServer @@ -17,11 +18,46 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +class MemoriesManager: + def __init__(self, project_root: str): + self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" + self._memory_dir.mkdir(parents=True, exist_ok=True) + self._encoding = SERENA_FILE_ENCODING + + def _get_memory_file_path(self, name: str) -> Path: + # strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not. + name = name.replace(".md", "") + filename = f"{name}.md" + return self._memory_dir / filename + + def load_memory(self, name: str) -> str: + memory_file_path = self._get_memory_file_path(name) + if not memory_file_path.exists(): + return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." + with open(memory_file_path, encoding=self._encoding) as f: + return f.read() + + def save_memory(self, name: str, content: str) -> str: + memory_file_path = self._get_memory_file_path(name) + with open(memory_file_path, "w", encoding=self._encoding) as f: + f.write(content) + return f"Memory {name} written." + + def list_memories(self) -> list[str]: + return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()] + + def delete_memory(self, name: str) -> str: + memory_file_path = self._get_memory_file_path(name) + memory_file_path.unlink() + return f"Memory {name} deleted." + + class Project: def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): self.project_root = project_root self.project_config = project_config - self.is_newly_created = is_newly_created + self.memories_manager = MemoriesManager(project_root) + self._is_newly_created = is_newly_created # create .gitignore file in the project's Serena data folder if not yet present serena_data_gitignore_path = os.path.join(self.path_to_serena_data_folder(), ".gitignore") @@ -75,6 +111,25 @@ class Project: def path_to_project_yml(self) -> str: return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) + def get_activation_message(self) -> str: + """ + :return: a message providing information about the project upon activation (e.g. programming language, memories, initial prompt) + """ + if self._is_newly_created: + msg = f"Created and activated a new project with name '{self.project_name}' at {self.project_root}. " + else: + msg = f"The project with name '{self.project_name}' at {self.project_root} is activated." + msg += f"\nProgramming language: {self.project_config.language.value}; file encoding: {self.project_config.encoding}" + memories = self.memories_manager.list_memories() + if memories: + msg += ( + f"\nAvailable project memories: {json.dumps(memories)}\n" + + "Use the `read_memory` tool to read these memories later if they are relevant to the task." + ) + if self.project_config.initial_prompt: + msg += f"\nAdditional project-specific instructions:\n {self.project_config.initial_prompt}" + return msg + def read_file(self, relative_path: str) -> str: """ Reads a file relative to the project root. diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 2110e33f..bc856ec5 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -1,38 +1,20 @@ -import json - from serena.config.context_mode import SerenaAgentMode from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ - Activates a project by name. + Activates a project based on the project name or path. """ def apply(self, project: str) -> str: """ - Activates the project with the given name. + Activates the project with the given name or path. :param project: the name of a registered project to activate or a path to a project directory """ active_project = self.agent.activate_project_from_path_or_name(project) - if active_project.is_newly_created: - result_str = ( - f"Created and activated a new project with name '{active_project.project_name}' at {active_project.project_root}, language: {active_project.project_config.language.value}. " - "You can activate this project later by name.\n" - f"The project's Serena configuration is in {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." - ) - else: - result_str = f"Activated existing project with name '{active_project.project_name}' at {active_project.project_root}, language: {active_project.project_config.language.value}" - - if active_project.project_config.initial_prompt: - result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}" - result_str += ( - f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}" - + "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task." - ) - result_str += f"\nAvailable tools:\n {json.dumps(self.agent.get_active_tool_names())}" - return result_str + return active_project.get_activation_message() class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 3f2d2c25..74c53322 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -10,7 +10,7 @@ from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metada from sensai.util import logging from sensai.util.string import dict_string -from serena.project import Project +from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.symbol import LanguageServerSymbolRetriever from serena.util.class_decorators import singleton @@ -18,7 +18,7 @@ from serena.util.inspection import iter_subclasses from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: - from serena.agent import MemoriesManager, SerenaAgent + from serena.agent import SerenaAgent from serena.code_editor import CodeEditor log = logging.getLogger(__name__) @@ -42,8 +42,7 @@ class Component(ABC): @property def memories_manager(self) -> "MemoriesManager": - assert self.agent.memories_manager is not None - return self.agent.memories_manager + return self.project.memories_manager def create_language_server_symbol_retriever(self) -> LanguageServerSymbolRetriever: if not self.agent.is_using_language_server():