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

+20 -7
View File
@@ -44,7 +44,7 @@ from serena.config.serena_config import (
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
from serena.facades.api.jb import JetBrainsApi
from serena.facades.api.lsp import LspApi
from serena.facades.facade import Facade
from serena.facades.facade import ApiScope, Facade
from serena.facades.repl import SerenaRepl
from serena.jetbrains import launch_coordinator as jetbrains_launch_coordinator
from serena.ls_manager import LanguageServerManager
@@ -1154,6 +1154,9 @@ class SerenaAgent:
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)
self._repl = None
# check if a tool was activated that is not in the exposed tool set and issue a warning if so
active_tools_not_exposed = set(self._active_tools.tool_names) - set(self._exposed_tools.tool_names)
if active_tools_not_exposed:
@@ -1168,12 +1171,25 @@ class SerenaAgent:
:return: the REPL instance for this agent, creating it if necessary
"""
if self._repl is None:
# determine API scope
api_scope = ApiScope()
api_scope.process(self.serena_config)
api_scope.process(self._context)
for mode in self._active_modes.get_modes():
api_scope.process(mode)
if self._active_project:
api_scope.process(self._active_project.project_config)
if self._active_project.project_config.read_only:
api_scope.exclude_editing()
# gather facades
facades = []
if self._language_backend.is_lsp():
facades.append(Facade.from_api(LspApi(self)))
facades.append(Facade.from_api(LspApi(self), api_scope))
elif self._language_backend.is_jetbrains():
facades.append(Facade.from_api(JetBrainsApi(self)))
self._repl = SerenaRepl(facades)
facades.append(Facade.from_api(JetBrainsApi(self), api_scope))
self._repl = SerenaRepl(facades, api_scope)
return self._repl
def issue_task(
@@ -1270,9 +1286,6 @@ class SerenaAgent:
self._project_prompt_status = ProjectPromptProvisionStatus(newly_activated_mode_names=newly_activated_mode_names)
# reset the REPL to ensure that the new project's configuration is considered
self._repl = None
if update_active_tools:
self._update_active_tools()
+3 -3
View File
@@ -12,7 +12,7 @@ import yaml
from sensai.util import logging
from sensai.util.string import ToStringMixin
from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition
from serena.config.serena_config import ApiInclusionDefinition, SerenaPaths, ToolInclusionDefinition
from serena.constants import (
DEFAULT_CONTEXT,
INTERNAL_MODE_YAMLS_DIR,
@@ -32,7 +32,7 @@ def looks_like_yaml_path(s: str) -> bool:
@dataclass(kw_only=True)
class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
class SerenaAgentMode(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Represents a mode of operation for the agent, typically read off a YAML file.
An agent can be in multiple modes simultaneously as long as they are not mutually exclusive.
The modes can be adjusted after the agent is running, for example for switching from planning to editing.
@@ -148,7 +148,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
@dataclass(kw_only=True)
class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
class SerenaAgentContext(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file.
An agent can only be in a single context at a time.
The contexts cannot be changed after the agent is running.
+17 -1
View File
@@ -178,6 +178,18 @@ class NamedToolInclusionDefinition(ToolInclusionDefinition):
return f"ToolInclusionDefinition[{self.name}]"
@dataclass
class ApiInclusionDefinition:
"""
Defines which APIs to include/exclude in Serena's operation.
A single API inclusion/exclusion can either be a full facade (facade name, which encompasses all of its methods, e.g. "lsp")
or a method of a facade (facade name + method name, e.g. "lsp.find_symbol").
"""
included_apis: Sequence[str] = ()
excluded_apis: Sequence[str] = ()
@dataclass
class ModeSelectionDefinition:
default_modes: Sequence[str] | None = None
@@ -274,7 +286,7 @@ class LineEnding(Enum):
@dataclass
class SharedConfig(ToolInclusionDefinition, ToStringMixin):
class SharedConfig(ToolInclusionDefinition, ApiInclusionDefinition, ToStringMixin):
"""Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form
(same as in ModeSelectionDefinition).
The defaults here shall be none and should be set to the global default values in SerenaConfig.
@@ -621,6 +633,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
fixed_tools = data["fixed_tools"] or []
excluded_tools = data["excluded_tools"] or []
included_optional_tools = data["included_optional_tools"] or []
excluded_apis = data.get("excluded_apis") or []
included_apis = data.get("included_apis") or []
additional_workspace_folders = data.get("ls_additional_workspace_folders") or []
if "base_modes" in data and data["base_modes"] is not None:
@@ -635,6 +649,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
excluded_tools=excluded_tools,
fixed_tools=fixed_tools,
included_optional_tools=included_optional_tools,
excluded_apis=excluded_apis,
included_apis=included_apis,
read_only=data["read_only"],
read_only_memory_patterns=data.get("read_only_memory_patterns", []),
ignored_memory_patterns=data.get("ignored_memory_patterns", []),
+15 -1
View File
@@ -15,7 +15,7 @@ from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil
from serena.symbol import JetBrainsSymbolDictGrouper
from serena.util.text_utils import find_text_coordinates
from ..facade import FacadeApi
from ..facade import FacadeApi, facade_method
from ..representable import JsonObject, JsonObjectRenderer, Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -178,6 +178,7 @@ class JetBrainsApi(FacadeApi):
# read operations
@facade_method()
def find_symbol(
self,
name_path_pattern: str,
@@ -261,6 +262,7 @@ class JetBrainsApi(FacadeApi):
raise ValueError(f"Matched {n_matches}>{max_matches=} symbols.\n" + renderer.render_identifiers(collection))
return collection
@facade_method()
def find_referencing_symbols(self, name_path: str, relative_path: str, max_answer_chars: int = -1) -> JetBrainsSymbolCollection:
"""
Finds all symbols that reference the given symbol — its callers / usages / dependents, i.e. the
@@ -293,6 +295,7 @@ class JetBrainsApi(FacadeApi):
renderer = JetBrainsReferencesRenderer(self._agent, max_answer_chars, grouper=self.references_grouper_)
return JetBrainsSymbolCollection(symbol_dicts, renderer)
@facade_method()
def get_symbols_overview(
self, relative_path: str, depth: int = -1, max_answer_chars: int = -1, include_file_documentation: bool = False
) -> JetBrainsSymbolsOverview:
@@ -338,6 +341,7 @@ class JetBrainsApi(FacadeApi):
result[rel_path].append(name_path)
return dict(result)
@facade_method()
def get_type_hierarchy(
self,
name_path: str,
@@ -376,6 +380,7 @@ class JetBrainsApi(FacadeApi):
result["levels_not_included"] = levels_not_included
return self._json_object(result, max_answer_chars)
@facade_method()
def find_declaration(self, relative_path: str, regex: str, include_body: bool = False) -> JetBrainsSymbolCollection:
r"""
Finds the declaration of a symbol.
@@ -398,6 +403,7 @@ class JetBrainsApi(FacadeApi):
)
return JetBrainsSymbolCollection(response["symbols"], JetBrainsSymbolCollectionRenderer(self._agent, -1))
@facade_method()
def find_implementations(self, relative_path: str, name_path: str) -> JetBrainsSymbolCollection:
"""
Finds the implementations of a symbol.
@@ -412,6 +418,7 @@ class JetBrainsApi(FacadeApi):
# edit operations
@facade_method(can_edit=True)
def rename(
self,
relative_path: str,
@@ -442,6 +449,7 @@ class JetBrainsApi(FacadeApi):
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True)
def move(
self,
relative_path: str,
@@ -482,6 +490,7 @@ class JetBrainsApi(FacadeApi):
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True)
def safe_delete(
self, relative_path: str, name_path: str | None = None, delete_even_if_used: bool = False, propagate: bool = False
) -> JsonObject:
@@ -508,6 +517,7 @@ class JetBrainsApi(FacadeApi):
)
return self._json_object(result)
@facade_method(beta=True, can_edit=True)
def inline_symbol(self, name_path: str, relative_path: str, keep_definition: bool = False) -> JsonObject:
"""
Inlines a symbol (usually a method/function, but also classes may be amenable to inlining,
@@ -528,6 +538,7 @@ class JetBrainsApi(FacadeApi):
# inspections
@facade_method()
def run_inspections(
self,
relative_path: str,
@@ -563,6 +574,7 @@ class JetBrainsApi(FacadeApi):
)
return self._json_object(result, max_answer_chars)
@facade_method()
def list_inspections(
self, language: str | None = None, group_path_contains: str | None = None, max_answer_chars: int = -1
) -> JsonObject:
@@ -583,6 +595,7 @@ class JetBrainsApi(FacadeApi):
# debugging
@facade_method(beta=True)
def debug_eval_info(self) -> str:
"""
Provides usage information for the debug REPL (method `debug_eval`)
@@ -591,6 +604,7 @@ class JetBrainsApi(FacadeApi):
"""
return self._agent.prompt_factory.create_info_jet_brains_debug_repl()
@facade_method(beta=True)
def debug_eval(self, expression: str, repl_key: str = "default") -> str:
"""
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
+14 -1
View File
@@ -21,7 +21,7 @@ from serena.symbol import (
from serena.util.text_utils import TextOutputUtils, find_text_coordinates
from solidlsp.lsp_protocol_handler.lsp_types import SymbolKind
from ..facade import SUCCESS_RESULT, FacadeApi
from ..facade import SUCCESS_RESULT, FacadeApi, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -311,6 +311,7 @@ class LspApi(FacadeApi):
# language server management
@facade_method(optional=True)
def restart_language_server(self) -> str:
"""
Restarts the language server(s). Use this only on explicit user request or after confirmation;
@@ -323,6 +324,7 @@ class LspApi(FacadeApi):
# read operations
@facade_method()
def get_symbols_overview(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> LspSymbolCollection:
"""
Gets an overview of the top-level symbols defined in the given file (classes, methods, fields) — its
@@ -368,6 +370,7 @@ class LspApi(FacadeApi):
)
return LspSymbolCollection(symbols, renderer)
@facade_method()
def find_symbol(
self,
name_path_pattern: str,
@@ -460,6 +463,7 @@ class LspApi(FacadeApi):
return symbol_collection
@facade_method()
def find_referencing_symbols(
self,
name_path: str,
@@ -493,6 +497,7 @@ class LspApi(FacadeApi):
)
return LspReferenceCollection(references, LspReferenceCollectionRenderer(self._agent, max_answer_chars, self.references_grouper_))
@facade_method()
def find_implementations(
self,
name_path: str,
@@ -528,6 +533,7 @@ class LspApi(FacadeApi):
output_params = SymbolOutputParams(kind=True, relative_path=True, body_location=True, include_info=include_info)
return LspSymbolCollection(symbols, LspSymbolCollectionRenderer(self._agent, max_answer_chars, symbol_retriever, output_params))
@facade_method()
def find_declaration(
self,
relative_path: str,
@@ -582,6 +588,7 @@ class LspApi(FacadeApi):
collection_renderer = LspSymbolCollectionRenderer(self._agent, -1, symbol_retriever, output_params)
return LspSymbol(defining_symbol, LspSymbolRenderer(self._agent, -1, collection_renderer))
@facade_method()
def get_diagnostics_for_file(
self, relative_path: str, start_line: int = 0, end_line: int = -1, min_severity: int = 4, max_answer_chars: int = -1
) -> LspDiagnostics:
@@ -615,6 +622,7 @@ class LspApi(FacadeApi):
return self._create_diagnostics(grouped_diagnostics, max_answer_chars)
@facade_method(optional=True)
def get_diagnostics_for_symbol(
self,
name_path: str,
@@ -658,6 +666,7 @@ class LspApi(FacadeApi):
# edit operations
@facade_method(can_edit=True)
def replace_symbol_body(self, name_path: str, relative_path: str, body: str) -> str:
"""
Replaces the body of the given symbol.
@@ -675,6 +684,7 @@ class LspApi(FacadeApi):
self._create_code_editor().replace_body(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
@facade_method(can_edit=True)
def insert_after_symbol(self, name_path: str, relative_path: str, body: str) -> str:
"""
Inserts code after a class/method/function definition.
@@ -689,6 +699,7 @@ class LspApi(FacadeApi):
self._create_code_editor().insert_after_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
@facade_method(can_edit=True)
def insert_before_symbol(self, name_path: str, relative_path: str, body: str) -> str:
"""
Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location).
@@ -703,6 +714,7 @@ class LspApi(FacadeApi):
self._create_code_editor().insert_before_symbol(name_path, relative_file_path=relative_path, body=body)
return SUCCESS_RESULT
@facade_method(can_edit=True)
def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str:
"""
Renames the symbol with the given `name_path` to `new_name` throughout the entire codebase.
@@ -717,6 +729,7 @@ class LspApi(FacadeApi):
self._get_project().ls_sync_file_system_changes()
return self._create_code_editor().rename_symbol(name_path, relative_path=relative_path, new_name=new_name)
@facade_method(can_edit=True)
def safe_delete_symbol(self, name_path_pattern: str, relative_path: str) -> str:
"""
Deletes the symbol if it is safe to do so (i.e., if there are no references to it)
+174 -27
View File
@@ -5,31 +5,83 @@ The facade, i.e. the object through which REPL code accesses a group of related
# SPDX-License-Identifier: GPL-3.0-or-later
import inspect
import logging
from abc import ABC
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypeVar
from serena.config.serena_config import ApiInclusionDefinition
from serena.project import Project
if TYPE_CHECKING:
from serena.agent import SerenaAgent
log = logging.getLogger(__name__)
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
SUCCESS_RESULT = "OK"
"""the result returned by operations which have no result other than their success"""
@dataclass(kw_only=True, frozen=True)
class FacadeMethodInfo:
"""
The metadata of a method exposed through a facade (see `facade_method`), mirroring the tool markers.
"""
name: str
"""the name of the method"""
optional: bool = False
"""whether the method is disabled by default and must be enabled explicitly"""
beta: bool = False
"""whether the method is in beta (not yet fully stable)"""
can_edit: bool = False
"""whether the method can modify the codebase (relevant for read-only contexts)"""
_FACADE_METHOD_INFO_ATTR = "__facade_method_info__"
def facade_method(*, optional: bool = False, beta: bool = False, can_edit: bool = False) -> Callable[[TCallable], TCallable]:
"""
Marks a method of a `FacadeApi` as exposed through the facade, attaching the given metadata.
The decorator only annotates the method (it does not wrap it), such that signature and docstring remain intact.
:param optional: whether the method is disabled by default and must be enabled explicitly
:param beta: whether the method is in beta
:param can_edit: whether the method can modify the codebase
:return: the decorator
"""
def decorator(method: TCallable) -> TCallable:
setattr(method, _FACADE_METHOD_INFO_ATTR, FacadeMethodInfo(name=method.__name__, optional=optional, beta=beta, can_edit=can_edit))
return method
return decorator
def get_facade_method_info(method: Callable[..., Any]) -> FacadeMethodInfo | None:
"""
:param method: a (bound or unbound) method
:return: the metadata attached via `facade_method`, or None if the method is not exposed
"""
return getattr(method, _FACADE_METHOD_INFO_ATTR, None)
class FacadeApi(ABC):
"""
The implementation of a facade's functionality.
API design principle: a member's name determines its visibility to the LLM.
API design principles:
* Names without a leading underscore and without a trailing underscore (e.g. `find_symbol`) constitute the
LLM-facing interface. Every such method of a concrete implementation is a candidate for exposure through
a `Facade`; which of them are actually exposed is decided by the facade.
* Names with a trailing underscore (e.g. `symbols_`, `to_dict_`) are public within Serena (e.g. for use by
classic tools or other facade implementations) but are never exposed to the LLM. Use this for functionality
which is not meant to be called from REPL code, in particular on the objects returned by API methods.
* A method is exposed to the LLM if and only if it is decorated with `facade_method`, which also carries
the method's metadata (optional, beta, can_edit). Undecorated methods are never exposed, regardless of their name.
* On the objects returned by API methods (which are not decorated), the name determines visibility:
names with a trailing underscore (e.g. `symbols_`, `to_dict_`) are public within Serena (e.g. for use by
classic tools or other facade implementations) but are not meant to be called from REPL code, whereas
names without leading or trailing underscore constitute the LLM-facing interface.
The same convention applies to non-exposed helper methods of API classes.
* Names with a leading underscore are private, as usual.
"""
@@ -59,9 +111,16 @@ class FacadeMethod:
enabled or disabled; only enabled methods are accessible from REPL code.
"""
def __init__(self, name: str, implementation: Callable[..., Any], enabled: bool = True) -> None:
def __init__(self, name: str, implementation: Callable[..., Any], info: FacadeMethodInfo, enabled: bool) -> None:
"""
:param name: the method's name
:param implementation: the implementation to delegate to
:param info: the method's metadata
:param enabled: whether the method is initially enabled
"""
self.name = name
self._implementation = implementation
self.info = info
self.enabled = enabled
def __call__(self, *args: Any, **kwargs: Any) -> Any:
@@ -77,6 +136,101 @@ class FacadeMethod:
return f"{facade_name}.{self.name}{signature}\n{doc}\n"
class ApiScope:
"""
The scope of APIs available to the LLM, i.e. which facade methods are enabled, as determined by
applying a sequence of inclusion/exclusion definitions (from the global configuration, the context,
the active modes and the project configuration) to the methods' default enablement.
"""
class FacadeScope:
"""
The scope of a single facade: whether the facade as a whole is included, and which of its methods
were explicitly included/excluded (a method is never in both sets).
If the facade is not included, it is opt-in, i.e. only explicitly included methods are enabled.
"""
def __init__(self) -> None:
self.is_included = True
self.method_inclusions: set[str] = set()
self.method_exclusions: set[str] = set()
def exclude_facade(self) -> None:
self.is_included = False
self.method_inclusions = set()
self.method_exclusions = set()
def include_facade(self) -> None:
self.is_included = True
def exclude_method(self, method_name: str) -> None:
self.method_inclusions.discard(method_name)
self.method_exclusions.add(method_name)
def include_method(self, method_name: str) -> None:
self.method_exclusions.discard(method_name)
self.method_inclusions.add(method_name)
def __init__(self) -> None:
self._facade_scopes: dict[str, ApiScope.FacadeScope] = {}
self._editing_excluded = False
def _get_facade_scope(self, facade_name: str) -> "ApiScope.FacadeScope":
if facade_name not in self._facade_scopes:
self._facade_scopes[facade_name] = ApiScope.FacadeScope()
return self._facade_scopes[facade_name]
def process(self, definition: ApiInclusionDefinition) -> None:
"""
Applies the given definition, exclusions first, then inclusions (such that inclusions take precedence
within a definition; across definitions, later definitions take precedence).
:param definition: the definition to apply
"""
def apply(api_ref: str, *, excluded: bool) -> None:
components = api_ref.split(".")
if len(components) > 2:
log.warning("Ignoring invalid API reference '%s' in %s (expected 'facade' or 'facade.method')", api_ref, definition)
return
facade_scope = self._get_facade_scope(components[0])
if len(components) == 1:
facade_scope.exclude_facade() if excluded else facade_scope.include_facade()
else:
facade_scope.exclude_method(components[1]) if excluded else facade_scope.include_method(components[1])
for api_exclusion in definition.excluded_apis:
apply(api_exclusion, excluded=True)
for api_inclusion in definition.included_apis:
apply(api_inclusion, excluded=False)
def exclude_editing(self) -> None:
"""
Excludes all methods which can edit the codebase (read-only operation), regardless of other inclusions.
"""
self._editing_excluded = True
def is_facade_enabled(self, facade_name: str) -> bool:
facade_scope = self._get_facade_scope(facade_name)
return facade_scope.is_included or len(facade_scope.method_inclusions) > 0
def is_method_enabled(self, facade_name: str, method_info: FacadeMethodInfo) -> bool:
"""
:param facade_name: the name of the facade
:param method_info: the method's metadata
:return: whether the method is enabled: optional methods (and all methods of an excluded facade) must be
explicitly included, other methods are enabled unless explicitly excluded; if editing is excluded,
editing methods are always disabled
"""
if self._editing_excluded and method_info.can_edit:
return False
facade_scope = self._get_facade_scope(facade_name)
if method_info.optional or not facade_scope.is_included:
return method_info.name in facade_scope.method_inclusions
else:
return method_info.name not in facade_scope.method_exclusions
class Facade:
"""
A named group of related operations which an LLM can invoke from REPL code.
@@ -89,29 +243,22 @@ class Facade:
object.__setattr__(self, "_methods", {m.name: m for m in methods})
@staticmethod
def _is_exposable_member_name(name: str) -> bool:
"""
:param name: the name of a member of a facade implementation
:return: whether the member may be exposed through a facade, i.e. whether its name has neither a leading
nor a trailing underscore (see `FacadeApi` for the naming principle)
"""
return not name.startswith("_") and not name.endswith("_")
@staticmethod
def from_api(api: FacadeApi, enabled_methods: Iterable[str] | None = None) -> "Facade":
def from_api(api: FacadeApi, api_scope: ApiScope) -> "Facade":
"""
Creates a facade wrapping the given implementation.
:param api: the implementation; each of its LLM-facing methods (see `_is_exposable_member_name`) becomes a facade method
:param enabled_methods: the names of the methods to enable; if None, all methods are enabled
:param api: the implementation; each of its methods decorated with `facade_method` becomes a facade method
:param api_scope: API scope definition determining which methods are enabled
:return: the facade
"""
enabled = None if enabled_methods is None else set(enabled_methods)
methods = [
FacadeMethod(name, member, enabled=enabled is None or name in enabled)
for name, member in inspect.getmembers(api, predicate=inspect.ismethod)
if Facade._is_exposable_member_name(name)
]
facade_name = api.get_name_()
methods = []
for name, member in inspect.getmembers(api, predicate=inspect.ismethod):
method_info = get_facade_method_info(member)
if method_info is None:
continue
is_enabled = api_scope.is_method_enabled(facade_name, method_info)
methods.append(FacadeMethod(name, member, method_info, enabled=is_enabled))
return Facade(api.get_name_(), api.get_description_(), methods)
@property
+18 -7
View File
@@ -4,14 +4,16 @@ The REPL through which an LLM executes Python code against Serena's facades.
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import textwrap
import traceback
from collections.abc import Iterable
from typing import Any
from .facade import Facade
from .facade import ApiScope, Facade
from .representable import Representable
log = logging.getLogger(__name__)
class SerenaReplEntrypoint:
"""
@@ -19,10 +21,18 @@ class SerenaReplEntrypoint:
and offers progressive disclosure of their interfaces via `info`.
"""
def __init__(self, facades: Iterable[Facade]) -> None:
def __init__(self, facades: list[Facade], api_scope: ApiScope) -> None:
"""
:param facades: the candidate facades
:param api_scope: the API scope, which determines which of the facades are made available
"""
self._facades: dict[str, Facade] = {}
registered_facade_names = []
for facade in facades:
self._register(facade)
if api_scope.is_facade_enabled(facade.name):
self._register(facade)
registered_facade_names.append(facade.name)
log.info("Registered %d/%d facades: %s", len(registered_facade_names), len(facades), registered_facade_names)
def _register(self, facade: Facade) -> None:
if facade.name in self._facades:
@@ -75,11 +85,12 @@ class SerenaRepl:
ENTRYPOINT_NAME = "s"
_FUNCTION_NAME = "__serena_repl_fn__"
def __init__(self, facades: Iterable[Facade]) -> None:
def __init__(self, facades: list[Facade], api_scope: ApiScope) -> None:
"""
:param facades: the facades to make available through the entrypoint
:param facades: the candidate facades
:param api_scope: the API scope, which determines which of the facades are made available
"""
self._entrypoint = SerenaReplEntrypoint(facades)
self._entrypoint = SerenaReplEntrypoint(facades, api_scope)
@property
def entrypoint(self) -> SerenaReplEntrypoint:
@@ -23,6 +23,13 @@ included_optional_tools: []
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be excluded from the REPL in this context.
excluded_apis: []
# included APIs that would otherwise be excluded (particularly optional facade methods, which are disabled by default),
# e.g. "lsp.get_diagnostics_for_symbol".
included_apis: []
# mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method).
# Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason.
tool_description_overrides: {}
@@ -22,4 +22,11 @@ included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
fixed_tools: []
# APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be excluded from the REPL in this mode.
excluded_apis: []
# included APIs that would otherwise be excluded (particularly optional facade methods, which are disabled by default),
# e.g. "lsp.get_diagnostics_for_symbol".
included_apis: []
@@ -131,6 +131,15 @@ included_optional_tools: []
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL.
# This extends the existing exclusions (e.g. from the global configuration).
excluded_apis: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL
# that would otherwise be disabled (particularly optional methods, which are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_apis: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
@@ -151,6 +151,13 @@ included_optional_tools: []
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to be globally excluded from the REPL
excluded_apis: []
# list of APIs (facades or facade methods, e.g. "lsp.get_diagnostics_for_symbol") to be included in the REPL
# (particularly optional methods, which are disabled by default)
included_apis: []
# list of mode names to that are always to be included in the set of active modes.
# The full set of modes to be activated is base_modes + default_modes + added_modes,
# where added_modes can be defined by projects/CLI parameters.
+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()