diff --git a/.serena/memories/repl.md b/.serena/memories/repl.md index ee88a232..8172b170 100644 --- a/.serena/memories/repl.md +++ b/.serena/memories/repl.md @@ -53,5 +53,7 @@ Code runs as a function body (`return` defines the result); a single expression - Python code can always modify the system; the REPL tool is inherently fully privileged, regardless of facade scope or the project's `read_only` setting (which only makes Serena's own API refuse edits). A "read-only REPL" is not feasible and must not be promised. -- Session/project management (activate_project, initial_instructions, dashboard, ...) stays tool-only; - facades expose operations on the active project. +- Project activation (activate_project) and initial_instructions stay tool-only (activation rebuilds the REPL); + Serena's configuration/session state (config overview, dashboard; later e.g. modes) lives in the `cfg` facade. + Computed conditions (read-only project, dashboard not openable) are applied to the API scope in + `SerenaAgent.get_repl` via `exclude_editing()`/`NamedApiInclusionDefinition`, mirroring the tool side. diff --git a/src/serena/agent.py b/src/serena/agent.py index 3034d77d..eba6ae85 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -36,6 +36,7 @@ from serena.config.serena_config import ( ModeSelectionDefinition, ModeSelectionDefinitionWithAddedModes, ModeSelectionDefinitionWithBaseModes, + NamedApiInclusionDefinition, NamedToolInclusionDefinition, RegisteredProject, SerenaConfig, @@ -48,6 +49,7 @@ from serena.ls_manager import LanguageServerManager from serena.memories.memory_manager import MemoryManager from serena.project import Project from serena.prompt_factory import SerenaPromptFactory +from serena.repl.api.cfg_api import ConfigApi from serena.repl.api.edit_api import EditApi from serena.repl.api.fs_api import FsApi from serena.repl.api.jb_api import JetBrainsApi @@ -760,6 +762,15 @@ class SerenaAgent: except Exception as e: log.debug(f"Failed to send usage info: {e}") + @staticmethod + def _is_dashboard_openable(serena_config: SerenaConfig) -> bool: + """ + :param serena_config: the configuration + :return: whether the web dashboard is available and opening it is a meaningful operation + (i.e. it is enabled and not opened automatically) + """ + return serena_config.web_dashboard and not serena_config.web_dashboard_open_on_launch and not serena_config.gui_log_window + @classmethod def _create_base_toolset( cls, @@ -789,7 +800,7 @@ class SerenaAgent: # determine whether to include the OpenDashboardTool based on the Serena configuration tool_inclusion_definitions: list[ToolInclusionDefinition] = [] - if serena_config.web_dashboard and not serena_config.web_dashboard_open_on_launch and not serena_config.gui_log_window: + if cls._is_dashboard_openable(serena_config): tool_inclusion_definitions.append( NamedToolInclusionDefinition(name="OpenDashboard", included_optional_tools=[OpenDashboardTool.get_name_from_cls()]) ) @@ -1281,9 +1292,12 @@ class SerenaAgent: api_scope.process(self._active_project.project_config) if self._active_project.project_config.read_only: api_scope.exclude_editing() + if not self._is_dashboard_openable(self.serena_config): + api_scope.process(NamedApiInclusionDefinition(name="Dashboard", excluded_apis=["cfg.open_dashboard"])) # gather facades facades = [ + Facade.from_api(ConfigApi(self), api_scope), Facade.from_api(FsApi(self), api_scope), Facade.from_api(EditApi(self), api_scope), Facade.from_api(MemoryApi(self), api_scope), diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 3f2d5aab..c7809f57 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -190,6 +190,14 @@ class ApiInclusionDefinition: excluded_apis: Sequence[str] = () +@dataclass +class NamedApiInclusionDefinition(ApiInclusionDefinition): + name: str | None = None + + def __str__(self) -> str: + return f"ApiInclusionDefinition[{self.name}]" + + @dataclass class ModeSelectionDefinition: default_modes: Sequence[str] | None = None diff --git a/src/serena/repl/api/cfg_api.py b/src/serena/repl/api/cfg_api.py new file mode 100644 index 00000000..61053727 --- /dev/null +++ b/src/serena/repl/api/cfg_api.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +""" +The implementation of operations concerning Serena's configuration and session state. +""" + +from typing import TYPE_CHECKING + +from serena.tools import GetCurrentConfigTool, OpenDashboardTool + +from ..facade import FacadeApi, facade_method + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + + +class ConfigApi(FacadeApi): + def __init__(self, agent: "SerenaAgent") -> None: + super().__init__(agent, name="cfg", description="Serena's configuration and session state (incl. the dashboard)") + + @facade_method(corresponding_tool=GetCurrentConfigTool) + def get_current_config(self) -> str: + """ + Provides the current configuration of the agent, including the active and available projects, tools, contexts, and modes. + + :return: the configuration overview + """ + return self._agent.get_current_config_overview() + + @facade_method(corresponding_tool=OpenDashboardTool) + def open_dashboard(self) -> str: + """ + Opens the Serena web dashboard in the default web browser. + The dashboard provides logs, session information, and tool usage statistics. + + :return: a message indicating whether the dashboard could be opened + """ + if self._agent.open_dashboard(): + return f"Serena web dashboard has been opened in the user's default web browser: {self._agent.get_dashboard_url()}" + else: + return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self._agent.get_dashboard_url()}" diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index aab6b13e..5d8769bc 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -1,11 +1,29 @@ # SPDX-License-Identifier: GPL-3.0-or-later +from typing import TYPE_CHECKING, cast + from sensai.util.helper import mark_used from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional +if TYPE_CHECKING: + from serena.repl.api.cfg_api import ConfigApi -class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + +class ConfigApiMixin: + """ + Mixin for tools which delegate to the configuration API. + The API is imported locally, since the API module refers to the tools (as corresponding tools). + """ + + def _api(self) -> "ConfigApi": + from serena.repl.api.cfg_api import ConfigApi + + tool = cast(Tool, cast(object, self)) + return ConfigApi(tool.agent) + + +class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject, ConfigApiMixin): """ Opens the Serena web dashboard in the default web browser. The dashboard provides logs, session information, and tool usage statistics. @@ -15,10 +33,7 @@ class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActive """ Opens the Serena web dashboard in the default web browser. """ - if self.agent.open_dashboard(): - return f"Serena web dashboard has been opened in the user's default web browser: {self.agent.get_dashboard_url()}" - else: - return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self.agent.get_dashboard_url()}" + return self._api().open_dashboard() class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): @@ -56,7 +71,7 @@ class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerO return f"Successfully removed project '{project_name}' from configuration." -class GetCurrentConfigTool(Tool): +class GetCurrentConfigTool(Tool, ConfigApiMixin): """ Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. """ @@ -65,4 +80,4 @@ class GetCurrentConfigTool(Tool): """ Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. """ - return self.agent.get_current_config_overview() + return self._api().get_current_config() diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index cdfa91c5..33fdb60a 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -268,7 +268,10 @@ class TestAgentInterface: fixed_toolset = {"serena_repl", "initial_instructions", "activate_project"} assert {t.get_name() for t in agent.get_exposed_tool_instances()} == fixed_toolset assert set(agent.get_active_tool_names()) == fixed_toolset - assert "s.mem" in agent.get_repl().entrypoint.overview() + overview = agent.get_repl().entrypoint.overview() + assert "s.mem" in overview + # the dashboard is disabled in the test configuration, so opening it is not offered + assert "s.cfg" in overview and "open_dashboard" not in overview # after activation: the toolset is unchanged, the REPL reflects the project's API exclusions agent.activate_project_from_path_or_name(name) diff --git a/test/serena/test_cfg_api.py b/test/serena/test_cfg_api.py new file mode 100644 index 00000000..46ea3cbd --- /dev/null +++ b/test/serena/test_cfg_api.py @@ -0,0 +1,25 @@ +""" +Tests for the configuration facade API. +""" + +from unittest.mock import MagicMock + +from serena.repl.api.cfg_api import ConfigApi +from serena.repl.facade import ApiScope, Facade + + +def test_facade_exposes_config_operations() -> None: + facade = Facade.from_api(ConfigApi(MagicMock()), ApiScope()) + assert facade.name == "cfg" + assert set(facade.enabled_method_names) == {"get_current_config", "open_dashboard"} + assert not any(facade.get_method(name).info.can_edit for name in facade.enabled_method_names) + + +def test_operations_delegate_to_agent() -> None: + agent = MagicMock() + agent.get_current_config_overview.return_value = "overview" + agent.open_dashboard.return_value = False + agent.get_dashboard_url.return_value = "http://localhost:1" + api = ConfigApi(agent) + assert api.get_current_config() == "overview" + assert "http://localhost:1" in api.open_dashboard()