From e596319492fa2a7ac47866fb2ed7f1c0ea10c4d4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 00:28:29 +0200 Subject: [PATCH] Possibility to load modes and contexts by name from $HOME/.serena --- src/serena/agent.py | 4 +-- src/serena/config/context_mode.py | 55 ++++++++++++++++++++++-------- src/serena/config/serena_config.py | 5 +-- src/serena/constants.py | 17 +++++++-- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 593223fd..ece6fdcf 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -21,7 +21,7 @@ from sensai.util.logging import LogTime from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolSet, get_serena_managed_dir +from serena.config.serena_config import SerenaConfig, ToolSet, get_serena_managed_in_project_dir from serena.constants import ( SERENA_LOG_FORMAT, ) @@ -62,7 +62,7 @@ class LinesRead: class MemoriesManager: def __init__(self, project_root: str): - self._memory_dir = Path(get_serena_managed_dir(project_root)) / "memories" + self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" self._memory_dir.mkdir(parents=True, exist_ok=True) def _get_memory_file_path(self, name: str) -> Path: diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index dfd690aa..fcd5790d 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -14,7 +14,15 @@ from sensai.util import logging from sensai.util.string import ToStringMixin from serena.config.serena_config import ToolInclusionDefinition -from serena.constants import CONTEXT_YAMLS_DIR, DEFAULT_CONTEXT, DEFAULT_MODES, INTERNAL_MODE_YAMLS_DIR, MODE_YAMLS_DIR +from serena.constants import ( + DEFAULT_CONTEXT, + DEFAULT_MODES, + INTERNAL_MODE_YAMLS_DIR, + SERENAS_OWN_CONTEXT_YAMLS_DIR, + SERENAS_OWN_MODE_YAMLS_DIR, + USER_CONTEXT_YAMLS_DIR, + USER_MODE_YAMLS_DIR, +) if TYPE_CHECKING: pass @@ -64,13 +72,19 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena mode.""" - yaml_path = os.path.join(MODE_YAMLS_DIR, f"{name}.yml") - if not os.path.exists(yaml_path): + fname = f"{name}.yml" + custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) + if os.path.exists(custom_mode_path): + log.info(f"Loading custom mode {name} from {custom_mode_path}") + return cls.from_yaml(custom_mode_path) + + own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) + if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " + f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " f"Available modes: {cls.list_registered_mode_names()}" ) - return cls.from_yaml(yaml_path) + return cls.from_yaml(own_yaml_path) @classmethod def from_name_internal(cls, name: str) -> Self: @@ -81,9 +95,12 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): return cls.from_yaml(yaml_path) @classmethod - def list_registered_mode_names(cls) -> list[str]: + def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: """Names of all registered modes (from the corresponding YAML files in the serena repo).""" - return sorted([f.stem for f in Path(MODE_YAMLS_DIR).glob("*.yml")]) + modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml")] + if include_user_modes: + modes += [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + return sorted(set(modes)) @classmethod def load_default_modes(cls) -> list[Self]: @@ -134,13 +151,20 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena context.""" - yaml_path = os.path.join(CONTEXT_YAMLS_DIR, f"{name}.yml") - if not os.path.exists(yaml_path): + fname = f"{name}.yml" + custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) + if os.path.exists(custom_context_path): + log.info(f"Loading custom context {name} from {custom_context_path}") + return cls.from_yaml(custom_context_path) + + own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) + if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Context {Path(yaml_path).stem} not found in {CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" + f"Context {Path(own_yaml_path).stem} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. " + f"You can load a custom context by using from_yaml() instead.\n" f"Available contexts:\n{cls.list_registered_context_names()}" ) - return cls.from_yaml(yaml_path) + return cls.from_yaml(own_yaml_path) @classmethod def load(cls, name_or_path: str | Path) -> Self: @@ -151,14 +175,17 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): return cls.from_yaml(name_or_path) except FileNotFoundError as e: raise FileNotFoundError( - f"Context {name_or_path} not found in {CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" + f"Context {name_or_path} not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" f"Available contexts:\n{cls.list_registered_context_names()}" ) from e @classmethod - def list_registered_context_names(cls) -> list[str]: + def list_registered_context_names(cls, include_user_contexts: bool = True) -> list[str]: """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" - return sorted([f.stem for f in Path(CONTEXT_YAMLS_DIR).glob("*.yml")]) + contexts = [f.stem for f in Path(SERENAS_OWN_CONTEXT_YAMLS_DIR).glob("*.yml")] + if include_user_contexts: + contexts += [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + return sorted(set(contexts)) @classmethod def load_default(cls) -> Self: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index f202e645..4fb30eaa 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -21,6 +21,7 @@ from serena.constants import ( PROJECT_TEMPLATE_FILE, REPO_ROOT, SELENA_CONFIG_TEMPLATE_FILE, + SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME, ) from serena.util.general import load_yaml, save_yaml @@ -113,7 +114,7 @@ class SerenaConfigError(Exception): pass -def get_serena_managed_dir(project_root: str | Path) -> str: +def get_serena_managed_in_project_dir(project_root: str | Path) -> str: return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) @@ -293,7 +294,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if is_running_in_docker(): return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) else: - config_path = str(Path.home() / SERENA_MANAGED_DIR_NAME / cls.CONFIG_FILE) + config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE) # if the config file does not exist, check if we can migrate it from the old location if not os.path.exists(config_path): diff --git a/src/serena/constants.py b/src/serena/constants.py index 668c2f90..4ed24bbb 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -3,15 +3,26 @@ from pathlib import Path _repo_root_path = Path(__file__).parent.parent.parent.resolve() _serena_pkg_path = Path(__file__).parent.resolve() +SERENA_MANAGED_DIR_NAME = ".serena" +_serena_in_home_managed_dir = Path.home() / ".serena" + +SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) + REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") -CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") -MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") +SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") +"""The contexts that are shipped with the Serena package, i.e. the default contexts.""" +USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") +"""Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one.""" +SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") +"""The modes that are shipped with the Serena package, i.e. the default modes.""" +USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes") +"""Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one.""" INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") +"""Internal modes, never overridden by user modes.""" SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") SERENA_ICON_DIR = str(_serena_pkg_path / "resources" / "icons") -SERENA_MANAGED_DIR_NAME = ".serena" DEFAULT_ENCODING = "utf-8" DEFAULT_CONTEXT = "desktop-app"