From af5d792e6b26267cfabfc45ed977e7f9f349f8d7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 25 Dec 2025 13:25:13 +0700 Subject: [PATCH 01/35] Support for symbol_info in LSP backend --- .serena/memories/glossary.md | 2 + src/serena/agent.py | 6 ++ src/serena/ls_manager.py | 8 +++ src/serena/symbol.py | 50 ++++++++++++- src/serena/tools/symbol_tools.py | 16 +++-- .../language_servers/eclipse_jdtls.py | 67 ++++++++++++++--- src/solidlsp/ls.py | 50 ++++++++++++- src/solidlsp/ls_types.py | 72 +++++++++++++++++++ .../src/main/java/test_repo/Model.java | 3 + test/serena/test_serena_agent.py | 18 ++++- test/serena/test_symbol.py | 14 +++- 11 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 .serena/memories/glossary.md diff --git a/.serena/memories/glossary.md b/.serena/memories/glossary.md new file mode 100644 index 00000000..0002784b --- /dev/null +++ b/.serena/memories/glossary.md @@ -0,0 +1,2 @@ + +us: UnifiedSymbolInformation (return format of many low-level language server symbol requests) diff --git a/src/serena/agent.py b/src/serena/agent.py index ca763b87..a43ec287 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -708,3 +708,9 @@ class SerenaAgent: def get_tool_by_name(self, tool_name: str) -> Tool: tool_class = ToolRegistry().get_tool_class_by_name(tool_name) return self.get_tool(tool_class) + + def get_active_lsp_languages(self) -> list[Language]: + ls_manager = self.get_language_server_manager() + if ls_manager is None: + return [] + return ls_manager.get_active_languages() diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 8f3681bf..6fdf0c24 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -182,6 +182,14 @@ class LanguageServerManager: ls = self._language_servers.pop(language) self._stop_language_server(ls, save_cache=save_cache) + def get_active_languages(self) -> list[Language]: + """ + Returns the list of languages for which language servers are currently managed. + + :return: list of languages + """ + return list(self._language_servers.keys()) + @staticmethod def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False, timeout: float = 2.0) -> None: if ls.is_running(): diff --git a/src/serena/symbol.py b/src/serena/symbol.py index ecc13353..caef4bd5 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -188,7 +188,9 @@ class NamePathMatcher(ToStringMixin): class LanguageServerSymbol(Symbol, ToStringMixin): - def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: + def __init__( + self, symbol_root_from_ls: UnifiedSymbolInformation, docstring: str | None = None, signature_string: str | None = None + ) -> None: self.symbol_root = symbol_root_from_ls def _tostring_includes(self) -> list[str]: @@ -496,6 +498,52 @@ class LanguageServerSymbolRetriever: self._ls_manager: LanguageServerManager = ls_manager self.agent = agent + def request_info(self, relative_file_path: str, line: int, column: int) -> str | None: + """Retrieves information (in a sanitized format) about the symbol at the desired location, + typically containing the docstring and signature. + + Returns None if no information is available. + """ + lang_server = self.get_language_server(relative_file_path) + hover_info = lang_server.request_hover(relative_file_path=relative_file_path, line=line, column=column) + if hover_info is None: + return None + contents = hover_info["contents"] + try: + # Handle various response formats + if isinstance(contents, list): + # Array format: extract all parts and join them + stripped_parts = [] + for part in contents: + if isinstance(part, str) and (stripped_part := part.strip()): + stripped_parts.append(stripped_part) + else: + # should be a dict with "value" key + stripped_parts.append(part["value"].strip()) # type: ignore + return "\n".join(stripped_parts) if stripped_parts else None + if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): + return stripped_contents + if isinstance(contents, str) and (stripped_contents := contents.strip()): + return stripped_contents + except (KeyError, IndexError, TypeError): + pass + return None + + def request_info_for_us(self, us: UnifiedSymbolInformation) -> str | None: + relative_path = us["location"]["relativePath"] + if relative_path is None: + return None + return self.request_info( + relative_file_path=relative_path, + # we need to use selectionRange for hover, + # as range typically starts at the symbol definition (at def, class, etc.) where no hover info is available + line=us["selectionRange"]["start"]["line"], + column=us["selectionRange"]["start"]["character"], + ) + + def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None: + return self.request_info_for_us(symbol.symbol_root) + def get_root_path(self) -> str: return self._ls_manager.get_root_path() diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 75d0e5c9..814c8d5c 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -89,6 +89,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): depth: int = 0, relative_path: str = "", include_body: bool = False, + include_info: bool = True, include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 substring_matching: bool = False, @@ -114,17 +115,14 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; for the case where the symbol is a class, this will return its methods). - Default 0. :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. - :param include_body: If True, include the symbol's source code. Use judiciously. - :param include_kinds: Optional. List of LSP symbol kind integers to include. (e.g., 5 for Class, 12 for Function). - Valid kinds: 1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum, - 11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object, - 20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter. + :param include_body: whether to include the symbol's source code. Use judiciously. + :param include_info: whether to include additional info about the symbol (ignored if include_body is True). + :param include_kinds: List of LSP symbol kind integers to include. If not provided, all kinds are included. :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. If not provided, no kinds are excluded. @@ -145,6 +143,12 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): within_relative_path=relative_path, ) symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] + if not include_body and include_info: + # we add an info field to the symbol dicts if requested + for s, s_dict in zip(symbols, symbol_dicts, strict=True): + if symbol_info := symbol_retriever.request_info_for_symbol(s): + s_dict["info"] = symbol_info + s_dict.pop("name", None) # name is included in the info result = self._to_json(symbol_dicts) return self._limit_length(result, max_answer_chars) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d058521e..312cf444 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -10,10 +10,12 @@ import shutil import threading import uuid from pathlib import PurePath +from time import sleep from typing import cast from overrides import override +from solidlsp import ls_types from solidlsp.ls import LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import UnifiedSymbolInformation @@ -144,9 +146,9 @@ class EclipseJDTLS(SolidLanguageServer): f"{data_dir}", ] - self.service_ready_event = threading.Event() - self.intellicode_enable_command_available = threading.Event() - self.initialize_searcher_command_available = threading.Event() + self._service_ready_event = threading.Event() + self._project_ready_event = threading.Event() + self._intellicode_enable_command_available = threading.Event() super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings @@ -612,6 +614,7 @@ class EclipseJDTLS(SolidLanguageServer): "maven": {"downloadSources": True, "updateSnapshots": False}, "eclipse": {"downloadSources": True}, "signatureHelp": {"enabled": True, "description": {"enabled": True}}, + "hover": {"javadoc": {"enabled": True}}, "implementationsCodeLens": {"enabled": True}, "format": { "enabled": True, @@ -721,15 +724,15 @@ class EclipseJDTLS(SolidLanguageServer): self.completions_available.set() if registration["method"] == "workspace/executeCommand": if "java.intellicode.enable" in registration["registerOptions"]["commands"]: - self.intellicode_enable_command_available.set() + self._intellicode_enable_command_available.set() return def lang_status_handler(params: dict) -> None: - # TODO: Should we wait for - # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} - # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": - self.service_ready_event.set() + self._service_ready_event.set() + if params["type"] == "ProjectStatus": + if params["message"] == "OK": + self._project_ready_event.set() def execute_client_command_handler(params: dict) -> list: assert params["command"] == "_java.reloadBundles.command" @@ -764,7 +767,7 @@ class EclipseJDTLS(SolidLanguageServer): self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore - self.intellicode_enable_command_available.wait() + self._intellicode_enable_command_available.wait() java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path assert os.path.exists(java_intellisense_members_path) @@ -776,8 +779,50 @@ class EclipseJDTLS(SolidLanguageServer): ) assert intellicode_enable_result - # TODO: Add comments about why we wait here, and how this can be optimized - self.service_ready_event.wait() + self._service_ready_event.wait() + self._project_ready_event.wait() + + @override + def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None: + # Eclipse JDTLS is flaky on request_hover, sometimes ignoring the javadoc of a symbol, and on top of that changes the output format + # depending on whether javadoc is present or not (contents: list[...] if present, contents: {value: info} if not) + # The following is a rather hacky way to keep calling _request_hover until "a larger" response comes back or we run out of retries + # Note: The file is kept open by the caller (request_hover), so retries don't cause repeated open/close cycles + max_retries = 5 + last_result = super()._request_hover(uri, line, column) + for _ in range(max_retries): + sleep(0.05) + new_result = super()._request_hover(uri, line, column) + if new_result: + if not last_result: + last_result = new_result + continue + last_contents = last_result["contents"] + new_contents = new_result["contents"] + if last_contents == new_contents: + continue + if isinstance(new_contents, list) and isinstance(last_contents, dict): + return new_result + if isinstance(last_contents, list) and isinstance(new_contents, dict): + return last_result + # Get comparable values - prefer list (full javadoc) over dict/str + if isinstance(last_contents, list): + last_value: list | str = last_contents + elif isinstance(last_contents, dict): + last_value = last_contents["value"] + else: + last_value = last_contents + if isinstance(new_contents, list): + new_value: list | str = new_contents + elif isinstance(new_contents, dict): + new_value = new_contents["value"] + else: + new_value = new_contents + if len(last_value) > len(new_value): + return last_result + else: + return new_result + return last_result def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index be66e36c..e5e9e785 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1322,11 +1322,57 @@ class SolidLanguageServer(ABC): :param relative_file_path: The relative path of the file that has the hover information :param line: The line number of the symbol :param column: The column number of the symbol + """ + with self.open_file(relative_file_path): + uri = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + return self._request_hover(uri, line, column) + + def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None: + """ + Internal method that performs the actual hover request. + The file must already be open when calling this method. + Subclasses can override this to customize hover behavior (e.g., retries). + + :param uri: The URI of the file + :param line: The line number of the symbol + :param column: The column number of the symbol + """ + response = self.server.send.hover( + { + "textDocument": {"uri": uri}, + "position": { + "line": line, + "character": column, + }, + } + ) + + if response is None: + return None + + assert isinstance(response, dict) + contents = response.get("contents") + if not contents: + return None + if isinstance(contents, dict) and not contents.get("value"): + return None + return ls_types.Hover(**response) # type: ignore + + def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None: + """ + Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp) + request to the Language Server to find the signature help at the given line and column in the given file. + Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition. + This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured). + + :param relative_file_path: The relative path of the file that has the signature help + :param line: The line number of the symbol + :param column: The column number of the symbol :return None """ with self.open_file(relative_file_path): - response = self.server.send.hover( + response = self.server.send.signature_help( { "textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}, "position": { @@ -1341,7 +1387,7 @@ class SolidLanguageServer(ABC): assert isinstance(response, dict) - return ls_types.Hover(**response) # type: ignore + return ls_types.SignatureHelp(**response) # type: ignore def retrieve_symbol_body( self, diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index e8899602..06e6ae9b 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -371,3 +371,75 @@ class Diagnostic(TypedDict): """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ + + +class SignatureHelp(TypedDict): + """ + Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + + See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#signatureHelp + """ + + signatures: list[SignatureInformation] + """ One or more signatures. """ + activeSignature: NotRequired[int] + """ The active signature. If omitted or the value lies outside the + range of `signatures` the value defaults to zero or is ignored if + the `SignatureHelp` has no signatures. + + Whenever possible implementers should make an active decision about + the active signature and shouldn't rely on a default value. + + In future version of the protocol this property might become + mandatory to better express this. """ + activeParameter: NotRequired[int] + """ The active parameter of the active signature. If omitted or the value + lies outside the range of `signatures[activeSignature].parameters` + defaults to 0 if the active signature has parameters. If + the active signature has no parameters it is ignored. + In future version of the protocol this property might become + mandatory to better express the active parameter if the + active signature does have any. """ + + +class SignatureInformation(TypedDict): + """Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + + label: str + """ The label of this signature. Will be shown in + the UI. """ + documentation: NotRequired[MarkupContent | str] + """ The human-readable doc-comment of this signature. Will be shown + in the UI but can be omitted. """ + parameters: NotRequired[list[ParameterInformation]] + """ The parameters of this signature. """ + activeParameter: NotRequired[int] + """ The index of the active parameter. + + If provided, this is used in place of `SignatureHelp.activeParameter`. + + @since 3.16.0 """ + + +class ParameterInformation(TypedDict): + """Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + + label: str | list[int] + """ The label of this parameter information. + + Either a string or an inclusive start and exclusive end offsets within its containing + signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + string representation as `Position` and `Range` does. + + *Note*: a label of type string should be a substring of its containing signature label. + Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """ + documentation: NotRequired[MarkupContent | str] + """ The human-readable doc-comment of this parameter. Will be shown + in the UI but can be omitted. """ diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java index 5a6a044e..8834f3ca 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java @@ -1,5 +1,8 @@ package test_repo; +/** + * A simple model class that holds a name and provides methods to retrieve it. + */ public class Model { private String name; diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 761affc2..52055975 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -14,6 +14,7 @@ from serena.config.serena_config import ProjectConfig, RegisteredProject, Serena from serena.project import Project from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from test.conftest import get_repo_path, language_tests_enabled from test.solidlsp import clojure as clj @@ -120,7 +121,7 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str): + def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name) @@ -130,6 +131,19 @@ class TestSerenaAgent: symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] for s in symbols ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" + # testing retrieval of symbol info + for s in symbols: + if s["kind"] == SymbolKind.File.name: + # we ignore file symbols for the info test + continue + symbol_info = s.get("info") + assert symbol_info, f"Expected symbol info to be present for symbol: {s}" + assert ( + symbol_name in s["info"] + ), f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}" + # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right + if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: + assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", @@ -172,7 +186,7 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str) -> None: + def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: agent = serena_agent # Find the symbol location first diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index d7dc772d..77558ecd 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -1,6 +1,8 @@ import pytest -from serena.symbol import NamePathMatcher +from serena.symbol import LanguageServerSymbolRetriever, NamePathMatcher +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language class TestSymbolNameMatching: @@ -176,3 +178,13 @@ class TestSymbolNameMatching: result = matcher.matches_components(symbol_name_path_parts, symbol_overload_idx) error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, False, expected, result) assert result == expected, error_msg + + +@pytest.mark.python +class TestLanguageServerSymbolRetriever: + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_request_info(self, language_server: SolidLanguageServer): + symbol_retriever = LanguageServerSymbolRetriever(language_server) + create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] + create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol) + assert "Create a new user and store it" in create_user_method_symbol_info From 1b80b5270dbd7e014efbe00358bb8a645a7bcc94 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 27 Dec 2025 20:49:21 +0700 Subject: [PATCH 02/35] Support for symbol_info in LSP backend --- .../language_servers/eclipse_jdtls.py | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 312cf444..3d516c58 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -784,45 +784,47 @@ class EclipseJDTLS(SolidLanguageServer): @override def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None: - # Eclipse JDTLS is flaky on request_hover, sometimes ignoring the javadoc of a symbol, and on top of that changes the output format - # depending on whether javadoc is present or not (contents: list[...] if present, contents: {value: info} if not) - # The following is a rather hacky way to keep calling _request_hover until "a larger" response comes back or we run out of retries - # Note: The file is kept open by the caller (request_hover), so retries don't cause repeated open/close cycles + # Eclipse JDTLS lazily loads javadocs on first hover request, then caches them. + # This means the first request often returns incomplete info (just the signature), + # while subsequent requests return the full javadoc. + # + # The response format also differs based on javadoc presence: + # - contents: list[...] when javadoc IS present (preferred, richer format) + # - contents: {value: info} when javadoc is NOT present + # + # There's no LSP signal for "javadoc fully loaded" and no way to request + # hover with "wait for complete info". The retry approach is the only viable + # workaround - we keep requesting until we get the richer list format or + # the content stops growing. + # + # The file is kept open by the caller (request_hover), so retries are cheap + # and don't cause repeated didOpen/didClose cycles. + + def content_score(result: ls_types.Hover | None) -> tuple[int, int]: + """Return (format_priority, length) for comparison. Higher is better.""" + if result is None: + return (0, 0) + contents = result["contents"] + if isinstance(contents, list): + return (2, len(contents)) # List format (has javadoc) is best + elif isinstance(contents, dict): + return (1, len(contents.get("value", ""))) + else: + return (1, len(contents)) + max_retries = 5 - last_result = super()._request_hover(uri, line, column) + best_result = super()._request_hover(uri, line, column) + best_score = content_score(best_result) + for _ in range(max_retries): sleep(0.05) new_result = super()._request_hover(uri, line, column) - if new_result: - if not last_result: - last_result = new_result - continue - last_contents = last_result["contents"] - new_contents = new_result["contents"] - if last_contents == new_contents: - continue - if isinstance(new_contents, list) and isinstance(last_contents, dict): - return new_result - if isinstance(last_contents, list) and isinstance(new_contents, dict): - return last_result - # Get comparable values - prefer list (full javadoc) over dict/str - if isinstance(last_contents, list): - last_value: list | str = last_contents - elif isinstance(last_contents, dict): - last_value = last_contents["value"] - else: - last_value = last_contents - if isinstance(new_contents, list): - new_value: list | str = new_contents - elif isinstance(new_contents, dict): - new_value = new_contents["value"] - else: - new_value = new_contents - if len(last_value) > len(new_value): - return last_result - else: - return new_result - return last_result + new_score = content_score(new_result) + if new_score > best_score: + best_result = new_result + best_score = new_score + + return best_result def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None From 6c53fd130accbd0f716192ec61445332358938cf Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 27 Dec 2025 23:34:27 +0700 Subject: [PATCH 03/35] Added support for type-hierarchy with jetbrains tools --- src/serena/tools/jetbrains_plugin_client.py | 48 ++++++++++ src/serena/tools/jetbrains_tools.py | 97 +++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index cd31b7b1..37d10a4d 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -181,6 +181,54 @@ class JetBrainsPluginClient(ToStringMixin): request_data = {"relativePath": relative_path} return self._make_request("POST", "/getSymbolsOverview", request_data) + def get_supertypes( + self, + name_path: str, + relative_path: str, + depth: int | None = None, + limit_children: int | None = None, + ) -> dict[str, Any]: + """ + Gets the supertypes (parent classes/interfaces) of a symbol. + + :param name_path: the name path of the symbol + :param relative_path: the relative path to the file containing the symbol + :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) + :param limit_children: optional limit on children per level + :return: dictionary containing symbol info and type hierarchy + """ + request_data = { + "namePath": name_path, + "relativePath": relative_path, + "depth": depth, + "limitChildren": limit_children, + } + return self._make_request("POST", "/getSupertypes", request_data) + + def get_subtypes( + self, + name_path: str, + relative_path: str, + depth: int | None = None, + limit_children: int | None = None, + ) -> dict[str, Any]: + """ + Gets the subtypes (subclasses/implementations) of a symbol. + + :param name_path: the name path of the symbol + :param relative_path: the relative path to the file containing the symbol + :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) + :param limit_children: optional limit on children per level + :return: dictionary containing symbol info and type hierarchy + """ + request_data = { + "namePath": name_path, + "relativePath": relative_path, + "depth": depth, + "limitChildren": limit_children, + } + return self._make_request("POST", "/getSubtypes", request_data) + def rename_symbol( self, name_path: str, relative_path: str, new_name: str, rename_in_comments: bool, rename_in_text_occurrences: bool ) -> None: diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index e2b84e6e..7fa4b553 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,3 +1,5 @@ +from typing import Literal + from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient @@ -121,3 +123,98 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) + + +class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend + """ + + @staticmethod + def _transform_hierarchy_nodes(nodes: list[dict] | None) -> dict[str, list]: + """ + Transform a list of TypeHierarchyNode into a file-grouped compact format. + + Returns a dict where keys are relative_paths and values are lists of either: + - "SymbolNamePath" (leaf node) + - {"SymbolNamePath": {nested_file_grouped_children}} (node with children) + """ + if not nodes: + return {} + + result: dict[str, list] = {} + + for node in nodes: + symbol = node["symbol"] + name_path = symbol["name_path"] + rel_path = symbol["relative_path"] + children = node.get("children", []) + + if rel_path not in result: + result[rel_path] = [] + + if children: + # Node with children - recurse + nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children) + result[rel_path].append({name_path: nested}) + else: + # Leaf node + result[rel_path].append(name_path) + + return result + + def apply( + self, + name_path: str, + relative_path: str, + hierarchy_type: Literal["super", "sub", "both"] = "sub", + depth: int | None = 1, + max_answer_chars: int = -1, + ) -> str: + """ + Gets the type hierarchy of a symbol (supertypes, subtypes, or both). + + :param name_path: name path of the symbol for which to get the type hierarchy. + :param relative_path: the relative path to the file containing the symbol. + :param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces, + "sub" for subclasses/implementations, or "both" for both directions. + :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: Compact JSON with file-grouped hierarchy. Error string if not applicable. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + subtypes = {} + supertypes = {} + if hierarchy_type in ("super", "both"): + supertypes_response = client.get_supertypes( + name_path=name_path, + relative_path=relative_path, + depth=depth, + ) + if error_msg := supertypes_response.get("error"): + return error_msg + + symbol = supertypes_response["symbol"] + supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) + if hierarchy_type in ("sub", "both"): + subtypes_response = client.get_subtypes( + name_path=name_path, + relative_path=relative_path, + depth=depth, + ) + if error_msg := subtypes_response.get("error"): + return error_msg + symbol = subtypes_response["symbol"] + + subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) + + symbol_compact = {symbol["relative_path"]: [symbol["name_path"]]} + result_dict: dict[str, dict | list] = {"symbol": symbol_compact} + if supertypes: + result_dict["supertypes"] = supertypes + if subtypes: + result_dict["subtypes"] = subtypes + + result = self._to_json(result_dict) + return self._limit_length(result, max_answer_chars) From dd5065f18035ded676f98b8fdf35966bced73967 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 28 Dec 2025 00:38:14 +0700 Subject: [PATCH 04/35] Various minor improvements (review comments) --- .serena/memories/glossary.md | 2 -- src/serena/symbol.py | 55 +++++++++++------------------ src/serena/tools/jetbrains_tools.py | 4 +-- src/serena/tools/symbol_tools.py | 5 +-- 4 files changed, 25 insertions(+), 41 deletions(-) delete mode 100644 .serena/memories/glossary.md diff --git a/.serena/memories/glossary.md b/.serena/memories/glossary.md deleted file mode 100644 index 0002784b..00000000 --- a/.serena/memories/glossary.md +++ /dev/null @@ -1,2 +0,0 @@ - -us: UnifiedSymbolInformation (return format of many low-level language server symbol requests) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index caef4bd5..da2c7c01 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -188,9 +188,7 @@ class NamePathMatcher(ToStringMixin): class LanguageServerSymbol(Symbol, ToStringMixin): - def __init__( - self, symbol_root_from_ls: UnifiedSymbolInformation, docstring: str | None = None, signature_string: str | None = None - ) -> None: + def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: self.symbol_root = symbol_root_from_ls def _tostring_includes(self) -> list[str]: @@ -498,7 +496,7 @@ class LanguageServerSymbolRetriever: self._ls_manager: LanguageServerManager = ls_manager self.agent = agent - def request_info(self, relative_file_path: str, line: int, column: int) -> str | None: + def _request_info(self, relative_file_path: str, line: int, column: int) -> str | None: """Retrieves information (in a sanitized format) about the symbol at the desired location, typically containing the docstring and signature. @@ -509,40 +507,27 @@ class LanguageServerSymbolRetriever: if hover_info is None: return None contents = hover_info["contents"] - try: - # Handle various response formats - if isinstance(contents, list): - # Array format: extract all parts and join them - stripped_parts = [] - for part in contents: - if isinstance(part, str) and (stripped_part := part.strip()): - stripped_parts.append(stripped_part) - else: - # should be a dict with "value" key - stripped_parts.append(part["value"].strip()) # type: ignore - return "\n".join(stripped_parts) if stripped_parts else None - if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): - return stripped_contents - if isinstance(contents, str) and (stripped_contents := contents.strip()): - return stripped_contents - except (KeyError, IndexError, TypeError): - pass + # Handle various response formats + if isinstance(contents, list): + # Array format: extract all parts and join them + stripped_parts = [] + for part in contents: + if isinstance(part, str) and (stripped_part := part.strip()): + stripped_parts.append(stripped_part) + else: + # should be a dict with "value" key + stripped_parts.append(part["value"].strip()) # type: ignore + return "\n".join(stripped_parts) if stripped_parts else None + if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): + return stripped_contents + if isinstance(contents, str) and (stripped_contents := contents.strip()): + return stripped_contents return None - def request_info_for_us(self, us: UnifiedSymbolInformation) -> str | None: - relative_path = us["location"]["relativePath"] - if relative_path is None: - return None - return self.request_info( - relative_file_path=relative_path, - # we need to use selectionRange for hover, - # as range typically starts at the symbol definition (at def, class, etc.) where no hover info is available - line=us["selectionRange"]["start"]["line"], - column=us["selectionRange"]["start"]["character"], - ) - def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None: - return self.request_info_for_us(symbol.symbol_root) + if None in [symbol.relative_path, symbol.line, symbol.column]: + return None + return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] def get_root_path(self) -> str: return self._ls_manager.get_root_path() diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 7fa4b553..3e70ba8c 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -177,8 +177,8 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona :param name_path: name path of the symbol for which to get the type hierarchy. :param relative_path: the relative path to the file containing the symbol. :param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces, - "sub" for subclasses/implementations, or "both" for both directions. - :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). + "sub" for subclasses/implementations, or "both" for both directions. Default is "sub". + :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1. :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: Compact JSON with file-grouped hierarchy. Error string if not applicable. diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 814c8d5c..9ccd5350 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -114,14 +114,15 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; - for the case where the symbol is a class, this will return its methods). + for the case where the symbol is a class, this will return its methods). Default 0. :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. :param include_body: whether to include the symbol's source code. Use judiciously. - :param include_info: whether to include additional info about the symbol (ignored if include_body is True). + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the symbol (ignored if include_body is True). Default True, info is never included for child symbols. :param include_kinds: List of LSP symbol kind integers to include. If not provided, all kinds are included. :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. From bd26cbd381d2a96d599169674b96d703d4d05f88 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 12:37:41 +0100 Subject: [PATCH 05/35] Add jet_brains_type_hierarchy to jetbrains mode --- src/serena/resources/config/internal_modes/jetbrains.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 7af49efe..863f3beb 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -13,3 +13,4 @@ included_optional_tools: - jet_brains_find_symbol - jet_brains_find_referencing_symbols - jet_brains_get_symbols_overview + - jet_brains_type_hierarchy From 4d1d2561cfd54dfdd828e24511e3b1ed98eb470f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 12:48:30 +0100 Subject: [PATCH 06/35] JetBrainsTypeHierarchyTool: Return supertypes/subtypes keys even if empty, because LLM otherwise thinks the retrieval failed and retries (with different parameters) --- src/serena/tools/jetbrains_tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 3e70ba8c..aeee56fd 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -184,8 +184,8 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona :return: Compact JSON with file-grouped hierarchy. Error string if not applicable. """ with JetBrainsPluginClient.from_project(self.project) as client: - subtypes = {} - supertypes = {} + subtypes = None + supertypes = None if hierarchy_type in ("super", "both"): supertypes_response = client.get_supertypes( name_path=name_path, @@ -211,9 +211,9 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona symbol_compact = {symbol["relative_path"]: [symbol["name_path"]]} result_dict: dict[str, dict | list] = {"symbol": symbol_compact} - if supertypes: + if supertypes is not None: result_dict["supertypes"] = supertypes - if subtypes: + if subtypes is not None: result_dict["subtypes"] = subtypes result = self._to_json(result_dict) From 061975922b1bfdc2669f7df56a088cc447d718c5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 14:52:32 +0100 Subject: [PATCH 07/35] JetBrainsTypeHierarchyTool: Report number of levels not included in result --- src/serena/tools/jetbrains_tools.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index aeee56fd..de5044ce 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -186,6 +186,8 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona with JetBrainsPluginClient.from_project(self.project) as client: subtypes = None supertypes = None + levels_not_included = {} + if hierarchy_type in ("super", "both"): supertypes_response = client.get_supertypes( name_path=name_path, @@ -194,9 +196,11 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona ) if error_msg := supertypes_response.get("error"): return error_msg - + if "num_levels_not_included" in supertypes_response: + levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"] symbol = supertypes_response["symbol"] supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) + if hierarchy_type in ("sub", "both"): subtypes_response = client.get_subtypes( name_path=name_path, @@ -206,7 +210,8 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona if error_msg := subtypes_response.get("error"): return error_msg symbol = subtypes_response["symbol"] - + if "num_levels_not_included" in subtypes_response: + levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) symbol_compact = {symbol["relative_path"]: [symbol["name_path"]]} @@ -215,6 +220,8 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona result_dict["supertypes"] = supertypes if subtypes is not None: result_dict["subtypes"] = subtypes + if levels_not_included: + result_dict["levels_not_included"] = levels_not_included result = self._to_json(result_dict) return self._limit_length(result, max_answer_chars) From d03c0573826cb52ec9984c27be6b691c387d9547 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 14:56:46 +0700 Subject: [PATCH 08/35] Removed symbol from hierarchy tool response --- src/serena/tools/jetbrains_tools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index de5044ce..b3ec9a61 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -214,8 +214,7 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) - symbol_compact = {symbol["relative_path"]: [symbol["name_path"]]} - result_dict: dict[str, dict | list] = {"symbol": symbol_compact} + result_dict: dict[str, dict | list] = {} if supertypes is not None: result_dict["supertypes"] = supertypes if subtypes is not None: From 1d5cf33237309e5f98085fb5337f084019a980ea Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 18:43:02 +0700 Subject: [PATCH 09/35] Support for info in JB symbols and various improvements. Adds bs4 to deps - Added typed dicts for most responses in jetbrains_types.py - Fix bug in code_editor (location key no longer existed, found by mypy after adding the types) Should be merged after next version of plugin release --- pyproject.toml | 3 +- scripts/demo_run_tools.py | 17 +++++-- src/serena/code_editor.py | 2 +- src/serena/symbol.py | 13 ++---- src/serena/text_utils.py | 19 ++++++++ src/serena/tools/jetbrain_types.py | 50 +++++++++++++++++++++ src/serena/tools/jetbrains_plugin_client.py | 34 +++++++------- src/serena/tools/jetbrains_tools.py | 38 +++++++++++++--- uv.lock | 2 + 9 files changed, 139 insertions(+), 39 deletions(-) create mode 100644 src/serena/tools/jetbrain_types.py diff --git a/pyproject.toml b/pyproject.toml index 53bb740d..e83c5d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "tqdm>=4.67.1", "tiktoken>=0.9.0", "anthropic>=0.54.0", + "beautifulsoup4>=4.14.2", ] [[tool.uv.index]] @@ -314,4 +315,4 @@ markers = [ skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true # ignore-regex = '' -ignore-words-list = 'paket' \ No newline at end of file +ignore-words-list = 'paket' diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 8cbaae79..bad7ed60 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -7,21 +7,30 @@ import json from pprint import pprint from serena.agent import SerenaAgent +from serena.config.serena_config import SerenaConfig from serena.constants import REPO_ROOT -from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, JetBrainsFindSymbolTool, SearchForPatternTool +from serena.tools import ( + FindFileTool, + FindReferencingSymbolsTool, + JetBrainsFindSymbolTool, + JetBrainsGetSymbolsOverviewTool, + SearchForPatternTool, +) if __name__ == "__main__": - agent = SerenaAgent(project=REPO_ROOT) + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + agent = SerenaAgent(project=REPO_ROOT, serena_config=serena_config) # apply a tool find_symbol_tool = agent.get_tool(JetBrainsFindSymbolTool) find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) find_file_tool = agent.get_tool(FindFileTool) search_pattern_tool = agent.get_tool(SearchForPatternTool) - overview_tool = agent.get_tool(GetSymbolsOverviewTool) + overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool) result = agent.execute_task( - lambda: find_symbol_tool.apply("displayBasicStats"), + lambda: find_symbol_tool.apply("SerenaAgent/get_tool_description_override"), ) pprint(json.loads(result)) # input("Press Enter to continue...") diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 4f5fe6bb..ac3dca3a 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -422,7 +422,7 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): if len(symbols) > 1: raise ValueError( f"Found multiple {len(symbols)} symbols with name {name_path} in file {relative_file_path}. " - "Their locations are: \n " + json.dumps([s["location"] for s in symbols], indent=2) + "Their locations are: \n " + json.dumps([s["text_range"] for s in symbols], indent=2) ) return JetBrainsSymbol(symbols[0], self._project) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index da2c7c01..4f71740a 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -4,10 +4,11 @@ import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterator, Sequence from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union +from typing import TYPE_CHECKING, Any, Self, Union from sensai.util.string import ToStringMixin +import serena.tools.jetbrain_types as jb from solidlsp import SolidLanguageServer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation @@ -705,15 +706,7 @@ class LanguageServerSymbolRetriever: class JetBrainsSymbol(Symbol): - class SymbolDict(TypedDict): - name_path: str - relative_path: str - type: str - text_range: NotRequired[dict] - body: NotRequired[str] - children: NotRequired[list["JetBrainsSymbol.SymbolDict"]] - - def __init__(self, symbol_dict: SymbolDict, project: Project) -> None: + def __init__(self, symbol_dict: jb.SymbolDTO, project: Project) -> None: """ :param symbol_dict: dictionary as returned by the JetBrains plugin client. """ diff --git a/src/serena/text_utils.py b/src/serena/text_utils.py index d87e9ad6..3c62e30a 100644 --- a/src/serena/text_utils.py +++ b/src/serena/text_utils.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from enum import StrEnum from typing import Any, Self +from bs4 import BeautifulSoup from joblib import Parallel, delayed from serena.constants import DEFAULT_SOURCE_FILE_ENCODING @@ -405,3 +406,21 @@ def search_files( log.info(f"Found {len(matches)} total matches across {len(filtered_paths)} files") return matches + + +def render_html(text: str) -> str: + """ + Remove HTML tags and decode HTML entities from text while preserving the actual content. + This keeps type information and structure but removes all formatting. + + :param text: HTML text to clean + :return: Plain text without HTML tags and with decoded entities + """ + soup = BeautifulSoup(text, "html.parser") + # join text with spaces to avoid concatenation of words + text = soup.get_text(separator=" ", strip=True) + + # normalize non-breaking spaces + text = text.replace("\xa0", " ") + + return text.strip() diff --git a/src/serena/tools/jetbrain_types.py b/src/serena/tools/jetbrain_types.py new file mode 100644 index 00000000..5ec12e00 --- /dev/null +++ b/src/serena/tools/jetbrain_types.py @@ -0,0 +1,50 @@ +from typing import NotRequired, TypedDict + +# TODO or not TODO: in principle, we could autogenerate these from the java code. For now not worth the effort. + + +class PositionDTO(TypedDict): + line: int + col: int + + +class TextRangeDTO(TypedDict): + start_pos: PositionDTO + end_pos: PositionDTO + + +class SymbolDTO(TypedDict): + name_path: str + relative_path: str + type: str + body: str + quick_info: NotRequired[str | None] + """Quick info text (e.g., type signature) for the symbol, as HTML string.""" + documentation: NotRequired[str | None] + """Documentation text for the symbol (if available), as HTML string.""" + text_range: TextRangeDTO + children: list["SymbolDTO"] | None + + +class SymbolCollectionResponse(TypedDict): + symbols: list[SymbolDTO] + documentation: NotRequired[str | None] + """Docstring of the collection (if applicable - usually present only if the collection is from a single file), + as HTML string.""" + + +class TypeHierarchyNode(TypedDict): + symbol: SymbolDTO + children: list["TypeHierarchyNode"] | None + + +class TypeHierarchy(TypedDict): + nodes: list[TypeHierarchyNode] + depth: int + + +class TypeHierarchyResponse(TypedDict): + symbol: SymbolDTO | None + hierarchy: list[TypeHierarchyNode] | None + error: str | None + num_levels_not_included: int | None diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index a2a54cbf..e46ecc8f 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -5,12 +5,13 @@ Client for the Serena JetBrains Plugin import json import logging from pathlib import Path -from typing import Any, Optional, Self, TypeVar +from typing import Any, Optional, Self, TypeVar, cast import requests from requests import Response from sensai.util.string import ToStringMixin +import serena.tools.jetbrain_types as jb from serena.project import Project T = TypeVar("T") @@ -141,21 +142,21 @@ class JetBrainsPluginClient(ToStringMixin): name_path: str, relative_path: str | None = None, include_body: bool = False, + include_info: bool = True, depth: int = 0, include_location: bool = False, search_deps: bool = False, - ) -> dict[str, Any]: + ) -> jb.SymbolCollectionResponse: """ Finds symbols by name. :param name_path: the name path to match :param relative_path: the relative path to which to restrict the search :param include_body: whether to include symbol body content + :param include_info: whether to include symbol info :param depth: depth of children to include (0 = no children) :param include_location: whether to include symbol location information :param search_deps: whether to also search in dependencies - - :return: Dictionary containing 'symbols' list with matching symbols """ request_data = { "namePath": name_path, @@ -164,27 +165,28 @@ class JetBrainsPluginClient(ToStringMixin): "depth": depth, "includeLocation": include_location, "searchDeps": search_deps, + "includeInfo": include_info, } - return self._make_request("POST", "/findSymbol", request_data) + return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data)) - def find_references(self, name_path: str, relative_path: str) -> dict[str, Any]: + def find_references(self, name_path: str, relative_path: str, include_info: bool) -> jb.SymbolCollectionResponse: """ Finds references to a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path - :return: dictionary containing 'symbols' list with symbol references + :param include_info: whether to include symbol info """ - request_data = {"namePath": name_path, "relativePath": relative_path} - return self._make_request("POST", "/findReferences", request_data) + request_data = {"namePath": name_path, "relativePath": relative_path, "includeInfo": include_info} + return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) - def get_symbols_overview(self, relative_path: str, depth: int) -> dict[str, Any]: + def get_symbols_overview(self, relative_path: str, depth: int) -> jb.SymbolCollectionResponse: """ :param relative_path: the relative path to a source file :param depth: the depth of children to include (0 = no children) """ request_data = {"relativePath": relative_path, "depth": depth} - return self._make_request("POST", "/getSymbolsOverview", request_data) + return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) def get_supertypes( self, @@ -192,7 +194,7 @@ class JetBrainsPluginClient(ToStringMixin): relative_path: str, depth: int | None = None, limit_children: int | None = None, - ) -> dict[str, Any]: + ) -> jb.TypeHierarchyResponse: """ Gets the supertypes (parent classes/interfaces) of a symbol. @@ -200,7 +202,6 @@ class JetBrainsPluginClient(ToStringMixin): :param relative_path: the relative path to the file containing the symbol :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level - :return: dictionary containing symbol info and type hierarchy """ request_data = { "namePath": name_path, @@ -208,7 +209,7 @@ class JetBrainsPluginClient(ToStringMixin): "depth": depth, "limitChildren": limit_children, } - return self._make_request("POST", "/getSupertypes", request_data) + return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSupertypes", request_data)) def get_subtypes( self, @@ -216,7 +217,7 @@ class JetBrainsPluginClient(ToStringMixin): relative_path: str, depth: int | None = None, limit_children: int | None = None, - ) -> dict[str, Any]: + ) -> jb.TypeHierarchyResponse: """ Gets the subtypes (subclasses/implementations) of a symbol. @@ -224,7 +225,6 @@ class JetBrainsPluginClient(ToStringMixin): :param relative_path: the relative path to the file containing the symbol :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level - :return: dictionary containing symbol info and type hierarchy """ request_data = { "namePath": name_path, @@ -232,7 +232,7 @@ class JetBrainsPluginClient(ToStringMixin): "depth": depth, "limitChildren": limit_children, } - return self._make_request("POST", "/getSubtypes", request_data) + return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSubtypes", request_data)) def rename_symbol( self, name_path: str, relative_path: str, new_name: str, rename_in_comments: bool, rename_in_text_occurrences: bool diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 6f3fd4c5..484bc3a6 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,9 +1,14 @@ +import logging from collections import defaultdict -from typing import Any +from typing import Any, Literal +import serena.tools.jetbrain_types as jb +from serena.text_utils import render_html from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient +log = logging.getLogger(__name__) + class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ @@ -16,6 +21,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): depth: int = 0, relative_path: str | None = None, include_body: bool = False, + include_info: bool = True, search_deps: bool = False, max_answer_chars: int = -1, ) -> str: @@ -46,6 +52,9 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. :param include_body: If True, include the symbol's source code. Use judiciously. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the symbol (ignored if include_body is True). + Default True, info is never included for child symbols and is not included when body is requested. :param search_deps: If True, also search in project dependencies (e.g., libraries). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. @@ -53,14 +62,27 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ if relative_path == ".": relative_path = None + if include_body: + include_info = False # we save tokens and don't include info if body is requested with JetBrainsPluginClient.from_project(self.project) as client: response_dict = client.find_symbol( name_path=name_path_pattern, relative_path=relative_path, depth=depth, include_body=include_body, + include_info=include_info, search_deps=search_deps, ) + + # massage the response to include only 'info' field if requested (instead of both doc and quick info) + if not include_body and include_info: + symbols = response_dict["symbols"] + for symbol in symbols: + documentation = symbol.pop("documentation", None) + quick_info = symbol.pop("quick_info", None) + info = documentation if documentation else quick_info + if info: + symbol["info"] = render_html(info) # type: ignore result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -74,6 +96,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark self, name_path: str, relative_path: str, + include_info: bool = True, max_answer_chars: int = -1, ) -> str: """ @@ -83,6 +106,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark :param name_path: name path of the symbol for which to find references; matching logic as described in find symbol tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: a list of JSON objects with the symbols referencing the requested symbol @@ -91,6 +115,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, + include_info=include_info, ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -153,9 +178,12 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp """ with JetBrainsPluginClient.from_project(self.project) as client: response_dict = client.get_symbols_overview(relative_path=relative_path, depth=depth) + documentation = response_dict.pop("documentation", None) + if documentation: + response_dict["docstring"] = render_html(documentation) # type: ignore symbols = response_dict["symbols"] - compact_symbols = self._transform_symbols_to_compact_format(symbols) - result = self._to_json(compact_symbols) + response_dict["symbols"] = self._transform_symbols_to_compact_format(symbols) # type: ignore + result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -165,7 +193,7 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona """ @staticmethod - def _transform_hierarchy_nodes(nodes: list[dict] | None) -> dict[str, list]: + def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNode] | None) -> dict[str, list]: """ Transform a list of TypeHierarchyNode into a file-grouped compact format. @@ -232,7 +260,6 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona return error_msg if "num_levels_not_included" in supertypes_response: levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"] - symbol = supertypes_response["symbol"] supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) if hierarchy_type in ("sub", "both"): @@ -243,7 +270,6 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona ) if error_msg := subtypes_response.get("error"): return error_msg - symbol = subtypes_response["symbol"] if "num_levels_not_included" in subtypes_response: levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) diff --git a/uv.lock b/uv.lock index e8083592..c8bbfb14 100644 --- a/uv.lock +++ b/uv.lock @@ -1933,6 +1933,7 @@ version = "0.1.4" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "beautifulsoup4" }, { name = "docstring-parser" }, { name = "dotenv" }, { name = "flask" }, @@ -1988,6 +1989,7 @@ google = [ requires-dist = [ { name = "agno", marker = "extra == 'agno'", specifier = ">=2.2.1" }, { name = "anthropic", specifier = ">=0.54.0" }, + { name = "beautifulsoup4", specifier = ">=4.14.2" }, { name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "dotenv", specifier = ">=0.9.9" }, From d3f332601191c3c37549d9e2083d902557651468 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 19:13:38 +0700 Subject: [PATCH 10/35] Added symbol info by default in LS based FindReferencingSymbolsTool Previously we extracted a few content lines around the symbol instead --- src/serena/tools/jetbrains_tools.py | 1 + src/serena/tools/symbol_tools.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 484bc3a6..f52734f7 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -92,6 +92,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark Finds symbols that reference the given symbol using the JetBrains backend """ + # TODO: (maybe) - add content snippets showing the references like in LS based version? def apply( self, name_path: str, diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index bd6c0d7a..7571cc9f 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -197,6 +197,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): self, name_path: str, relative_path: str, + include_info: bool = True, include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 max_answer_chars: int = -1, @@ -208,6 +209,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the referencing symbols. Default True. :param include_kinds: same as in the `find_symbol` tool. :param exclude_kinds: same as in the `find_symbol` tool. :param max_answer_chars: same as in the `find_symbol` tool. @@ -231,6 +234,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): if not include_body: ref_relative_path = ref.symbol.location.relative_path assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." + if include_info and (referencing_symbol_info := symbol_retriever.request_info_for_symbol(ref.symbol)): + ref_dict["info"] = referencing_symbol_info content_around_ref = self.project.retrieve_content_around_line( relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1 ) From e3fa2d0b678286583c7860a75dd3eae6cb3e8893 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 19:28:53 +0700 Subject: [PATCH 11/35] Fixed NotRequired vs None annotations in jetbrain_types.py --- src/serena/tools/jetbrain_types.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/serena/tools/jetbrain_types.py b/src/serena/tools/jetbrain_types.py index 5ec12e00..d7646bd9 100644 --- a/src/serena/tools/jetbrain_types.py +++ b/src/serena/tools/jetbrain_types.py @@ -18,24 +18,24 @@ class SymbolDTO(TypedDict): relative_path: str type: str body: str - quick_info: NotRequired[str | None] + quick_info: NotRequired[str] """Quick info text (e.g., type signature) for the symbol, as HTML string.""" - documentation: NotRequired[str | None] + documentation: NotRequired[str] """Documentation text for the symbol (if available), as HTML string.""" text_range: TextRangeDTO - children: list["SymbolDTO"] | None + children: NotRequired[list["SymbolDTO"]] class SymbolCollectionResponse(TypedDict): symbols: list[SymbolDTO] - documentation: NotRequired[str | None] + documentation: NotRequired[str] """Docstring of the collection (if applicable - usually present only if the collection is from a single file), as HTML string.""" class TypeHierarchyNode(TypedDict): symbol: SymbolDTO - children: list["TypeHierarchyNode"] | None + children: NotRequired[list["TypeHierarchyNode"]] class TypeHierarchy(TypedDict): @@ -44,7 +44,7 @@ class TypeHierarchy(TypedDict): class TypeHierarchyResponse(TypedDict): - symbol: SymbolDTO | None - hierarchy: list[TypeHierarchyNode] | None - error: str | None - num_levels_not_included: int | None + symbol: NotRequired[SymbolDTO] + hierarchy: NotRequired[list[TypeHierarchyNode]] + error: NotRequired[str] + num_levels_not_included: NotRequired[int] From 7be24d185f6e980e6974b6b96dcb5c73942cb9f3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 19:31:30 +0700 Subject: [PATCH 12/35] Rename module (typo) --- src/serena/symbol.py | 2 +- src/serena/tools/jetbrains_plugin_client.py | 2 +- src/serena/tools/jetbrains_tools.py | 2 +- src/serena/tools/{jetbrain_types.py => jetbrains_types.py} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/serena/tools/{jetbrain_types.py => jetbrains_types.py} (100%) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 4f71740a..ad807a95 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Self, Union from sensai.util.string import ToStringMixin -import serena.tools.jetbrain_types as jb +import serena.tools.jetbrains_types as jb from solidlsp import SolidLanguageServer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index e46ecc8f..4d1fea89 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -11,7 +11,7 @@ import requests from requests import Response from sensai.util.string import ToStringMixin -import serena.tools.jetbrain_types as jb +import serena.tools.jetbrains_types as jb from serena.project import Project T = TypeVar("T") diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index f52734f7..45d0675a 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -2,7 +2,7 @@ import logging from collections import defaultdict from typing import Any, Literal -import serena.tools.jetbrain_types as jb +import serena.tools.jetbrains_types as jb from serena.text_utils import render_html from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient diff --git a/src/serena/tools/jetbrain_types.py b/src/serena/tools/jetbrains_types.py similarity index 100% rename from src/serena/tools/jetbrain_types.py rename to src/serena/tools/jetbrains_types.py From 16c6b7eacf8473411f65cdba186e676096684b10 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 3 Jan 2026 20:03:33 +0700 Subject: [PATCH 13/35] Fixes in typed dicts (incorrect translation from java) --- src/serena/tools/jetbrains_tools.py | 2 +- src/serena/tools/jetbrains_types.py | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 45d0675a..9ec7e456 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -194,7 +194,7 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona """ @staticmethod - def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNode] | None) -> dict[str, list]: + def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]: """ Transform a list of TypeHierarchyNode into a file-grouped compact format. diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index d7646bd9..24ad71e8 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -17,12 +17,12 @@ class SymbolDTO(TypedDict): name_path: str relative_path: str type: str - body: str + body: NotRequired[str] quick_info: NotRequired[str] """Quick info text (e.g., type signature) for the symbol, as HTML string.""" documentation: NotRequired[str] """Documentation text for the symbol (if available), as HTML string.""" - text_range: TextRangeDTO + text_range: NotRequired[TextRangeDTO] children: NotRequired[list["SymbolDTO"]] @@ -33,18 +33,13 @@ class SymbolCollectionResponse(TypedDict): as HTML string.""" -class TypeHierarchyNode(TypedDict): +class TypeHierarchyNodeDTO(TypedDict): symbol: SymbolDTO - children: NotRequired[list["TypeHierarchyNode"]] - - -class TypeHierarchy(TypedDict): - nodes: list[TypeHierarchyNode] - depth: int + children: NotRequired[list["TypeHierarchyNodeDTO"]] class TypeHierarchyResponse(TypedDict): symbol: NotRequired[SymbolDTO] - hierarchy: NotRequired[list[TypeHierarchyNode]] + hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] error: NotRequired[str] num_levels_not_included: NotRequired[int] From 5b0705c37365f91c0cf638c29508eef6a145ffb2 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 4 Jan 2026 18:16:58 +0700 Subject: [PATCH 14/35] New field in JB type --- src/serena/tools/jetbrains_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index 24ad71e8..4398f468 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -24,6 +24,7 @@ class SymbolDTO(TypedDict): """Documentation text for the symbol (if available), as HTML string.""" text_range: NotRequired[TextRangeDTO] children: NotRequired[list["SymbolDTO"]] + num_usages: NotRequired[int] class SymbolCollectionResponse(TypedDict): From a69832dae5812a40278d3877dc4ba83a49cd1850 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 4 Jan 2026 23:01:27 +0700 Subject: [PATCH 15/35] Always show at least quick_info (but not docstring) in jb_find_symbol --- src/serena/code_editor.py | 2 +- src/serena/tools/jetbrains_plugin_client.py | 18 ++++++++----- src/serena/tools/jetbrains_tools.py | 30 +++++++++------------ src/serena/tools/jetbrains_types.py | 1 - 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index ac3dca3a..eb60b2bb 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -422,7 +422,7 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): if len(symbols) > 1: raise ValueError( f"Found multiple {len(symbols)} symbols with name {name_path} in file {relative_file_path}. " - "Their locations are: \n " + json.dumps([s["text_range"] for s in symbols], indent=2) + "They are: \n " + json.dumps(symbols, indent=2) ) return JetBrainsSymbol(symbols[0], self._project) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 4d1fea89..19617eb2 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -142,7 +142,9 @@ class JetBrainsPluginClient(ToStringMixin): name_path: str, relative_path: str | None = None, include_body: bool = False, - include_info: bool = True, + include_quick_info: bool = True, + include_documentation: bool = False, + include_num_usages: bool = True, depth: int = 0, include_location: bool = False, search_deps: bool = False, @@ -153,7 +155,9 @@ class JetBrainsPluginClient(ToStringMixin): :param name_path: the name path to match :param relative_path: the relative path to which to restrict the search :param include_body: whether to include symbol body content - :param include_info: whether to include symbol info + :param include_quick_info: whether to include quick info + :param include_documentation: whether to include documentation + :param include_num_usages: whether to include number of usages :param depth: depth of children to include (0 = no children) :param include_location: whether to include symbol location information :param search_deps: whether to also search in dependencies @@ -165,19 +169,21 @@ class JetBrainsPluginClient(ToStringMixin): "depth": depth, "includeLocation": include_location, "searchDeps": search_deps, - "includeInfo": include_info, + "includeQuickInfo": include_quick_info, + "includeDocumentation": include_documentation, + "includeNumUsages": include_num_usages, } return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data)) - def find_references(self, name_path: str, relative_path: str, include_info: bool) -> jb.SymbolCollectionResponse: + def find_references(self, name_path: str, relative_path: str, include_quick_info: bool) -> jb.SymbolCollectionResponse: """ Finds references to a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path - :param include_info: whether to include symbol info + :param include_quick_info: whether to include quick info about references """ - request_data = {"namePath": name_path, "relativePath": relative_path, "includeInfo": include_info} + request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info} return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) def get_symbols_overview(self, relative_path: str, depth: int) -> jb.SymbolCollectionResponse: diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 9ec7e456..ad65e365 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from typing import Any, Literal +from typing import Any, Literal, cast import serena.tools.jetbrains_types as jb from serena.text_utils import render_html @@ -21,7 +21,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): depth: int = 0, relative_path: str | None = None, include_body: bool = False, - include_info: bool = True, + include_info: bool = False, search_deps: bool = False, max_answer_chars: int = -1, ) -> str: @@ -62,27 +62,25 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ if relative_path == ".": relative_path = None - if include_body: - include_info = False # we save tokens and don't include info if body is requested with JetBrainsPluginClient.from_project(self.project) as client: + include_documentation = include_info and not include_body + include_quick_info = not include_documentation and not include_info response_dict = client.find_symbol( name_path=name_path_pattern, relative_path=relative_path, depth=depth, include_body=include_body, - include_info=include_info, + include_documentation=include_documentation, + include_quick_info=include_quick_info, search_deps=search_deps, ) - # massage the response to include only 'info' field if requested (instead of both doc and quick info) - if not include_body and include_info: - symbols = response_dict["symbols"] - for symbol in symbols: - documentation = symbol.pop("documentation", None) - quick_info = symbol.pop("quick_info", None) - info = documentation if documentation else quick_info - if info: - symbol["info"] = render_html(info) # type: ignore + symbols = response_dict["symbols"] + for symbol in symbols: + for doc_field in ("documentation", "quick_info"): + if doc_html := symbol.get(doc_field): + doc_html = cast(str, doc_html) + symbol[doc_field] = render_html(doc_html) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -97,7 +95,6 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark self, name_path: str, relative_path: str, - include_info: bool = True, max_answer_chars: int = -1, ) -> str: """ @@ -107,7 +104,6 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark :param name_path: name path of the symbol for which to find references; matching logic as described in find symbol tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. - :param include_info: whether to include additional info (hover-like, typically including docstring and signature). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: a list of JSON objects with the symbols referencing the requested symbol @@ -116,7 +112,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_info=include_info, + include_quick_info=True, ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index 4398f468..fba344a9 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -40,7 +40,6 @@ class TypeHierarchyNodeDTO(TypedDict): class TypeHierarchyResponse(TypedDict): - symbol: NotRequired[SymbolDTO] hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] error: NotRequired[str] num_levels_not_included: NotRequired[int] From 1b862ee687519e6c1603cbab48bb751057412229 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 5 Jan 2026 11:32:58 +0700 Subject: [PATCH 16/35] Enhanced error message for plugin API errors --- src/serena/tools/jetbrains_plugin_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 19617eb2..14553954 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -108,9 +108,15 @@ class JetBrainsPluginClient(ToStringMixin): except requests.exceptions.Timeout as e: raise ConnectionError(f"Request to {url} timed out: {e}") except requests.exceptions.HTTPError as e: + plugin_error_prefix = ( + "The request failed due to an error on the Serena JetBrains plugin side. This may be caused" + "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " + "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " + "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" + ) if response is not None: - raise APIError(f"API request failed with status {response.status_code}: {response.text}") - raise APIError(f"API request failed with HTTP error: {e}") + raise APIError(plugin_error_prefix + f"API request failed with status {response.status_code}: {response.text}") + raise APIError(plugin_error_prefix + f"API request failed with HTTP error: {e}") except requests.exceptions.RequestException as e: raise SerenaClientError(f"Request failed: {e}") From 7bd70e963121007577bb6c8dc6dd8beefd3449c0 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 13:39:05 +0100 Subject: [PATCH 17/35] Remove TODO --- src/serena/tools/jetbrains_types.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index fba344a9..c5e99b12 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -1,7 +1,5 @@ from typing import NotRequired, TypedDict -# TODO or not TODO: in principle, we could autogenerate these from the java code. For now not worth the effort. - class PositionDTO(TypedDict): line: int From a45500be5fdcdd7a69def9c35af6f849f173c4fd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:07:39 +0100 Subject: [PATCH 18/35] Add plugin error preamble only for non-recoverable errors --- src/serena/tools/jetbrains_plugin_client.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 14553954..3b33ea1a 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -108,15 +108,25 @@ class JetBrainsPluginClient(ToStringMixin): except requests.exceptions.Timeout as e: raise ConnectionError(f"Request to {url} timed out: {e}") except requests.exceptions.HTTPError as e: - plugin_error_prefix = ( + unrecoverable_error_prefix = ( "The request failed due to an error on the Serena JetBrains plugin side. This may be caused" "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" ) + if response is not None: - raise APIError(plugin_error_prefix + f"API request failed with status {response.status_code}: {response.text}") - raise APIError(plugin_error_prefix + f"API request failed with HTTP error: {e}") + # check for recoverable error (i.e. errors where the problem can usually be fixed by the caller). + # The plugin returns 400 for such errors (typically illegal arguments, e.g. non-unique name path) + is_recoverable_error = response.status_code == 400 + + error_text = f"API request failed with status {response.status_code}: {response.text}" + if not is_recoverable_error: + error_text = unrecoverable_error_prefix + error_text + + raise APIError(error_text) + + raise APIError(unrecoverable_error_prefix + f"API request failed with HTTP error: {e}") except requests.exceptions.RequestException as e: raise SerenaClientError(f"Request failed: {e}") From e996e914cfb39b63d79a6bc2440b9910c968d67a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:47:58 +0100 Subject: [PATCH 19/35] Add version util --- src/serena/util/version.py | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/serena/util/version.py diff --git a/src/serena/util/version.py b/src/serena/util/version.py new file mode 100644 index 00000000..d8d8381f --- /dev/null +++ b/src/serena/util/version.py @@ -0,0 +1,85 @@ +class Version: + """ + Represents a version, specifically the numeric components of a version string. + + Suffixes like "rc1" or "-dev" are ignored, i.e. for a version string like "1.2.3rc1", + the components are [1, 2, 3]. + """ + + def __init__(self, package_or_version: object | str): + """ + :param package_or_version: a package object (with a `__version__` attribute) or a version string like "1.2.3". + If a version contains a suffix (like "1.2.3rc1" or "1.2.3-dev"), the suffix is ignored. + """ + if isinstance(package_or_version, str): + version_string = package_or_version + elif hasattr(package_or_version, "__version__"): + package_version_string = getattr(package_or_version, "__version__", None) + if package_version_string is None: + raise ValueError(f"The given package object {package_or_version} has no __version__ attribute") + version_string = package_version_string + else: + raise ValueError("The given argument must be either a version string or a package object with a __version__ attribute") + self.version_string = version_string + self.components = self._get_version_components(version_string) + + def __str__(self) -> str: + return self.version_string + + @staticmethod + def _get_version_components(version_string: str) -> list[int]: + components = version_string.split(".") + int_components = [] + for c in components: + num_str = "" + for ch in c: + if ch.isdigit(): + num_str += ch + else: + break + if num_str == "": + break + int_components.append(int(num_str)) + return int_components + + def is_at_least(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is at least the given version, False otherwise + """ + for i, desired_min_version in enumerate(components): + actual_version = self.components[i] + if actual_version < desired_min_version: + return False + elif actual_version > desired_min_version: + return True + return True + + def is_at_most(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is at most the given version, False otherwise + """ + for i, desired_max_version in enumerate(components): + actual_version = self.components[i] + if actual_version > desired_max_version: + return False + elif actual_version < desired_max_version: + return True + return True + + def is_equal(self, *components: int) -> bool: + """ + Checks this version against the given version components. + This version object must contain at least the respective number of components + + :param components: version components in order (i.e. major, minor, patch, etc.) + :return: True if the version is the given version, False otherwise + """ + return self.components[: len(components)] == list(components) From 559250cfddc91d0fb98daf26a2b3260f7a594493 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:22:53 +0100 Subject: [PATCH 20/35] Get plugin status upon client construction, storing version --- src/serena/tools/jetbrains_plugin_client.py | 34 ++++++++++++--------- src/serena/tools/jetbrains_types.py | 5 +++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 3b33ea1a..74c47dff 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -13,6 +13,8 @@ from sensai.util.string import ToStringMixin import serena.tools.jetbrains_types as jb from serena.project import Project +from serena.tools.jetbrains_types import PluginStatusDTO +from serena.util.version import Version T = TypeVar("T") log = logging.getLogger(__name__) @@ -54,6 +56,17 @@ class JetBrainsPluginClient(ToStringMixin): self.session = requests.Session() self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) + # connect and obtain status + self._project_root: str | None + self._plugin_version: Version | None + try: + status_response: PluginStatusDTO = cast(jb.PluginStatusDTO, self._make_request("GET", "/status")) + self._project_root = status_response["project_root"] + self._plugin_version = Version(status_response["plugin_version"]) + except ConnectionError: + self._project_root = None + self._plugin_version = None + def _tostring_includes(self) -> list[str]: return ["base_url", "timeout"] @@ -76,10 +89,14 @@ class JetBrainsPluginClient(ToStringMixin): raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path)) def matches(self, resolved_path: Path) -> bool: - try: - return Path(self.project_root()).resolve() == resolved_path - except ConnectionError: + if self._project_root is None: return False + return Path(self._project_root).resolve() == resolved_path + + def is_version_at_least(self, *version_parts: int) -> bool: + if self._plugin_version is None: + return False + return self._plugin_version.is_at_least(*version_parts) def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: url = f"{self.base_url}{endpoint}" @@ -149,10 +166,6 @@ class JetBrainsPluginClient(ToStringMixin): return convert(response) - def project_root(self) -> str: - response = self._make_request("GET", "/status") - return response["project_root"] - def find_symbol( self, name_path: str, @@ -288,13 +301,6 @@ class JetBrainsPluginClient(ToStringMixin): } self._make_request("POST", "/refreshFile", request_data) - def is_service_available(self) -> bool: - try: - self.project_root() - return True - except (ConnectionError, APIError): - return False - def close(self) -> None: self.session.close() diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index c5e99b12..c19ba4e6 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -1,6 +1,11 @@ from typing import NotRequired, TypedDict +class PluginStatusDTO(TypedDict): + project_root: str + plugin_version: str + + class PositionDTO(TypedDict): line: int col: int From e3915371b4580bbf4ed92ac448d32e0045fc13f0 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:25:16 +0100 Subject: [PATCH 21/35] Check for (non-)recoverable error only for 2023.2.6+, where status code is differentiated --- src/serena/tools/jetbrains_plugin_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 74c47dff..d5125d3a 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -126,7 +126,7 @@ class JetBrainsPluginClient(ToStringMixin): raise ConnectionError(f"Request to {url} timed out: {e}") except requests.exceptions.HTTPError as e: unrecoverable_error_prefix = ( - "The request failed due to an error on the Serena JetBrains plugin side. This may be caused" + "The request failed due to an error on the Serena JetBrains plugin side. This may be caused " "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" @@ -135,7 +135,11 @@ class JetBrainsPluginClient(ToStringMixin): if response is not None: # check for recoverable error (i.e. errors where the problem can usually be fixed by the caller). # The plugin returns 400 for such errors (typically illegal arguments, e.g. non-unique name path) - is_recoverable_error = response.status_code == 400 + # but only since version 2023.2.6 + if self.is_version_at_least(2023, 2, 6): + is_recoverable_error = response.status_code == 400 + else: + is_recoverable_error = True # assume recoverable for older versions (mix of errors) error_text = f"API request failed with status {response.status_code}: {response.text}" if not is_recoverable_error: From 0da7abd32477c5a1ef74e9ca8eb9de2b27a9fcc9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:55:50 +0100 Subject: [PATCH 22/35] Log JetBrains plugin version for matching client --- src/serena/tools/jetbrains_plugin_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index d5125d3a..34db7c33 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -51,6 +51,7 @@ class JetBrainsPluginClient(ToStringMixin): last_port: int | None = None def __init__(self, port: int, timeout: int = PLUGIN_REQUEST_TIMEOUT): + self._port = port self.base_url = f"http://127.0.0.1:{port}" self.timeout = timeout self.session = requests.Session() @@ -68,7 +69,7 @@ class JetBrainsPluginClient(ToStringMixin): self._plugin_version = None def _tostring_includes(self) -> list[str]: - return ["base_url", "timeout"] + return ["_port", "_project_root", "_plugin_version"] @classmethod def from_project(cls, project: Project) -> Self: @@ -82,7 +83,7 @@ class JetBrainsPluginClient(ToStringMixin): for port in range(cls.BASE_PORT, cls.BASE_PORT + 20): client = JetBrainsPluginClient(port) if client.matches(resolved_path): - log.info("Found JetBrains IDE service at port %d for project %s", port, resolved_path) + log.info("Found matching %s", client) cls.last_port = port return client From 360e8d31c67baeb43b093c167c8ff9603cd29428 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 14:56:59 +0100 Subject: [PATCH 23/35] JetBrainsPluginClient: Consistently use private members --- src/serena/tools/jetbrains_plugin_client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 34db7c33..c5199814 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -52,10 +52,10 @@ class JetBrainsPluginClient(ToStringMixin): def __init__(self, port: int, timeout: int = PLUGIN_REQUEST_TIMEOUT): self._port = port - self.base_url = f"http://127.0.0.1:{port}" - self.timeout = timeout - self.session = requests.Session() - self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) + self._base_url = f"http://127.0.0.1:{port}" + self._timeout = timeout + self._session = requests.Session() + self._session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) # connect and obtain status self._project_root: str | None @@ -100,15 +100,15 @@ class JetBrainsPluginClient(ToStringMixin): return self._plugin_version.is_at_least(*version_parts) def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: - url = f"{self.base_url}{endpoint}" + url = f"{self._base_url}{endpoint}" response: Response | None = None try: if method.upper() == "GET": - response = self.session.get(url, timeout=self.timeout) + response = self._session.get(url, timeout=self._timeout) elif method.upper() == "POST": json_data = json.dumps(data) if data else None - response = self.session.post(url, data=json_data, timeout=self.timeout) + response = self._session.post(url, data=json_data, timeout=self._timeout) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -307,7 +307,7 @@ class JetBrainsPluginClient(ToStringMixin): self._make_request("POST", "/refreshFile", request_data) def close(self) -> None: - self.session.close() + self._session.close() def __enter__(self) -> Self: return self From 04d9abd85481870e10a3ac7512735e1ca479ff01 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 15:15:02 +0100 Subject: [PATCH 24/35] Fix incorrect default in docstring --- src/serena/tools/jetbrains_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index ad65e365..79ee7e00 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -54,7 +54,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): :param include_body: If True, include the symbol's source code. Use judiciously. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), about the symbol (ignored if include_body is True). - Default True, info is never included for child symbols and is not included when body is requested. + Default False; info is never included for child symbols and is not included when body is requested. :param search_deps: If True, also search in project dependencies (e.g., libraries). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. From 25db8275a08ee6a6498a0dfdb6d23c3f8786681a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 15:33:55 +0100 Subject: [PATCH 25/35] JetBrainsPluginClient.find_symbol: Change new flag defaults to False --- src/serena/tools/jetbrains_plugin_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index c5199814..2ce17a55 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -176,9 +176,9 @@ class JetBrainsPluginClient(ToStringMixin): name_path: str, relative_path: str | None = None, include_body: bool = False, - include_quick_info: bool = True, + include_quick_info: bool = False, include_documentation: bool = False, - include_num_usages: bool = True, + include_num_usages: bool = False, depth: int = 0, include_location: bool = False, search_deps: bool = False, From e1e7c233edf6bbea7ff3db486d6873f9bbc97396 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 15:48:46 +0100 Subject: [PATCH 26/35] JetBrainsPluginClient.get_symbols_overview: Add parameter include_file_documentation --- src/serena/tools/jetbrains_plugin_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 2ce17a55..7bf9894b 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -220,12 +220,13 @@ class JetBrainsPluginClient(ToStringMixin): request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info} return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) - def get_symbols_overview(self, relative_path: str, depth: int) -> jb.SymbolCollectionResponse: + def get_symbols_overview(self, relative_path: str, depth: int, include_file_documentation: bool = False) -> jb.SymbolCollectionResponse: """ :param relative_path: the relative path to a source file :param depth: the depth of children to include (0 = no children) + :param include_file_documentation: whether to include the file's documentation string (if any) """ - request_data = {"relativePath": relative_path, "depth": depth} + request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation} return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) def get_supertypes( From 18a9a13f69809a07330174be912133b77c8ff02d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 16:02:16 +0100 Subject: [PATCH 27/35] Add type jb.GetSymbolsOverviewResponse --- src/serena/tools/jetbrains_plugin_client.py | 6 ++++-- src/serena/tools/jetbrains_types.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 7bf9894b..0cd236f3 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -220,14 +220,16 @@ class JetBrainsPluginClient(ToStringMixin): request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info} return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) - def get_symbols_overview(self, relative_path: str, depth: int, include_file_documentation: bool = False) -> jb.SymbolCollectionResponse: + def get_symbols_overview( + self, relative_path: str, depth: int, include_file_documentation: bool = False + ) -> jb.GetSymbolsOverviewResponse: """ :param relative_path: the relative path to a source file :param depth: the depth of children to include (0 = no children) :param include_file_documentation: whether to include the file's documentation string (if any) """ request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation} - return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) + return cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) def get_supertypes( self, diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index c19ba4e6..f71874c3 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -32,6 +32,9 @@ class SymbolDTO(TypedDict): class SymbolCollectionResponse(TypedDict): symbols: list[SymbolDTO] + + +class GetSymbolsOverviewResponse(SymbolCollectionResponse): documentation: NotRequired[str] """Docstring of the collection (if applicable - usually present only if the collection is from a single file), as HTML string.""" From 76834ddf32324a625fa162fec6a2578b2e00f9be Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 17:29:46 +0100 Subject: [PATCH 28/35] JetBrainsFindReferencingSymbolsTool: set include_quick_info to False by default Signature info is not generally helpful when looking for references --- src/serena/tools/jetbrains_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 79ee7e00..01d46a20 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -112,7 +112,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_quick_info=True, + include_quick_info=False, ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) From 01d0f26164d24c764c3e1f42cdf16206c4e4af18 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 17:46:17 +0100 Subject: [PATCH 29/35] Post-process symbol collections in the client (implementing it centrally for all affected API calls) --- src/serena/tools/jetbrains_plugin_client.py | 37 ++++++++++++++++++--- src/serena/tools/jetbrains_tools.py | 9 +---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 0cd236f3..c014efca 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -5,7 +5,7 @@ Client for the Serena JetBrains Plugin import json import logging from pathlib import Path -from typing import Any, Optional, Self, TypeVar, cast +from typing import Any, Literal, Optional, Self, TypeVar, cast import requests from requests import Response @@ -13,6 +13,7 @@ from sensai.util.string import ToStringMixin import serena.tools.jetbrains_types as jb from serena.project import Project +from serena.text_utils import render_html from serena.tools.jetbrains_types import PluginStatusDTO from serena.util.version import Version @@ -171,6 +172,28 @@ class JetBrainsPluginClient(ToStringMixin): return convert(response) + def _postprocess_symbol_collection_response(self, response_dict: jb.SymbolCollectionResponse) -> None: + """ + Postprocesses a symbol collection response in-place, converting HTML documentation to plain text. + + :param response_dict: the response dictionary + """ + + def convert_html(key: Literal["documentation", "quick_info"], symbol: jb.SymbolDTO) -> None: + if key in symbol: + doc_html: str = symbol[key] + doc_text = render_html(doc_html) + if doc_text: + symbol[key] = doc_text + else: + del symbol[key] + + # convert documentation and quick info from HTML to plain text (if present) + symbols = response_dict["symbols"] + for s in symbols: + convert_html("documentation", s) + convert_html("quick_info", s) + def find_symbol( self, name_path: str, @@ -207,7 +230,9 @@ class JetBrainsPluginClient(ToStringMixin): "includeDocumentation": include_documentation, "includeNumUsages": include_num_usages, } - return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data)) + symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data)) + self._postprocess_symbol_collection_response(symbol_collection) + return symbol_collection def find_references(self, name_path: str, relative_path: str, include_quick_info: bool) -> jb.SymbolCollectionResponse: """ @@ -218,7 +243,9 @@ class JetBrainsPluginClient(ToStringMixin): :param include_quick_info: whether to include quick info about references """ request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info} - return cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) + symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) + self._postprocess_symbol_collection_response(symbol_collection) + return symbol_collection def get_symbols_overview( self, relative_path: str, depth: int, include_file_documentation: bool = False @@ -229,7 +256,9 @@ class JetBrainsPluginClient(ToStringMixin): :param include_file_documentation: whether to include the file's documentation string (if any) """ request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation} - return cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) + response = cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) + self._postprocess_symbol_collection_response(response) + return response def get_supertypes( self, diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 01d46a20..c7d38c5f 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from typing import Any, Literal, cast +from typing import Any, Literal import serena.tools.jetbrains_types as jb from serena.text_utils import render_html @@ -74,13 +74,6 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): include_quick_info=include_quick_info, search_deps=search_deps, ) - - symbols = response_dict["symbols"] - for symbol in symbols: - for doc_field in ("documentation", "quick_info"): - if doc_html := symbol.get(doc_field): - doc_html = cast(str, doc_html) - symbol[doc_field] = render_html(doc_html) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) From 337472a33a2b1f5a799304f124ab13777133f3f5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 8 Jan 2026 18:10:30 +0100 Subject: [PATCH 30/35] Postprocess HTML documentation in symbol overviews within the client, not the tool --- src/serena/tools/jetbrains_plugin_client.py | 5 +++++ src/serena/tools/jetbrains_tools.py | 19 +++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index c014efca..a4cad01e 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -258,6 +258,11 @@ class JetBrainsPluginClient(ToStringMixin): request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation} response = cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) self._postprocess_symbol_collection_response(response) + + # process file documentation + if "documentation" in response: + response["documentation"] = render_html(response["documentation"]) + return response def get_supertypes( diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index c7d38c5f..1b7fc90a 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -3,7 +3,6 @@ from collections import defaultdict from typing import Any, Literal import serena.tools.jetbrains_types as jb -from serena.text_utils import render_html from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient @@ -117,7 +116,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp """ @staticmethod - def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]: + def _transform_symbols_to_compact_format(symbols: list[jb.SymbolDTO]) -> dict[str, list]: """ Transform symbol overview from verbose format to compact grouped format. @@ -143,7 +142,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp result[kind].append({name: children_dict}) else: # Symbol has no children: just add the name - result[kind].append(name) + result[kind].append(name) # type: ignore return result @@ -167,14 +166,14 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp :return: a JSON object containing the symbols grouped by kind in a compact format. """ with JetBrainsPluginClient.from_project(self.project) as client: - response_dict = client.get_symbols_overview(relative_path=relative_path, depth=depth) - documentation = response_dict.pop("documentation", None) + symbol_overview = client.get_symbols_overview(relative_path=relative_path, depth=depth, include_file_documentation=False) + symbols = symbol_overview["symbols"] + result: dict[str, Any] = {"symbols": self._transform_symbols_to_compact_format(symbols)} + documentation = symbol_overview.pop("documentation", None) if documentation: - response_dict["docstring"] = render_html(documentation) # type: ignore - symbols = response_dict["symbols"] - response_dict["symbols"] = self._transform_symbols_to_compact_format(symbols) # type: ignore - result = self._to_json(response_dict) - return self._limit_length(result, max_answer_chars) + result["docstring"] = documentation + json_result = self._to_json(result) + return self._limit_length(json_result, max_answer_chars) class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): From a5f586fda255290601c585fd75317cc6094e79f6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 9 Jan 2026 13:34:57 +0100 Subject: [PATCH 31/35] Add version check for new features, telling the caller to update --- src/serena/tools/jetbrains_plugin_client.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index a4cad01e..d6debed7 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -100,6 +100,19 @@ class JetBrainsPluginClient(ToStringMixin): return False return self._plugin_version.is_at_least(*version_parts) + def _require_version_at_least(self, *version_parts: int) -> None: + """ + Ensures that the plugin version is at least the given version and raises an error otherwise. + + :param version_parts: the minimum required version parts (major, minor, patch) + """ + if not self.is_version_at_least(*version_parts): + raise SerenaClientError( + f"This operation requires Serena JetBrains plugin version " + f"{'.'.join(map(str, version_parts))} or higher, but the current version is " + f"{self._plugin_version}. Ask the user to update the plugin!" + ) + def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: url = f"{self._base_url}{endpoint}" @@ -280,6 +293,7 @@ class JetBrainsPluginClient(ToStringMixin): :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level """ + self._require_version_at_least(2023, 2, 6) request_data = { "namePath": name_path, "relativePath": relative_path, @@ -303,6 +317,7 @@ class JetBrainsPluginClient(ToStringMixin): :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level """ + self._require_version_at_least(2023, 2, 6) request_data = { "namePath": name_path, "relativePath": relative_path, From 32a97eb306f2f7d74aae02fa0369b3c56f22df8f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 9 Jan 2026 18:21:34 +0100 Subject: [PATCH 32/35] Do not overwrite input variable for flexibility --- src/serena/text_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serena/text_utils.py b/src/serena/text_utils.py index 3c62e30a..c36dce78 100644 --- a/src/serena/text_utils.py +++ b/src/serena/text_utils.py @@ -408,15 +408,15 @@ def search_files( return matches -def render_html(text: str) -> str: +def render_html(html: str) -> str: """ Remove HTML tags and decode HTML entities from text while preserving the actual content. This keeps type information and structure but removes all formatting. - :param text: HTML text to clean + :param html: HTML text to clean :return: Plain text without HTML tags and with decoded entities """ - soup = BeautifulSoup(text, "html.parser") + soup = BeautifulSoup(html, "html.parser") # join text with spaces to avoid concatenation of words text = soup.get_text(separator=" ", strip=True) From d58cc581952193e34069c16fb7adfb99611317e6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 9 Jan 2026 18:22:14 +0100 Subject: [PATCH 33/35] JetBrainsTypeHierarchyTool: Change default type to 'both' --- src/serena/tools/jetbrains_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 1b7fc90a..e26bee27 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -218,7 +218,7 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona self, name_path: str, relative_path: str, - hierarchy_type: Literal["super", "sub", "both"] = "sub", + hierarchy_type: Literal["super", "sub", "both"] = "both", depth: int | None = 1, max_answer_chars: int = -1, ) -> str: From 021572f572ddfe490514058afcdedd2cc0e1b633 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 9 Jan 2026 18:22:59 +0100 Subject: [PATCH 34/35] JetBrainsTypeHierarchyTool: Remove error handling (now auto-handled via exception) --- src/serena/tools/jetbrains_tools.py | 4 ---- src/serena/tools/jetbrains_types.py | 1 - 2 files changed, 5 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index e26bee27..9e716616 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -245,8 +245,6 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona relative_path=relative_path, depth=depth, ) - if error_msg := supertypes_response.get("error"): - return error_msg if "num_levels_not_included" in supertypes_response: levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"] supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) @@ -257,8 +255,6 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona relative_path=relative_path, depth=depth, ) - if error_msg := subtypes_response.get("error"): - return error_msg if "num_levels_not_included" in subtypes_response: levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index f71874c3..80f02947 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -47,5 +47,4 @@ class TypeHierarchyNodeDTO(TypedDict): class TypeHierarchyResponse(TypedDict): hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] - error: NotRequired[str] num_levels_not_included: NotRequired[int] From b10f36f1c8c35c9c1ec40107c967d67980949654 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 9 Jan 2026 23:28:05 +0100 Subject: [PATCH 35/35] Introduce dedicated exception classes * for expected API errors (that shall simply be passed on) * for unexpected server errors (that may need to be reported) --- src/serena/tools/jetbrains_plugin_client.py | 47 +++++++++++++-------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index d6debed7..0bbefddc 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -30,7 +30,28 @@ class ConnectionError(SerenaClientError): class APIError(SerenaClientError): - """Raised when the API returns an error response.""" + """Raised when the API returns an error response that shall be reported on the caller/LLM.""" + + def __init__(self, response: Response): + try: + error_data = response.json() + message = error_data.get("error", response.text) + except: + message = response.text + super().__init__(message) + + +class PluginServerError(SerenaClientError): + """Raised when the plugin's service encounters an internal error (which may need to be reported).""" + + def __init__(self, message: str): + unrecoverable_error_prefix = ( + "The request failed due to an error on the Serena JetBrains plugin side. This may be caused " + "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " + "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " + "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" + ) + super().__init__(unrecoverable_error_prefix + message) class ServerNotFoundError(Exception): @@ -109,7 +130,7 @@ class JetBrainsPluginClient(ToStringMixin): if not self.is_version_at_least(*version_parts): raise SerenaClientError( f"This operation requires Serena JetBrains plugin version " - f"{'.'.join(map(str, version_parts))} or higher, but the current version is " + f"{'.'.join(map(str, version_parts))} or higher, but the installed version is " f"{self._plugin_version}. Ask the user to update the plugin!" ) @@ -140,29 +161,19 @@ class JetBrainsPluginClient(ToStringMixin): except requests.exceptions.Timeout as e: raise ConnectionError(f"Request to {url} timed out: {e}") except requests.exceptions.HTTPError as e: - unrecoverable_error_prefix = ( - "The request failed due to an error on the Serena JetBrains plugin side. This may be caused " - "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " - "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " - "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" - ) - if response is not None: - # check for recoverable error (i.e. errors where the problem can usually be fixed by the caller). + # check for recoverable error (i.e. errors where the problem can be resolved by the caller or + # other errors where the error text shall simply be passed on to the LLM). # The plugin returns 400 for such errors (typically illegal arguments, e.g. non-unique name path) # but only since version 2023.2.6 if self.is_version_at_least(2023, 2, 6): is_recoverable_error = response.status_code == 400 else: is_recoverable_error = True # assume recoverable for older versions (mix of errors) - - error_text = f"API request failed with status {response.status_code}: {response.text}" - if not is_recoverable_error: - error_text = unrecoverable_error_prefix + error_text - - raise APIError(error_text) - - raise APIError(unrecoverable_error_prefix + f"API request failed with HTTP error: {e}") + if is_recoverable_error: + raise APIError(response) + raise PluginServerError(f"API request failed with status {response.status_code}: {response.text}") + raise PluginServerError(f"API request failed with HTTP error: {e}") except requests.exceptions.RequestException as e: raise SerenaClientError(f"Request failed: {e}")