mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 03:13:51 +00:00
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:
1 parent
91c2ec8dc2
commit
eabc7e60d6
14 files changed
+382
-65
No files matched your search
+20
-7
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user