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.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-15 12:50:44 +02:00
1 parent e7aa6a74e1
commit 337d545c4b
10 files changed
+244 -20

No files matched your search

+7
View File
@@ -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.
+54 -15
View File
@@ -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)"
+10
View File
@@ -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,
+68 -1
View File
@@ -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:
+5 -1
View File
@@ -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)
@@ -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
@@ -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.
+2 -2
View File
@@ -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.
"""
+60
View File
@@ -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,
+21 -1
View File
@@ -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