From 337d545c4b4c0b7e5560b99869a34517c9efdda2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 7 Sep 2026 19:44:47 +0200 Subject: [PATCH] Add agent_interface setting selecting the tool or REPL interface Introduce AgentInterface (tools/REPL) as a setting in the global configuration, overridable per project (resolution: project > global > default "tools") and via the CLI option --agent-interface. The interface is fixed for the session, like the language backend. In REPL mode, the set of exposed tools is fixed: the REPL tool, initial_instructions and (unless in a single-project session) activate_project. Tool inclusion/exclusion definitions do not apply in REPL mode, as they pertain to the tool interface; the REPL is configured via API inclusions/exclusions. The REPL tool is optional in tool mode. SerenaAgent.is_single_project records whether the session is a single-project session. --- .serena/memories/repl.md | 7 ++ src/serena/agent.py | 69 +++++++++++++++---- src/serena/cli.py | 10 +++ src/serena/config/serena_config.py | 69 ++++++++++++++++++- src/serena/mcp.py | 6 +- src/serena/resources/project.template.yml | 5 ++ .../resources/serena_config.template.yml | 12 ++++ src/serena/tools/repl_tools.py | 4 +- test/serena/config/test_serena_config.py | 60 ++++++++++++++++ test/serena/test_serena_agent.py | 22 +++++- 10 files changed, 244 insertions(+), 20 deletions(-) diff --git a/.serena/memories/repl.md b/.serena/memories/repl.md index 2d8b7f59..ee88a232 100644 --- a/.serena/memories/repl.md +++ b/.serena/memories/repl.md @@ -34,6 +34,13 @@ Code runs as a function body (`return` defines the result); a single expression ## Configuration +- `agent_interface: tools | REPL` (`AgentInterface`; global config, overridable per project; CLI `--agent-interface`). + `None` = Serena's default (`tools`). Fixed for the session. In REPL mode the toolset is *fixed* + (`serena_repl`, `initial_instructions`, `activate_project` unless single-project); tool inclusion/exclusion + definitions do not apply — each interface has its own configuration vocabulary (tool definitions ↔ tools, + API definitions ↔ REPL). Contexts do not influence the interface. + Idea (not implemented, considered over-engineered for now): contexts could declare *supported* interfaces + (a capability constraint, e.g. clients that handle the REPL badly), with the user's preference choosing among them. - `included_apis`/`excluded_apis` (references `facade` or `facade.method`) in global config, context, modes, project config; applied in that order via `ApiScope` (exclusions first, then inclusions; later definitions win). Optional methods and all methods of an excluded facade must be included explicitly. diff --git a/src/serena/agent.py b/src/serena/agent.py index b2efef85..4446ae06 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -31,6 +31,7 @@ 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 ( + AgentInterface, LanguageBackend, ModeSelectionDefinition, ModeSelectionDefinitionWithAddedModes, @@ -59,10 +60,12 @@ from serena.task_executor import TaskExecutor from serena.tools import ( ActivateProjectTool, GetCurrentConfigTool, + InitialInstructionsTool, OnboardingTool, OpenDashboardTool, ReadMemoryTool, ReplaceContentTool, + SerenaReplTool, Tool, ToolMarker, ToolRegistry, @@ -660,6 +663,13 @@ class SerenaAgent: log_choice=True, ) + # determine the effective agent interface for this session (project configuration > global configuration). + # Like the language backend, it is fixed for the session, since the set of exposed tools cannot change after startup. + self._agent_interface = self.serena_config.determine_agent_interface( + project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None, + log_choice=True, + ) + # create the tool names mapping for prompts self._prompt_tool_names_mapping = self._create_prompt_tool_names_mapping(self._language_backend) @@ -685,8 +695,14 @@ class SerenaAgent: self._project_activation_error = str(e) self._update_active_modes() + # determine whether we are operating in a single-project session, i.e. the project that was activated at startup + # (if any) is the only project that will be worked with throughout the session (no project switching) + self._is_single_project = self._context.single_project and self._active_project is not None + # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), - self._base_toolset = self._create_base_toolset(self.serena_config, self._context, self._active_modes, self._active_project) + self._base_toolset = self._create_base_toolset( + self.serena_config, self._context, self._active_modes, self._active_project, self._agent_interface, self._is_single_project + ) self._exposed_tools = self._base_toolset.to_available_tools(self._all_tools) log.info(f"Number of exposed tools: {len(self._exposed_tools)}. Exposed tools: {self._exposed_tools.tool_names}") @@ -753,10 +769,12 @@ class SerenaAgent: context: SerenaAgentContext, modes: ActiveModes, project: Project | None, + agent_interface: AgentInterface, + is_single_project: bool, ) -> ToolSet: """ Determines the base toolset defining the set of exposed tools (which e.g. the MCP shall see). - It depends on ... + In REPL mode, the toolset is fixed. Otherwise, it depends on ... * dashboard availability/opening on launch * Serena config * the context (which is fixed for the session) @@ -764,6 +782,13 @@ class SerenaAgent: * the optional tools enabled by initial dynamic modes * single-project mode reductions (if applicable) """ + # when in REPL mode, the toolset is fixed and does not depend on the configuration, context, modes or project + if agent_interface.is_repl(): + tool_classes: list[type[Tool]] = [SerenaReplTool, InitialInstructionsTool] + if not is_single_project: + tool_classes.append(ActivateProjectTool) + return ToolSet({tool_class.get_name_from_cls() for tool_class in tool_classes}) + # 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: @@ -775,10 +800,6 @@ class SerenaAgent: tool_inclusion_definitions.append(serena_config) tool_inclusion_definitions.append(context) - # determine whether we are operating in a single-project context - # (i.e. the project that is activated at startup is the only project that will be worked with throughout the session) - is_single_project = context.single_project and project is not None - # consider modes # * base modes: These cannot be changed, so they are fully applied for base_mode in modes.get_base_modes(include_background_base_modes=True): @@ -830,6 +851,17 @@ class SerenaAgent: def get_language_backend(self) -> LanguageBackend: return self._language_backend + def is_single_project(self) -> bool: + """ + :return: whether this is a single-project session, i.e. the project activated at startup is the only project + that will be worked with throughout the session (no project switching); requires a single-project context + and a project at startup + """ + return self._is_single_project + + def get_agent_interface(self) -> AgentInterface: + return self._agent_interface + def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]: """ Gets the list of tasks currently running or queued for execution. @@ -1142,23 +1174,29 @@ class SerenaAgent: def _update_active_tools(self) -> None: """ - Updates the active tools based on the active modes and the active project. + Updates the active tools (and the REPL, which depends on the same configuration) based on the active modes + and the active project. Must be called whenever the active modes or the active project change. The base tool set already takes the Serena configuration and the context into account (as well as many other aspects, such as JetBrains mode). """ - # apply modes - tool_set = self._base_toolset.apply(*self._active_modes.get_modes()) + if self._agent_interface.is_repl(): + # the REPL toolset is fixed; tool inclusion/exclusion definitions do not apply + tool_set = self._base_toolset + else: + # apply modes + tool_set = self._base_toolset.apply(*self._active_modes.get_modes()) - # apply active project configuration (if any) - if self._active_project is not None: - tool_set = tool_set.apply(self._active_project.project_config) - if self._active_project.project_config.read_only: - tool_set = tool_set.without_editing_tools() + # apply active project configuration (if any) + if self._active_project is not None: + tool_set = tool_set.apply(self._active_project.project_config) + if self._active_project.project_config.read_only: + tool_set = tool_set.without_editing_tools() self._active_tools = tool_set.to_available_tools(self._all_tools) log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self._active_tools.tool_names)}") - # reset the REPL, which depends on the same configuration (it is re-created on demand) + # reset the REPL, whose facades/API scope depend on the active modes and project (it is re-created on demand). + # NOTE: This must happen irrespective of the agent interface, since the REPL tool may be active in tool mode as well. self._repl = None # check if a tool was activated that is not in the exposed tool set and issue a warning if so @@ -1435,6 +1473,7 @@ class SerenaAgent: result_str += f"Active project: {self._active_project.project_name}\n" else: result_str += "No active project\n" + result_str += f"Agent interface: {self._agent_interface.value}\n" result_str += f"Language backend: {self._language_backend.value}" if self._active_project and self._active_project.project_config.language_backend is not None: result_str += " (project override)" diff --git a/src/serena/cli.py b/src/serena/cli.py index b8ae292c..97ca9fc9 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -23,6 +23,7 @@ from serena import serena_version from serena.config.client_setup import client_setup_handlers from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( + AgentInterface, LanguageBackend, ModeSelectionDefinition, ModeSelectionDefinitionWithAddedModes, @@ -263,6 +264,13 @@ class TopLevelCommands(AutoRegisteringGroup): default=None, help="Override the configured language backend.", ) + @click.option( + "--agent-interface", + type=click.Choice([i.value for i in AgentInterface], case_sensitive=False), + default=None, + help="Override the configured agent interface: 'tools' (one tool per operation) or " + "'REPL' (Python code execution via the serena_repl tool, with a fixed set of tools).", + ) @click.option( "--transport", type=click.Choice(["stdio", "sse", "streamable-http"]), @@ -325,6 +333,7 @@ class TopLevelCommands(AutoRegisteringGroup): default_modes: Sequence[str], added_modes: Sequence[str], language_backend: str | None, + agent_interface: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, port: int, @@ -385,6 +394,7 @@ class TopLevelCommands(AutoRegisteringGroup): port=port, mode_selection_def=mode_selection_def, language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, + agent_interface=AgentInterface.from_str(agent_interface) if agent_interface else None, enable_web_dashboard=enable_web_dashboard, open_web_dashboard=open_web_dashboard, enable_gui_log_window=enable_gui_log_window, diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 71043c46..3f2d5aab 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -208,6 +208,37 @@ class ModeSelectionDefinitionWithAddedModes(ModeSelectionDefinition): added_modes: Sequence[str] | None = None +class AgentInterface(Enum): + """ + The interface through which the agent (LLM) accesses Serena's functionality. + """ + + TOOLS = "tools" + """ + The classic tool interface: each operation is a separate tool, and the set of tools is configurable + (via tool inclusions/exclusions in the configuration, context, modes and project). + """ + REPL = "REPL" + """ + The REPL interface: operations are accessed programmatically via the serena_repl tool, which executes Python code. + The set of tools is fixed (the REPL tool and the tools required for session management) and tool inclusions/exclusions + do not apply; the operations available in the REPL are configured via API inclusions/exclusions instead. + """ + + @staticmethod + def from_str(interface_str: str) -> "AgentInterface": + for interface in AgentInterface: + if interface.value.lower() == interface_str.lower(): + return interface + raise ValueError(f"Unknown agent interface '{interface_str}': valid values are {[i.value for i in AgentInterface]}") + + def is_tools(self) -> bool: + return self == AgentInterface.TOOLS + + def is_repl(self) -> bool: + return self == AgentInterface.REPL + + class LanguageBackend(Enum): LSP = "LSP" """ @@ -294,6 +325,7 @@ class SharedConfig(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixi symbol_info_budget: float | None = None language_backend: LanguageBackend | None = None + agent_interface: AgentInterface | None = None line_ending: LineEnding | None = None read_only_memory_patterns: list[str] = field(default_factory=list) ignored_memory_patterns: list[str] = field(default_factory=list) @@ -624,6 +656,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): language_backend_value = data.get("language_backend") language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None + agent_interface_value = data.get("agent_interface") + agent_interface = AgentInterface.from_str(agent_interface_value) if agent_interface_value else None line_ending_value = data.get("line_ending") line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None @@ -659,6 +693,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): encoding=data["encoding"], line_ending=line_ending, language_backend=language_backend, + agent_interface=agent_interface, added_modes=data["added_modes"], default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, @@ -683,6 +718,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # map fields using non-primitive types to a YAML-compatible representation d["language_servers"] = [lang.get_key() for lang in self.language_servers] d["language_backend"] = self.language_backend.value if self.language_backend is not None else None + d["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None d["line_ending"] = self.line_ending.value if self.line_ending is not None else None return d @@ -976,7 +1012,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): # *** static members *** CONFIG_FILE = "serena_config.yml" - CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "line_ending"} + CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "agent_interface", "line_ending"} # *** methods *** @classmethod @@ -1126,6 +1162,15 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): del loaded_commented_yaml["jetbrains"] instance.language_backend = language_backend + # determine agent interface + agent_interface_value = loaded_commented_yaml.get("agent_interface") + agent_interface: AgentInterface | None = None + if "agent_interface" in loaded_commented_yaml: + agent_interface = AgentInterface.from_str(agent_interface_value) if agent_interface_value else None + else: + num_migrations += 1 + instance.agent_interface = agent_interface + # determine line ending line_ending_value = loaded_commented_yaml.get("line_ending") if line_ending_value: @@ -1369,6 +1414,9 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): # convert language backend to string commented_yaml["language_backend"] = self.language_backend.value + # convert agent interface to string (None if not configured) + commented_yaml["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None + # convert line ending to string commented_yaml["line_ending"] = self.line_ending.value @@ -1475,6 +1523,25 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): return True return False + def determine_agent_interface(self, project_config: "ProjectConfig | None" = None, log_choice: bool = False) -> AgentInterface: + """ + Determines the effective agent interface: the project configuration takes precedence over the global configuration; + if neither configures an interface, the tool interface is used. + + :param project_config: the configuration of the project to be activated, if any + :param log_choice: whether to log the choice + :return: the effective agent interface + """ + if project_config is not None and project_config.agent_interface is not None: + agent_interface, source = project_config.agent_interface, "project configuration" + elif self.agent_interface is not None: + agent_interface, source = self.agent_interface, "global configuration" + else: + agent_interface, source = AgentInterface.TOOLS, "default" + if log_choice: + log.info(f"Using agent interface '{agent_interface.value}' ({source})") + return agent_interface + def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False): language_backend = self.language_backend if project_config and project_config.language_backend is not None: diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 1bd2844b..cbe38d8b 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -26,7 +26,7 @@ from serena.agent import ( SerenaAgent, ) from serena.config.context_mode import SerenaAgentContext -from serena.config.serena_config import LanguageBackend, ModeSelectionDefinition, SerenaConfig +from serena.config.serena_config import AgentInterface, LanguageBackend, ModeSelectionDefinition, SerenaConfig from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT from serena.tools import Tool, ToolCallError from serena.util.exception import show_fatal_exception_safe @@ -326,6 +326,7 @@ class SerenaMCPFactory: port: int = 8000, mode_selection_def: ModeSelectionDefinition | None = None, language_backend: LanguageBackend | None = None, + agent_interface: AgentInterface | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, open_web_dashboard: bool | None = None, @@ -341,6 +342,7 @@ class SerenaMCPFactory: :param port: The port to bind to :param mode_selection_def: the mode selection definition to apply :param language_backend: the language backend to use, overriding the configuration setting. + :param agent_interface: the agent interface to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. If not specified, will take the value from the serena configuration. @@ -371,6 +373,8 @@ class SerenaMCPFactory: config.tool_timeout = tool_timeout if language_backend is not None: config.language_backend = language_backend + if agent_interface is not None: + config.agent_interface = agent_interface self.agent = self._create_serena_agent(config, modes=mode_selection_def, project_activation_error=project_activation_error) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index b45df235..c72f7ad0 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -68,6 +68,11 @@ line_ending: # is activated post-init, an error will be returned. language_backend: +# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting). +# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting. +# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied. +agent_interface: + # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 96d9cb58..f7163f2a 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -7,6 +7,18 @@ # in your IDE). language_backend: LSP +# The interface through which the agent (LLM) accesses Serena's functionality: +# * tools: the classic tool interface, in which each operation is a separate tool. +# The set of tools is configurable via the tool inclusion/exclusion settings (excluded_tools etc.) +# of the configuration, the context, modes and the project. +# * REPL: operations are accessed programmatically via the serena_repl tool, which executes Python code. +# The set of tools is fixed (the REPL tool and the tools required for session management), and the tool +# inclusion/exclusion settings do not apply; instead, the operations available in the REPL are configured +# via the API inclusion/exclusion settings (excluded_apis etc.). +# Leave empty to use Serena's default (currently "tools"). +# The interface is fixed at startup and can be overridden per project. +agent_interface: + # line ending convention to use when writing source files. # Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). # Note that Serena's own files (e.g. memories and configuration files) always use native line endings. diff --git a/src/serena/tools/repl_tools.py b/src/serena/tools/repl_tools.py index ba7b4980..611241e4 100644 --- a/src/serena/tools/repl_tools.py +++ b/src/serena/tools/repl_tools.py @@ -4,10 +4,10 @@ Tools which provide access to Serena's functionality through Python code executi # SPDX-License-Identifier: GPL-3.0-or-later -from serena.tools.tools_base import Tool, ToolMarkerBeta +from serena.tools.tools_base import Tool, ToolMarkerBeta, ToolMarkerOptional -class SerenaReplTool(Tool, ToolMarkerBeta): +class SerenaReplTool(Tool, ToolMarkerOptional, ToolMarkerBeta): """ Executes Python code which accesses Serena's functionality programmatically. """ diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 529667f7..cdfa91c5 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -10,6 +10,7 @@ import pytest from serena.agent import SerenaAgent from serena.config.serena_config import ( DEFAULT_PROJECT_SERENA_FOLDER_LOCATION, + AgentInterface, LanguageBackend, ProjectConfig, RegisteredProject, @@ -218,6 +219,65 @@ class TestProjectConfigLanguageBackend: assert config.language_backend is None +class TestAgentInterface: + """Tests for the agent_interface setting (global and per project).""" + + @staticmethod + def _project_config(agent_interface: AgentInterface | None) -> ProjectConfig: + return ProjectConfig(project_name="test", language_servers=[LanguageServerId.PYTHON], agent_interface=agent_interface) + + def test_agent_interface_roundtrips_through_project_yaml(self): + assert self._project_config(AgentInterface.REPL)._to_yaml_dict()["agent_interface"] == "REPL" + assert self._project_config(None)._to_yaml_dict()["agent_interface"] is None + + def test_agent_interface_parsed_from_project_dict(self): + data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + data["project_name"] = "test" + data["languages"] = ["python"] + data["agent_interface"] = "repl" # case-insensitive + assert ProjectConfig._from_dict(data, local_override_keys=[]).agent_interface == AgentInterface.REPL + data.pop("agent_interface") + assert ProjectConfig._from_dict(data, local_override_keys=[]).agent_interface is None + + def test_determine_agent_interface_precedence(self): + # default + assert SerenaConfig().determine_agent_interface() == AgentInterface.TOOLS + assert SerenaConfig().determine_agent_interface(self._project_config(None)) == AgentInterface.TOOLS + # global configuration + assert SerenaConfig(agent_interface=AgentInterface.REPL).determine_agent_interface() == AgentInterface.REPL + # project configuration takes precedence + config = SerenaConfig(agent_interface=AgentInterface.REPL) + assert config.determine_agent_interface(self._project_config(AgentInterface.TOOLS)) == AgentInterface.TOOLS + assert config.determine_agent_interface(self._project_config(None)) == AgentInterface.REPL + + def test_repl_toolset_is_fixed_and_repl_follows_project_activation(self): + """ + In REPL mode, neither the exposed nor the active toolset is affected by tool inclusion/exclusion definitions + (here: the project's exclusions and read-only setting), whereas the REPL's API scope follows the active project. + """ + config, name = _make_config_with_project("test_proj") + config.agent_interface = AgentInterface.REPL + project_config = config.projects[0].project_config + project_config.excluded_tools = ["initial_instructions", "serena_repl"] + project_config.excluded_apis = ["mem"] + project_config.read_only = True + + agent = SerenaAgent(project=None, serena_config=config) + try: + # before activation: the fixed toolset and the full set of facades + 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() + + # after activation: the toolset is unchanged, the REPL reflects the project's API exclusions + agent.activate_project_from_path_or_name(name) + assert set(agent.get_active_tool_names()) == fixed_toolset + assert "s.mem" not in agent.get_repl().entrypoint.overview() + finally: + agent.on_shutdown(timeout=5) + + def _make_config_with_project( project_name: str, language_backend: LanguageBackend | None = None, diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 8fb5002b..8ee9463a 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -14,7 +14,7 @@ from _pytest.mark import Mark, MarkDecorator, ParameterSet from serena.agent import SerenaAgent from serena.config.context_mode import SerenaAgentContext -from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.config.serena_config import AgentInterface, ProjectConfig, RegisteredProject, SerenaConfig from serena.lsp.lsp_diagnostics import DiagnosticsContext from serena.project import Project from serena.tools import ( @@ -30,6 +30,7 @@ from serena.tools import ( ReplaceInFilesTool, ReplaceSymbolBodyTool, SafeDeleteSymbol, + SerenaReplTool, Tool, ) from solidlsp.ls_config import LanguageServerId @@ -901,6 +902,25 @@ class TestSerenaAgent: finally: agent.on_shutdown(timeout=5) + @pytest.mark.python + @pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.PYTHON), reason="python tests are disabled in this environment") + @pytest.mark.parametrize("context_name", ["desktop-app", "grok"], ids=["multi_project", "single_project"]) + def test_repl_interface_exposes_fixed_toolset(self, serena_config, context_name: str): + # the toolset is fixed regardless of tool inclusions/exclusions (e.g. the context's or the configuration's); + # only the single-project property of the context matters (no project activation in that case) + serena_config.agent_interface = AgentInterface.REPL + serena_config.included_optional_tools = ["get_diagnostics_for_symbol"] + context = SerenaAgentContext.from_name(context_name) + agent = SerenaAgent(project="test_repo_python", serena_config=serena_config, context=context) + agent.execute_task(lambda: None) + try: + exposed = {tool.get_name() for tool in agent.get_exposed_tool_instances()} + expected = {"serena_repl", "initial_instructions"} | (set() if context.single_project else {"activate_project"}) + assert exposed == expected + assert "s.lsp" in agent.get_tool(SerenaReplTool).apply("s.info()") + finally: + agent.on_shutdown(timeout=5) + def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: return ( symbol.get("name") == expected_name