Add facade method metadata and API scope configuration for the REPL

Facade methods are now declared explicitly via the facade_method
decorator, which attaches FacadeMethodInfo (optional, beta, can_edit)
mirroring the tool markers; undecorated methods are never exposed.
All LspApi and JetBrainsApi methods are decorated accordingly
(JetBrains methods are non-optional, as the facade only exists with
the JetBrains backend).

Which facades/methods are enabled is determined by an ApiScope, which
is built by applying ApiInclusionDefinitions (included_apis,
excluded_apis, referencing facades or facade methods such as "lsp" or
"lsp.find_symbol") from the global configuration, the context, the
active modes and the project configuration, in that order. Optional
methods and all methods of an excluded facade must be included
explicitly; other methods are enabled unless excluded. For read-only
projects, editing methods are excluded. The entrypoint omits facades
which are not enabled.

The REPL is re-created whenever the active tools are updated (mode
switch, project activation), as it depends on the same configuration.
The new settings are read from project.yml and documented in all
configuration templates.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-15 12:50:43 +02:00
1 parent 91c2ec8dc2
commit eabc7e60d6
14 files changed
+382 -65

No files matched your search

+2 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from serena.facades.api.jb import JetBrainsApi
from serena.facades.facade import Facade
from serena.facades.facade import ApiScope, Facade
@pytest.fixture
@@ -26,7 +26,7 @@ def client() -> MagicMock:
def test_facade_exposes_all_jetbrains_operations(agent: MagicMock) -> None:
facade = Facade.from_api(JetBrainsApi(agent))
facade = Facade.from_api(JetBrainsApi(agent), ApiScope())
assert facade.name == "jb"
assert set(facade.enabled_method_names) == {
"find_symbol",
+2 -1
View File
@@ -8,6 +8,7 @@ from serena import __version__
from serena.agent import Tool, ToolRegistry
from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import SerenaConfig
from serena.facades.facade import ApiScope
from serena.facades.repl import SerenaRepl
from serena.mcp import SerenaMCPFactory
@@ -26,7 +27,7 @@ class MockAgent:
@staticmethod
def get_repl() -> SerenaRepl:
return SerenaRepl([])
return SerenaRepl([], ApiScope())
class BaseMockTool(Tool):
+86 -14
View File
@@ -7,8 +7,9 @@ from unittest.mock import MagicMock
import pytest
from serena.config.serena_config import ApiInclusionDefinition
from serena.facades.api.lsp import LspApi
from serena.facades.facade import Facade, FacadeApi
from serena.facades.facade import ApiScope, Facade, FacadeApi, FacadeMethodInfo, facade_method
from serena.facades.repl import SerenaRepl
from serena.tools import SerenaReplTool
from solidlsp.ls_config import LanguageServerId
@@ -20,7 +21,7 @@ class TestReplExecution:
@pytest.fixture
def repl(self) -> SerenaRepl:
return SerenaRepl([Facade.from_api(LspApi(MagicMock()))])
return SerenaRepl([Facade.from_api(LspApi(MagicMock()), ApiScope())], ApiScope())
def test_return_statement_defines_result(self, repl: SerenaRepl) -> None:
assert repl.execute("x = 20\ny = 22\nreturn x + y") == "42"
@@ -59,28 +60,81 @@ class TestFacade:
def __init__(self, agent: MagicMock) -> None:
super().__init__(agent, name="dummy", description="a dummy facade")
@facade_method()
def add(self, a: int, b: int) -> int:
"""Adds two numbers."""
return a + b
@facade_method(can_edit=True)
def secret(self) -> str:
return "hidden"
def serena_internal_(self) -> str:
"""Public within Serena, but not LLM-facing."""
@facade_method(optional=True, beta=True)
def extra(self) -> str:
return "extra"
def undecorated(self) -> str:
"""Public within Serena, but not exposed, since it is not decorated."""
return "internal"
def _internal(self) -> None:
pass
@staticmethod
def _scope(*definitions: ApiInclusionDefinition, **kwargs: list[str]) -> ApiScope:
"""
:param definitions: definitions to apply in order
:param kwargs: an additional definition (`included_apis`/`excluded_apis`) to apply last
"""
scope = ApiScope()
for definition in definitions:
scope.process(definition)
if kwargs:
scope.process(ApiInclusionDefinition(**kwargs))
return scope
def test_api_scope_facade_exclusion_and_method_inclusion(self) -> None:
# excluding the facade disables everything
facade = Facade.from_api(self.DummyApi(MagicMock()), self._scope(excluded_apis=["dummy"]))
assert facade.enabled_method_names == []
# an excluded facade is opt-in: a method inclusion enables exactly that method
facade = Facade.from_api(self.DummyApi(MagicMock()), self._scope(excluded_apis=["dummy"], included_apis=["dummy.add"]))
assert facade.enabled_method_names == ["add"]
def test_entrypoint_omits_excluded_facades(self) -> None:
def create_repl(scope: ApiScope) -> SerenaRepl:
return SerenaRepl([Facade.from_api(self.DummyApi(MagicMock()), scope)], scope)
assert "s.dummy" in create_repl(ApiScope()).execute("s.info()")
assert "s.dummy" not in create_repl(self._scope(excluded_apis=["dummy"])).execute("s.info()")
# a method inclusion keeps the facade available (with just that method)
overview = create_repl(self._scope(excluded_apis=["dummy"], included_apis=["dummy.add"])).execute("s.info()")
assert "s.dummy" in overview and "methods: add" in overview
def test_api_scope_later_definitions_take_precedence(self) -> None:
scope = self._scope(
ApiInclusionDefinition(included_apis=["dummy.extra"]),
ApiInclusionDefinition(excluded_apis=["dummy.extra", "dummy.add"]),
ApiInclusionDefinition(included_apis=["dummy.add"]),
)
facade = Facade.from_api(self.DummyApi(MagicMock()), scope)
assert set(facade.enabled_method_names) == {"add", "secret"}
def test_api_scope_read_only_excludes_editing_methods(self) -> None:
scope = self._scope(included_apis=["dummy.secret"])
scope.exclude_editing()
facade = Facade.from_api(self.DummyApi(MagicMock()), scope)
assert facade.enabled_method_names == ["add"]
def test_enabled_methods_delegate_to_implementation(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()))
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
assert facade.add(1, 2) == 3
assert "dummy.add(a: int, b: int) -> int" in facade.describe()
assert "Adds two numbers." in facade.describe_method("add")
def test_disabled_methods_are_inaccessible_and_undocumented(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()), enabled_methods=["add"])
facade = Facade.from_api(self.DummyApi(MagicMock()), self._scope(excluded_apis=["dummy.secret"]))
assert facade.add(1, 2) == 3
with pytest.raises(AttributeError):
facade.secret()
@@ -89,18 +143,36 @@ class TestFacade:
assert "secret" not in facade.describe()
assert "_internal" not in facade.describe()
def test_trailing_underscore_members_are_not_llm_facing(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()))
assert self.DummyApi(MagicMock()).serena_internal_() == "internal" # usable from within Serena
def test_undecorated_methods_are_not_exposed(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
assert self.DummyApi(MagicMock()).undecorated() == "internal" # usable from within Serena
with pytest.raises(AttributeError):
facade.serena_internal_()
facade.undecorated()
with pytest.raises(ValueError):
facade.get_method("serena_internal_")
assert "serena_internal_" not in facade.describe()
assert "serena_internal_" not in facade.enabled_method_names
facade.get_method("undecorated")
assert "undecorated" not in facade.describe()
assert "undecorated" not in facade.enabled_method_names
def test_optional_methods_are_disabled_by_default(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
assert "extra" not in facade.enabled_method_names
with pytest.raises(AttributeError):
facade.extra()
facade.get_method("extra").enabled = True
assert facade.extra() == "extra"
# an explicit inclusion enables it
facade = Facade.from_api(self.DummyApi(MagicMock()), self._scope(included_apis=["dummy.extra"]))
assert "extra" in facade.enabled_method_names
def test_method_info_mirrors_decorator(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
assert facade.get_method("add").info == FacadeMethodInfo(name="add")
assert facade.get_method("secret").info.can_edit
assert facade.get_method("extra").info == FacadeMethodInfo(name="extra", optional=True, beta=True)
def test_enablement_can_be_changed(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()))
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
facade.get_method("secret").enabled = False
with pytest.raises(AttributeError):
facade.secret()