diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index f1f34fd9..9238d806 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -41,6 +41,11 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): It is formatted by the agent (see SerenaAgent._format_prompt()). """ description: str = "" + _yaml_path: Path | None = field(default=None, repr=False, compare=False) + """ + Internal field storing the path to the YAML file this mode was loaded from. + Used to support loading modes from arbitrary file paths. + """ def _tostring_includes(self) -> list[str]: return ["name"] @@ -54,14 +59,24 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a mode from a YAML file.""" - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: + yaml_as_path = Path(yaml_path).resolve() + with Path(yaml_as_path).open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) - name = data.pop("name", Path(yaml_path).stem) - return cls(name=name, **data) + name = data.pop("name", yaml_as_path.stem) + return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod - def get_path(cls, name: str) -> str: - """Get the path to the YAML file for a mode.""" + def get_path(cls, name: str, instance: Self | None = None) -> str: + """Get the path to the YAML file for a mode. + + :param name: The name of the mode + :param instance: Optional mode instance. If provided and it has a stored path, that path is returned. + :return: The path to the mode's YAML file + """ + # If we have an instance with a stored path, use that + if instance is not None and instance._yaml_path is not None: + return str(instance._yaml_path) + fname = f"{name}.yml" custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname) if os.path.exists(custom_mode_path): @@ -109,8 +124,16 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - if str(name_or_path).endswith(".yml"): + # Check if it's a file path that exists + path = Path(name_or_path) + if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) + + # If it looks like a file path but doesn't exist, raise FileNotFoundError + name_or_path_str = str(name_or_path) + if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): + raise FileNotFoundError(f"Mode file not found: {path.resolve()}") + return cls.from_name(str(name_or_path)) @@ -137,6 +160,12 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings. """ + _yaml_path: Path | None = field(default=None, repr=False, compare=False) + """ + Internal field storing the path to the YAML file this context was loaded from. + Used to support loading contexts from arbitrary file paths. + """ + single_project: bool = False """ whether to assume that Serena shall only work on a single project in this context (provided that a project is given @@ -152,17 +181,27 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a context from a YAML file.""" - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: + yaml_as_path = Path(yaml_path).resolve() + with yaml_as_path.open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) - name = data.pop("name", Path(yaml_path).stem) + name = data.pop("name", yaml_as_path.stem) # Ensure backwards compatibility for tool_description_overrides if "tool_description_overrides" not in data: data["tool_description_overrides"] = {} - return cls(name=name, **data) + return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod - def get_path(cls, name: str) -> str: - """Get the path to the YAML file for a context.""" + def get_path(cls, name: str, instance: Self | None = None) -> str: + """Get the path to the YAML file for a context. + + :param name: The name of the context + :param instance: Optional context instance. If provided and it has a stored path, that path is returned. + :return: The path to the context's YAML file + """ + # If we have an instance with a stored path, use that + if instance is not None and instance._yaml_path is not None: + return str(instance._yaml_path) + fname = f"{name}.yml" custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname) if os.path.exists(custom_context_path): @@ -194,8 +233,16 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - if str(name_or_path).endswith(".yml"): + # Check if it's a file path that exists + path = Path(name_or_path) + if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) + + # If it looks like a file path but doesn't exist, raise FileNotFoundError + name_or_path_str = str(name_or_path) + if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): + raise FileNotFoundError(f"Context file not found: {path.resolve()}") + return cls.from_name(str(name_or_path)) @classmethod diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 55d8b87b..1d937b73 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -354,12 +354,15 @@ class SerenaDashboardAPI: context_info = { "name": context.name, "description": context.description, - "path": SerenaAgentContext.get_path(context.name), + "path": SerenaAgentContext.get_path(context.name, instance=context), } # Get active modes modes = self._agent.get_active_modes() - modes_info = [{"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name)} for mode in modes] + modes_info = [ + {"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name, instance=mode)} + for mode in modes + ] active_mode_names = [mode.name for mode in modes] # Get active tools @@ -392,11 +395,16 @@ class SerenaDashboardAPI: all_mode_names = SerenaAgentMode.list_registered_mode_names() available_modes: list[dict[str, str | bool]] = [] for mode_name in all_mode_names: + try: + mode_path = SerenaAgentMode.get_path(mode_name) + except FileNotFoundError: + # Skip modes that can't be found (shouldn't happen for registered modes) + continue available_modes.append( { "name": mode_name, "is_active": mode_name in active_mode_names, - "path": SerenaAgentMode.get_path(mode_name), + "path": mode_path, } ) @@ -404,11 +412,16 @@ class SerenaDashboardAPI: all_context_names = SerenaAgentContext.list_registered_context_names() available_contexts: list[dict[str, str | bool]] = [] for context_name in all_context_names: + try: + context_path = SerenaAgentContext.get_path(context_name) + except FileNotFoundError: + # Skip contexts that can't be found (shouldn't happen for registered contexts) + continue available_contexts.append( { "name": context_name, "is_active": context_name == context.name, - "path": SerenaAgentContext.get_path(context_name), + "path": context_path, } )