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

+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