From 3768cb6e3192ef47d8ac0b60b82da5dea82c84b3 Mon Sep 17 00:00:00 2001 From: Roman Nikulenkov Date: Mon, 19 Jan 2026 12:39:19 +0300 Subject: [PATCH 01/16] Add include_info hover budget configuration and validation - Introduced `include_info_hover_budget_seconds` to ProjectConfig and SerenaConfig for per-project and global hover request time limits. - Updated YAML templates to include hover budget settings with appropriate documentation. - Enhanced LanguageServerSymbolRetriever to respect hover budget during symbol info requests, allowing for partial results if the budget is exceeded. - Added unit tests to validate hover budget behavior and ensure correct handling of various budget scenarios. Change log level from error to debug for performance logging in LanguageServerSymbolRetriever and SolidLanguageServer Change log level from debug to error for performance logging in LanguageServerSymbolRetriever and SolidLanguageServer Enhance symbol retrieval by adding batch request method and optimize hover info fetching + DEBUG --- src/serena/config/serena_config.py | 38 +++ src/serena/resources/project.template.yml | 5 + .../resources/serena_config.template.yml | 5 + src/serena/symbol.py | 132 +++++++++++ src/serena/tools/symbol_tools.py | 13 +- src/solidlsp/ls.py | 71 ++++-- test/serena/config/test_serena_config.py | 61 +++++ test/serena/test_symbol.py | 223 ++++++++++++++++++ 8 files changed, 531 insertions(+), 17 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index f449a64f..37cdc768 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -164,6 +164,12 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + include_info_hover_budget_seconds: float | None = None + """ + Per-project override for include_info hover budget (seconds). + + If None, global config is used. 0 disables the budget. Negative values are invalid. + """ SERENA_DEFAULT_PROJECT_FILE = "project.yml" @@ -267,6 +273,8 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True) data["initial_prompt"] = data.get("initial_prompt", "") data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING) + # Keep None if missing to inherit from global config. + data.setdefault("include_info_hover_budget_seconds", None) # backward compatibility: handle single "language" field if len(data["languages"]) == 0 and "language" in data: @@ -309,6 +317,17 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e + # Validate include_info_hover_budget_seconds + hover_budget_raw = data["include_info_hover_budget_seconds"] + hover_budget = hover_budget_raw + if hover_budget is not None: + try: + hover_budget = float(hover_budget_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"include_info_hover_budget_seconds must be a number or null, got: {hover_budget_raw}") from e + if hover_budget < 0: + raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") + return cls( project_name=data["project_name"], languages=languages, @@ -319,6 +338,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], + include_info_hover_budget_seconds=hover_budget, ) def to_yaml_dict(self) -> dict: @@ -434,6 +454,14 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + include_info_hover_budget_seconds: float = 5.0 + """ + Time budget (seconds) for LSP hover requests when tools request include_info. + + If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. + 0 disables the budget (no early stopping). Negative values are invalid. + """ + # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None @@ -574,6 +602,16 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars") instance.ls_specific_settings = get_value_or_default("ls_specific_settings") + # Load and validate include_info_hover_budget_seconds + hover_budget_raw = get_value_or_default("include_info_hover_budget_seconds") + try: + hover_budget = float(hover_budget_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"include_info_hover_budget_seconds must be a number, got: {hover_budget_raw}") from e + if hover_budget < 0: + raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") + instance.include_info_hover_budget_seconds = hover_budget + # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file") diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a0f79106..f777b75d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -84,4 +84,9 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: "" +# Per-project override for include_info hover budget (seconds). +# If null or missing, global config is used. +# 0 disables the budget (no early stopping). Negative values are invalid. +include_info_hover_budget_seconds: null + project_name: "project_name" diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index ddb41e35..d65baa08 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -91,5 +91,10 @@ token_count_estimator: CHAR_COUNT # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. +include_info_hover_budget_seconds: 5 +# Time budget (seconds) for LSP hover requests when tools request include_info. +# If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. + # The list of registered project paths (updated automatically). projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py index ad807a95..3c6d50e6 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterator, Sequence from dataclasses import asdict, dataclass +from time import perf_counter from typing import TYPE_CHECKING, Any, Self, Union from sensai.util.string import ToStringMixin @@ -507,7 +508,9 @@ class LanguageServerSymbolRetriever: 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"] + # Handle various response formats if isinstance(contents, list): # Array format: extract all parts and join them @@ -519,10 +522,13 @@ class LanguageServerSymbolRetriever: # 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_symbol(self, symbol: LanguageServerSymbol) -> str | None: @@ -530,6 +536,132 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] + def request_info_for_symbols( + self, + symbols: list[LanguageServerSymbol], + ) -> dict[LanguageServerSymbol, str | None]: + """Retrieves information for multiple symbols, optimizing by grouping by file. + + Groups symbols by file path to minimize file switching overhead and uses a per-file + cache keyed by (line, col) to avoid duplicate hover lookups. + + The hover budget (include_info_hover_budget_seconds) limits total time spent on hover + requests. If exceeded, remaining symbols get info=None (partial results). + + :param symbols: list of symbols to get info for + :return: a dict mapping each symbol to its info (or None if unavailable) + """ + if not symbols: + return {} + + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_total = perf_counter() if debug_enabled else 0.0 + + # Resolve effective hover budget: project -> global -> default 5.0 + budget_seconds = 5.0 + if self.agent is not None: + budget_seconds = self.agent.serena_config.include_info_hover_budget_seconds + active_project = self.agent.get_active_project() + if active_project is not None: + project_budget = active_project.project_config.include_info_hover_budget_seconds + if project_budget is not None: + budget_seconds = project_budget + + info_by_symbol: dict[LanguageServerSymbol, str | None] = {} + skipped_symbols = 0 + detail_present_count = 0 + + # Group symbols by file path, filtering invalid symbols. + symbols_by_file: dict[str, list[LanguageServerSymbol]] = {} + for sym in symbols: + detail = sym.symbol_root.get("detail") + if isinstance(detail, str) and detail.strip(): + detail_present_count += 1 + + file_path = sym.relative_path + line = sym.line + column = sym.column + if file_path is None or line is None or column is None: + skipped_symbols += 1 + continue + + symbols_by_file.setdefault(file_path, []).append(sym) + + per_file_stats: list[tuple[str, int, float]] = [] + total_hover_lookups = 0 + hover_cache_hits = 0 + hover_spent_seconds = 0.0 + skipped_due_to_budget = 0 + + for file_path, file_symbols in symbols_by_file.items(): + t0_file = perf_counter() if debug_enabled else 0.0 + hover_cache: dict[tuple[int, int], str | None] = {} + file_hover_lookups = 0 + + for sym in file_symbols: + line = sym.line + column = sym.column + assert line is not None and column is not None + + cache_key = (line, column) + if cache_key in hover_cache: + hover_cache_hits += 1 + info = hover_cache[cache_key] + else: + # Check budget before starting new hover request + if budget_seconds > 0 and hover_spent_seconds >= budget_seconds: + skipped_due_to_budget += 1 + info = None + else: + t0_hover = perf_counter() + info = self._request_info(file_path, line, column) + hover_spent_seconds += perf_counter() - t0_hover + file_hover_lookups += 1 + total_hover_lookups += 1 + + hover_cache[cache_key] = info + + info_by_symbol[sym] = info + + if debug_enabled: + file_elapsed_ms = (perf_counter() - t0_file) * 1000 + per_file_stats.append((file_path, file_hover_lookups, file_elapsed_ms)) + + if debug_enabled: + total_elapsed_ms = (perf_counter() - t0_total) * 1000 + total_symbols = len(symbols) + unique_files = len(symbols_by_file) + detail_rate = (detail_present_count / total_symbols * 100) if total_symbols > 0 else 0.0 + budget_exceeded = skipped_due_to_budget > 0 + + log.debug( + "perf: request_info_for_symbols total_elapsed_ms=%.2f symbols=%d skipped=%d " + "detail_present=%d detail_rate=%.1f%% hover_lookups=%d hover_cache_hits=%d unique_files=%d " + "hover_budget_seconds=%.1f hover_spent_seconds=%.2f budget_exceeded=%s skipped_due_to_budget=%d", + total_elapsed_ms, + total_symbols, + skipped_symbols, + detail_present_count, + detail_rate, + total_hover_lookups, + hover_cache_hits, + unique_files, + budget_seconds, + hover_spent_seconds, + budget_exceeded, + skipped_due_to_budget, + ) + + for file_path, lookup_count, elapsed_ms in per_file_stats: + log.debug( + "perf: request_info_for_symbols file=%s hover_count=%d elapsed_ms=%.2f", + file_path, + lookup_count, + elapsed_ms, + ) + + return info_by_symbol + 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 2c0ec882..bed53273 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -179,9 +179,9 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): ) 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 + info_by_symbol = symbol_retriever.request_info_for_symbols(symbols) for s, s_dict in zip(symbols, symbol_dicts, strict=True): - if symbol_info := symbol_retriever.request_info_for_symbol(s): + if symbol_info := info_by_symbol.get(s): s_dict["info"] = symbol_info s_dict.pop("name", None) # name is included in the info result = self._to_json(symbol_dicts) @@ -228,6 +228,13 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, ) + + # Batch-request hover info for referencing symbols (optional and potentially slow). + info_by_symbol = {} + if not include_body and include_info: + ref_symbols = [ref.symbol for ref in references_in_symbols] + info_by_symbol = symbol_retriever.request_info_for_symbols(ref_symbols) + reference_dicts = [] for ref in references_in_symbols: ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body) @@ -235,7 +242,7 @@ 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)): + if include_info and (referencing_symbol_info := info_by_symbol.get(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 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 396ba533..fe5de74a 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,7 +13,7 @@ from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath -from time import sleep +from time import perf_counter, sleep from typing import Self, Union, cast import pathspec @@ -820,6 +820,8 @@ class SolidLanguageServer(ABC): sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0 = perf_counter() if debug_enabled else 0.0 with self.open_file(relative_file_path): try: response = self._send_references_request(relative_file_path, line=line, column=column) @@ -832,6 +834,9 @@ class SolidLanguageServer(ABC): ) from e raise if response is None: + if debug_enabled: + elapsed_ms = (perf_counter() - t0) * 1000 + log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms) return [] ret: list[ls_types.Location] = [] @@ -860,6 +865,17 @@ class SolidLanguageServer(ABC): new_item["relativePath"] = str(rel_path) ret.append(ls_types.Location(**new_item)) # type: ignore + if debug_enabled: + elapsed_ms = (perf_counter() - t0) * 1000 + unique_files = len({r["relativePath"] for r in ret}) + log.debug( + "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", + relative_file_path, + elapsed_ms, + len(ret), + unique_files, + ) + return ret def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]: @@ -1033,15 +1049,22 @@ class SolidLanguageServer(ABC): def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) - if file_hash_and_result is not None: - file_hash, result = file_hash_and_result - if file_hash == fd.content_hash: - log.debug("Returning cached raw document symbols for %s", relative_file_path) - return result - else: - log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) - else: + if file_hash_and_result is None: log.debug("No cache hit for raw document symbols symbols in %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) + return None + + file_hash, result = file_hash_and_result + if file_hash == fd.content_hash: + log.debug("Returning cached raw document symbols for %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) + return result + + log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) return None def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: @@ -1086,15 +1109,21 @@ class SolidLanguageServer(ABC): # check if the desired result is cached cache_key = relative_file_path file_hash_and_result = self._document_symbols_cache.get(cache_key) - if file_hash_and_result is not None: + if file_hash_and_result is None: + log.debug("No cache hit for document symbols in %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) + else: file_hash, document_symbols = file_hash_and_result if file_hash == file_data.content_hash: log.debug("Returning cached document symbols for %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) return document_symbols - else: - log.debug("Cached document symbol content for %s has changed", relative_file_path) - else: - log.debug("No cache hit for document symbols in %s", relative_file_path) + + log.debug("Cached document symbol content for %s has changed", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) # no cached result: request the root symbols from the language server root_symbols = self._request_document_symbols(relative_file_path, file_data) @@ -1543,6 +1572,8 @@ class SolidLanguageServer(ABC): if not references: return [] + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_loop = perf_counter() if debug_enabled else 0.0 # For each reference, find the containing symbol result = [] incoming_symbol = None @@ -1643,6 +1674,18 @@ class SolidLanguageServer(ABC): result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + if debug_enabled: + loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 + unique_files = len({r.symbol["location"]["relativePath"] for r in result}) + log.debug( + "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", + relative_file_path, + loop_elapsed_ms, + len(references), + len(result), + unique_files, + ) + return result def request_containing_symbol( diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 048b3dbc..4fff1fa6 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -117,3 +117,64 @@ class TestProjectConfigAutogenerate: assert config.project_name == custom_name assert config.languages == [Language.TYPESCRIPT] + + +class TestProjectConfigHoverBudget: + """Test class for include_info_hover_budget_seconds validation in ProjectConfig.""" + + def _base_project_dict(self, **overrides): + data = { + "project_name": "test_project", + "languages": ["python"], + "ignored_paths": [], + "excluded_tools": [], + "included_optional_tools": [], + "read_only": False, + "ignore_all_files_in_gitignore": True, + "initial_prompt": "", + "encoding": "utf-8", + "include_info_hover_budget_seconds": None, + } + data.update(overrides) + return data + + def test_from_dict_accepts_null_budget(self): + """Test that _from_dict accepts null hover budget (for inheritance).""" + data = self._base_project_dict() + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds is None + + def test_from_dict_accepts_positive_budget(self): + """Test that _from_dict accepts positive hover budget.""" + data = self._base_project_dict(include_info_hover_budget_seconds=10.5) + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds == 10.5 + + def test_from_dict_accepts_zero_budget(self): + """Test that _from_dict accepts zero hover budget (disabled).""" + data = self._base_project_dict(include_info_hover_budget_seconds=0) + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds == 0.0 + + def test_from_dict_rejects_negative_budget(self): + """Test that _from_dict raises ValueError for negative hover budget.""" + data = self._base_project_dict(include_info_hover_budget_seconds=-1.0) + with pytest.raises(ValueError) as exc_info: + ProjectConfig._from_dict(data) + assert "cannot be negative" in str(exc_info.value) + + def test_apply_defaults_preserves_null(self): + """Test that _apply_defaults_to_dict preserves None for hover budget.""" + data = {"project_name": "test", "languages": ["python"]} + result = ProjectConfig._apply_defaults_to_dict(data) + assert result["include_info_hover_budget_seconds"] is None + + def test_apply_defaults_preserves_explicit_value(self): + """Test that _apply_defaults_to_dict preserves explicit hover budget value.""" + data = { + "project_name": "test", + "languages": ["python"], + "include_info_hover_budget_seconds": 15.0, + } + result = ProjectConfig._apply_defaults_to_dict(data) + assert result["include_info_hover_budget_seconds"] == 15.0 diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 77558ecd..adb2cece 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from serena.symbol import LanguageServerSymbolRetriever, NamePathMatcher @@ -188,3 +190,224 @@ class TestLanguageServerSymbolRetriever: 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 + + +def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.py") -> list[MagicMock]: + symbols: list[MagicMock] = [] + for i in range(count): + sym = MagicMock() + sym.relative_path = relative_path + sym.line = i + 1 + sym.column = 0 + sym.symbol_root = {} + symbols.append(sym) + return symbols + + +@pytest.mark.python +class TestHoverBudget: + """Tests for include_info_hover_budget_seconds time budget behavior.""" + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_not_exceeded_all_lookups_performed(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With a large budget, all hover lookups are performed.""" + # Create symbol retriever with a mock agent that has large budget + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # All 3 symbols should have info (no budget exceeded) + assert call_count == 3 + assert all(info is not None for info in result.values()) + assert len(result) == 3 + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_exceeded_partial_info(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With a small budget, hover lookups stop and remaining symbols get None info.""" + # Create symbol retriever with a mock agent that has small budget (0.1s) + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 0.1 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls and simulate 0.05s per call + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + # Simulate each hover taking 0.05s + simulated_time[0] += 0.05 + return f"info:{line}:{column}" + + # Mock perf_counter to return simulated time for hover duration + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Budget is 0.1s, each call takes 0.05s, so only 2 calls should succeed + # After 2 calls: 0.1s >= 0.1s budget, remaining 3 should be skipped + assert call_count == 2 + assert len(result) == 5 + + # First 2 symbols should have info, last 3 should be None + result_list = list(result.values()) + assert result_list[0] is not None + assert result_list[1] is not None + assert result_list[2] is None + assert result_list[3] is None + assert result_list[4] is None + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_zero_means_unlimited(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With budget=0, all hover lookups proceed (no early stopping).""" + # Create symbol retriever with budget=0 (unlimited) + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 0.0 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # All 5 symbols should be looked up (no budget limit) + assert call_count == 5 + assert all(info is not None for info in result.values()) + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_project_budget_overrides_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """Project-level budget overrides global budget.""" + # Create symbol retriever with global budget 10.0 but project budget 0.05 + mock_project = MagicMock() + mock_project.project_config.include_info_hover_budget_seconds = 0.05 + + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = mock_project + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls and simulate time + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + simulated_time[0] += 0.03 + return f"info:{line}:{column}" + + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols + symbols = _make_mock_symbols(5) + + symbol_retriever.request_info_for_symbols(symbols) + + # Project budget is 0.05s, each call takes 0.03s + # Budget check happens BEFORE starting a new call: + # - Before call 1: spent=0 < 0.05, proceed, spent becomes 0.03 + # - Before call 2: spent=0.03 < 0.05, proceed, spent becomes 0.06 + # - Before call 3: spent=0.06 >= 0.05, skip + # So 2 calls succeed (proving project budget 0.05 overrode global 10.0) + assert call_count == 2 + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_project_null_inherits_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """When project budget is None, global budget is used.""" + # Create symbol retriever with project budget=None (inherit global) + mock_project = MagicMock() + mock_project.project_config.include_info_hover_budget_seconds = None + + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = mock_project + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create 3 mock symbols + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Global budget is 10s, all 3 should succeed + assert call_count == 3 + assert all(info is not None for info in result.values()) + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_no_agent_uses_default_budget(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """When agent is None, default budget of 5s is used.""" + # Create symbol retriever without agent + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=None) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create 3 mock symbols + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Default budget is 5s, all 3 should succeed + assert call_count == 3 + assert all(info is not None for info in result.values()) From 44696c6362490107a932cc2b479b878176c02910 Mon Sep 17 00:00:00 2001 From: Roman Nikulenkov Date: Mon, 19 Jan 2026 13:01:46 +0300 Subject: [PATCH 02/16] Update copilot instructions to emphasize immediate compliance and critical importance --- .github/copilot-instructions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d359e4f7..15a50f1f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1,3 @@ -MUST use the project-specific instructions from the `CLAUDE.md` file located in the project root. \ No newline at end of file + +MUST read IMMEDIATELY and follow the project-specific instructions from the `CLAUDE.md` file located in the project's root directory. AVOIDING these instructions will lead to your FAILURE! + \ No newline at end of file From e9cca73889ebe8af3021541575027da6ce1acc24 Mon Sep 17 00:00:00 2001 From: Roman Nikulenkov Date: Mon, 19 Jan 2026 14:06:58 +0300 Subject: [PATCH 03/16] Improve docstring for LanguageServerSymbolRetriever to clarify symbol processing criteria --- src/serena/symbol.py | 2 +- src/solidlsp/ls.py | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 3c6d50e6..e0b08727 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -549,7 +549,7 @@ class LanguageServerSymbolRetriever: requests. If exceeded, remaining symbols get info=None (partial results). :param symbols: list of symbols to get info for - :return: a dict mapping each symbol to its info (or None if unavailable) + :return: a dict mapping each processable symbol to its info (or None if unavailable). Symbols with missing location attributes (relative_path/line/column is None) are skipped and omitted from the result. """ if not symbols: return {} diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index fe5de74a..0dd83264 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1050,21 +1050,18 @@ class SolidLanguageServer(ABC): def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) if file_hash_and_result is None: - log.debug("No cache hit for raw document symbols symbols in %s", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) + log.debug("No cache hit for raw document symbols in %s", relative_file_path) + log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) return None file_hash, result = file_hash_and_result if file_hash == fd.content_hash: log.debug("Returning cached raw document symbols for %s", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) + log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) return result log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) + log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) return None def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: @@ -1111,19 +1108,16 @@ class SolidLanguageServer(ABC): file_hash_and_result = self._document_symbols_cache.get(cache_key) if file_hash_and_result is None: log.debug("No cache hit for document symbols in %s", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) + log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) else: file_hash, document_symbols = file_hash_and_result if file_hash == file_data.content_hash: log.debug("Returning cached document symbols for %s", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) + log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) return document_symbols log.debug("Cached document symbol content for %s has changed", relative_file_path) - if log.isEnabledFor(logging.DEBUG): - log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) + log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) # no cached result: request the root symbols from the language server root_symbols = self._request_document_symbols(relative_file_path, file_data) From f19f2937f734e752cf0232ea55e82bacbca03fb0 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 14:42:08 +0700 Subject: [PATCH 04/16] Minor: turn debug_enabled into a module-level var --- src/solidlsp/ls.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index c49cb598..d05be1c8 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -49,6 +49,9 @@ from solidlsp.util.cache import load_cache, save_cache GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation] log = logging.getLogger(__name__) +_debug_enabled = log.isEnabledFor(logging.DEBUG) +"""Serves as a flag that triggers additional computation when debug logging is enabled.""" + @dataclasses.dataclass(kw_only=True) class ReferenceInSymbol: @@ -868,8 +871,7 @@ class SolidLanguageServer(ABC): sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True - debug_enabled = log.isEnabledFor(logging.DEBUG) - t0 = perf_counter() if debug_enabled else 0.0 + t0 = perf_counter() if _debug_enabled else 0.0 with self.open_file(relative_file_path): try: response = self._send_references_request(relative_file_path, line=line, column=column) @@ -882,7 +884,7 @@ class SolidLanguageServer(ABC): ) from e raise if response is None: - if debug_enabled: + if _debug_enabled: elapsed_ms = (perf_counter() - t0) * 1000 log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms) return [] @@ -913,7 +915,7 @@ class SolidLanguageServer(ABC): new_item["relativePath"] = str(rel_path) ret.append(ls_types.Location(**new_item)) # type: ignore - if debug_enabled: + if _debug_enabled: elapsed_ms = (perf_counter() - t0) * 1000 unique_files = len({r["relativePath"] for r in ret}) log.debug( From 1377acf6cf47959604e9b08ae2f25f6a94bf6355 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 14:43:40 +0700 Subject: [PATCH 05/16] Minor: rename .._for_symbols to ..for_symbol_batch --- src/serena/symbol.py | 2 +- src/serena/tools/symbol_tools.py | 4 ++-- test/serena/test_symbol.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index a946028a..2bc43d8a 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -552,7 +552,7 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] - def request_info_for_symbols( + def request_info_for_symbol_batch( self, symbols: list[LanguageServerSymbol], ) -> dict[LanguageServerSymbol, str | None]: diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 4f2b6fcf..2355ae73 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -187,7 +187,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): ) 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: - info_by_symbol = symbol_retriever.request_info_for_symbols(symbols) + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) for s, s_dict in zip(symbols, symbol_dicts, strict=True): if symbol_info := info_by_symbol.get(s): s_dict["info"] = symbol_info @@ -241,7 +241,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): info_by_symbol = {} if not include_body and include_info: ref_symbols = [ref.symbol for ref in references_in_symbols] - info_by_symbol = symbol_retriever.request_info_for_symbols(ref_symbols) + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(ref_symbols) reference_dicts = [] for ref in references_in_symbols: diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 7d8c4cea..865d603a 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -281,7 +281,7 @@ class TestHoverBudget: # Create mock symbols with unique (line, col) pairs symbols = _make_mock_symbols(3) - result = symbol_retriever.request_info_for_symbols(symbols) + result = symbol_retriever.request_info_for_symbol_batch(symbols) # All 3 symbols should have info (no budget exceeded) assert call_count == 3 @@ -319,7 +319,7 @@ class TestHoverBudget: # Create 5 mock symbols with unique (line, col) pairs symbols = _make_mock_symbols(5) - result = symbol_retriever.request_info_for_symbols(symbols) + result = symbol_retriever.request_info_for_symbol_batch(symbols) # Budget is 0.1s, each call takes 0.05s, so only 2 calls should succeed # After 2 calls: 0.1s >= 0.1s budget, remaining 3 should be skipped @@ -357,7 +357,7 @@ class TestHoverBudget: # Create mock symbols symbols = _make_mock_symbols(5) - result = symbol_retriever.request_info_for_symbols(symbols) + result = symbol_retriever.request_info_for_symbol_batch(symbols) # All 5 symbols should be looked up (no budget limit) assert call_count == 5 @@ -395,7 +395,7 @@ class TestHoverBudget: # Create 5 mock symbols symbols = _make_mock_symbols(5) - symbol_retriever.request_info_for_symbols(symbols) + symbol_retriever.request_info_for_symbol_batch(symbols) # Project budget is 0.05s, each call takes 0.03s # Budget check happens BEFORE starting a new call: @@ -431,7 +431,7 @@ class TestHoverBudget: # Create 3 mock symbols symbols = _make_mock_symbols(3) - result = symbol_retriever.request_info_for_symbols(symbols) + result = symbol_retriever.request_info_for_symbol_batch(symbols) # Global budget is 10s, all 3 should succeed assert call_count == 3 @@ -456,7 +456,7 @@ class TestHoverBudget: # Create 3 mock symbols symbols = _make_mock_symbols(3) - result = symbol_retriever.request_info_for_symbols(symbols) + result = symbol_retriever.request_info_for_symbol_batch(symbols) # Default budget is 5s, all 3 should succeed assert call_count == 3 From 5527bae952ee9b23cc82d098795166a11bce06f4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 15:06:40 +0700 Subject: [PATCH 06/16] Shortened debugging code, removed the symbol detail counting Unclear why the symbol detail counting was used in debugging, we don't do anything with the "details" entry at the moment --- src/serena/symbol.py | 66 +++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 2bc43d8a..bbb4de14 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -552,11 +552,27 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] + def _get_hover_budget(self, default_budget: float = 5) -> float: + """Project -> global -> default""" + hover_budget_seconds = default_budget + if self.agent is not None: + hover_budget_seconds = self.agent.serena_config.include_info_hover_budget_seconds + active_project = self.agent.get_active_project() + if active_project is not None: + project_hover_budget = active_project.project_config.include_info_hover_budget_seconds + if project_hover_budget is not None: + hover_budget_seconds = project_hover_budget + return hover_budget_seconds + def request_info_for_symbol_batch( self, symbols: list[LanguageServerSymbol], ) -> dict[LanguageServerSymbol, str | None]: - """Retrieves information for multiple symbols, optimizing by grouping by file. + """Retrieves information for multiple symbols while staying within a time budget. + + The request_hover operation used here is potentially expensive, we optimize by grouping by file + and stop executing it (returning the info as None) after the hover_budget is exceeded. + The hover budget is 5s by default Groups symbols by file path to minimize file switching overhead and uses a per-file cache keyed by (line, col) to avoid duplicate hover lookups. @@ -573,27 +589,12 @@ class LanguageServerSymbolRetriever: debug_enabled = log.isEnabledFor(logging.DEBUG) t0_total = perf_counter() if debug_enabled else 0.0 - # Resolve effective hover budget: project -> global -> default 5.0 - budget_seconds = 5.0 - if self.agent is not None: - budget_seconds = self.agent.serena_config.include_info_hover_budget_seconds - active_project = self.agent.get_active_project() - if active_project is not None: - project_budget = active_project.project_config.include_info_hover_budget_seconds - if project_budget is not None: - budget_seconds = project_budget - info_by_symbol: dict[LanguageServerSymbol, str | None] = {} skipped_symbols = 0 - detail_present_count = 0 # Group symbols by file path, filtering invalid symbols. symbols_by_file: dict[str, list[LanguageServerSymbol]] = {} for sym in symbols: - detail = sym.symbol_root.get("detail") - if isinstance(detail, str) and detail.strip(): - detail_present_count += 1 - file_path = sym.relative_path line = sym.line column = sym.column @@ -603,10 +604,12 @@ class LanguageServerSymbolRetriever: symbols_by_file.setdefault(file_path, []).append(sym) + hover_spent_seconds = 0.0 + hover_budget_seconds = self._get_hover_budget() + # the vars below are only for debug logging per_file_stats: list[tuple[str, int, float]] = [] total_hover_lookups = 0 hover_cache_hits = 0 - hover_spent_seconds = 0.0 skipped_due_to_budget = 0 for file_path, file_symbols in symbols_by_file.items(): @@ -625,8 +628,9 @@ class LanguageServerSymbolRetriever: info = hover_cache[cache_key] else: # Check budget before starting new hover request - if budget_seconds > 0 and hover_spent_seconds >= budget_seconds: + if 0 < hover_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 + log.debug("Skipped hover lookup for symbol %s due to budget exceeded", sym) info = None else: t0_hover = perf_counter() @@ -647,34 +651,16 @@ class LanguageServerSymbolRetriever: total_elapsed_ms = (perf_counter() - t0_total) * 1000 total_symbols = len(symbols) unique_files = len(symbols_by_file) - detail_rate = (detail_present_count / total_symbols * 100) if total_symbols > 0 else 0.0 budget_exceeded = skipped_due_to_budget > 0 log.debug( - "perf: request_info_for_symbols total_elapsed_ms=%.2f symbols=%d skipped=%d " - "detail_present=%d detail_rate=%.1f%% hover_lookups=%d hover_cache_hits=%d unique_files=%d " - "hover_budget_seconds=%.1f hover_spent_seconds=%.2f budget_exceeded=%s skipped_due_to_budget=%d", - total_elapsed_ms, - total_symbols, - skipped_symbols, - detail_present_count, - detail_rate, - total_hover_lookups, - hover_cache_hits, - unique_files, - budget_seconds, - hover_spent_seconds, - budget_exceeded, - skipped_due_to_budget, + f"perf: request_info_for_symbols {total_elapsed_ms=:.2f} {total_symbols=} {skipped_symbols=} " + f"{total_hover_lookups=} {hover_cache_hits=} {unique_files=} " + f"{hover_budget_seconds=:.1f} {hover_spent_seconds=:.2f} {budget_exceeded=} {skipped_due_to_budget=}" ) for file_path, lookup_count, elapsed_ms in per_file_stats: - log.debug( - "perf: request_info_for_symbols file=%s hover_count=%d elapsed_ms=%.2f", - file_path, - lookup_count, - elapsed_ms, - ) + log.debug(f"perf: {file_path=} {lookup_count=} {elapsed_ms=:.2f}") return info_by_symbol From 4cdec983097cc62f897817d0c2fa400bd7a785d1 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 15:14:27 +0700 Subject: [PATCH 07/16] Minor improvement in logging --- src/serena/symbol.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index bbb4de14..9546f80d 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -630,8 +630,10 @@ class LanguageServerSymbolRetriever: # Check budget before starting new hover request if 0 < hover_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 - log.debug("Skipped hover lookup for symbol %s due to budget exceeded", sym) info = None + # log once when budget exceeded + if skipped_due_to_budget == 1: + log.debug(f"Skipping further hover operations due to budget exceeded") else: t0_hover = perf_counter() info = self._request_info(file_path, line, column) From bc67337935e264e484e4d621f760ba1e3903672e Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 15:17:01 +0700 Subject: [PATCH 08/16] Remove hover_cache The cache could never have been triggered and served no purpose --- src/serena/symbol.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 9546f80d..7d43f584 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -614,7 +614,6 @@ class LanguageServerSymbolRetriever: for file_path, file_symbols in symbols_by_file.items(): t0_file = perf_counter() if debug_enabled else 0.0 - hover_cache: dict[tuple[int, int], str | None] = {} file_hover_lookups = 0 for sym in file_symbols: @@ -622,26 +621,19 @@ class LanguageServerSymbolRetriever: column = sym.column assert line is not None and column is not None - cache_key = (line, column) - if cache_key in hover_cache: - hover_cache_hits += 1 - info = hover_cache[cache_key] + # Check budget before starting new hover request + if 0 < hover_budget_seconds <= hover_spent_seconds: + skipped_due_to_budget += 1 + info = None + # log once when budget exceeded + if skipped_due_to_budget == 1: + log.debug("Skipping further hover operations due to budget exceeded") else: - # Check budget before starting new hover request - if 0 < hover_budget_seconds <= hover_spent_seconds: - skipped_due_to_budget += 1 - info = None - # log once when budget exceeded - if skipped_due_to_budget == 1: - log.debug(f"Skipping further hover operations due to budget exceeded") - else: - t0_hover = perf_counter() - info = self._request_info(file_path, line, column) - hover_spent_seconds += perf_counter() - t0_hover - file_hover_lookups += 1 - total_hover_lookups += 1 - - hover_cache[cache_key] = info + t0_hover = perf_counter() + info = self._request_info(file_path, line, column) + hover_spent_seconds += perf_counter() - t0_hover + file_hover_lookups += 1 + total_hover_lookups += 1 info_by_symbol[sym] = info From e1d1dc907fc5751d05765257976b69de03726b2b Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 29 Jan 2026 15:28:12 +0700 Subject: [PATCH 09/16] find_symbol: only pop symbol name if info is included --- src/serena/symbol.py | 7 +++---- src/serena/tools/symbol_tools.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 7d43f584..71d87677 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -617,10 +617,6 @@ class LanguageServerSymbolRetriever: file_hover_lookups = 0 for sym in file_symbols: - line = sym.line - column = sym.column - assert line is not None and column is not None - # Check budget before starting new hover request if 0 < hover_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 @@ -629,6 +625,9 @@ class LanguageServerSymbolRetriever: if skipped_due_to_budget == 1: log.debug("Skipping further hover operations due to budget exceeded") else: + line = sym.line + column = sym.column + assert line is not None and column is not None # for mypy, we filtered invalid symbols above t0_hover = perf_counter() info = self._request_info(file_path, line, column) hover_spent_seconds += perf_counter() - t0_hover diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2355ae73..40a5ee54 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -191,7 +191,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): for s, s_dict in zip(symbols, symbol_dicts, strict=True): if symbol_info := info_by_symbol.get(s): s_dict["info"] = symbol_info - s_dict.pop("name", None) # name is included in the 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) From f7ab5b2c859d5300b1377d838f8be96452e9b37f Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 1 Feb 2026 13:19:40 +0700 Subject: [PATCH 10/16] Minor renaming and docs extension --- src/serena/config/serena_config.py | 24 +++++++++---------- src/serena/resources/project.template.yml | 7 +++--- .../resources/serena_config.template.yml | 10 +++++--- src/serena/symbol.py | 17 ++++++------- test/serena/config/test_serena_config.py | 22 ++++++++--------- test/serena/test_symbol.py | 16 ++++++------- 6 files changed, 50 insertions(+), 46 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 4702444d..47e4e1d6 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -171,7 +171,7 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING - include_info_hover_budget_seconds: float | None = None + hover_budget: float | None = None """ Per-project override for include_info hover budget (seconds). @@ -329,16 +329,16 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e - # Validate include_info_hover_budget_seconds - hover_budget_raw = data["include_info_hover_budget_seconds"] + # Validate hover_budget + hover_budget_raw = data["hover_budget"] hover_budget = hover_budget_raw if hover_budget is not None: try: hover_budget = float(hover_budget_raw) except (TypeError, ValueError) as e: - raise ValueError(f"include_info_hover_budget_seconds must be a number or null, got: {hover_budget_raw}") from e + raise ValueError(f"hover_budget must be a number or null, got: {hover_budget_raw}") from e if hover_budget < 0: - raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") + raise ValueError(f"hover_budget cannot be negative, got: {hover_budget}") return cls( project_name=data["project_name"], @@ -353,7 +353,7 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi encoding=data["encoding"], base_modes=data["base_modes"], default_modes=data["default_modes"], - include_info_hover_budget_seconds=hover_budget, + hover_budget=hover_budget, ) def _to_yaml_dict(self) -> dict: @@ -511,7 +511,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" - include_info_hover_budget_seconds: float = 5.0 + hover_budget: float = 5.0 """ Time budget (seconds) for LSP hover requests when tools request include_info. @@ -660,15 +660,15 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix instance.log_level = loaded_commented_yaml["gui_log_level"] del loaded_commented_yaml["gui_log_level"] - # Load and validate include_info_hover_budget_seconds - hover_budget_raw = get_value_or_default("include_info_hover_budget_seconds") + # Load and validate hover_budget + hover_budget_raw = get_value_or_default("hover_budget") try: hover_budget = float(hover_budget_raw) except (TypeError, ValueError) as e: - raise ValueError(f"include_info_hover_budget_seconds must be a number, got: {hover_budget_raw}") from e + raise ValueError(f"hover_budget must be a number, got: {hover_budget_raw}") from e if hover_budget < 0: - raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") - instance.include_info_hover_budget_seconds = hover_budget + raise ValueError(f"hover_budget cannot be negative, got: {hover_budget}") + instance.hover_budget = hover_budget # re-save the configuration file if any migrations were performed if num_migrations > 0: diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index ee126f56..af563037 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -110,7 +110,6 @@ default_modes: # (contrary to the memories, which are loaded on demand). initial_prompt: "" -# Per-project override for include_info hover budget (seconds). -# If null or missing, global config is used. -# 0 disables the budget (no early stopping). Negative values are invalid. -include_info_hover_budget_seconds: null +# Override of the correspounding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +hover_budget: null diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index b8d0c3b3..501e32be 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -103,10 +103,14 @@ default_max_tool_answer_chars: 150000 # estimate the token count using the Claude Sonnet 4 tokenizer. token_count_estimator: CHAR_COUNT -# Time budget (seconds) for LSP hover requests when tools request include_info. -# If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. +# Time budget (seconds) budget for LSP request_hover calls within an MCP tool call (in seconds). +# This is an advanced setting that can help alleviate problems with LSP servers +# that have a slow implementation of request_hover (clangd is one of those) +# or with tool calls that find very many symbols. +# If the configured budget is exceeded, Serena stops issuing further hover requests and returns partial info results. # 0 disables the budget (no early stopping). Negative values are invalid. -include_info_hover_budget_seconds: 5 +# Can be overridden in project.yml. +hover_budget: 10 # the list of registered project paths (updated automatically). projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 71d87677..a15e4731 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -552,17 +552,17 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] - def _get_hover_budget(self, default_budget: float = 5) -> float: + def _get_hover_budget(self, default_budget: float = 10) -> float: """Project -> global -> default""" - hover_budget_seconds = default_budget + hover_budget = default_budget if self.agent is not None: - hover_budget_seconds = self.agent.serena_config.include_info_hover_budget_seconds + hover_budget = self.agent.serena_config.hover_budget active_project = self.agent.get_active_project() if active_project is not None: - project_hover_budget = active_project.project_config.include_info_hover_budget_seconds + project_hover_budget = active_project.project_config.hover_budget if project_hover_budget is not None: - hover_budget_seconds = project_hover_budget - return hover_budget_seconds + hover_budget = project_hover_budget + return hover_budget def request_info_for_symbol_batch( self, @@ -577,7 +577,7 @@ class LanguageServerSymbolRetriever: Groups symbols by file path to minimize file switching overhead and uses a per-file cache keyed by (line, col) to avoid duplicate hover lookups. - The hover budget (include_info_hover_budget_seconds) limits total time spent on hover + The hover budget (hover_budget) limits total time spent on hover requests. If exceeded, remaining symbols get info=None (partial results). :param symbols: list of symbols to get info for @@ -617,7 +617,8 @@ class LanguageServerSymbolRetriever: file_hover_lookups = 0 for sym in file_symbols: - # Check budget before starting new hover request + # Check budget before starting a new hover request + # hover_budget_seconds=0 disables the budget mechanism (the first inequality) if 0 < hover_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 info = None diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 3bfefa09..79bb905d 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -127,7 +127,7 @@ class TestProjectConfig: class TestProjectConfigHoverBudget: - """Test class for include_info_hover_budget_seconds validation in ProjectConfig.""" + """Test class for hover_budget validation in ProjectConfig.""" def _base_project_dict(self, **overrides): data = { @@ -140,7 +140,7 @@ class TestProjectConfigHoverBudget: "ignore_all_files_in_gitignore": True, "initial_prompt": "", "encoding": "utf-8", - "include_info_hover_budget_seconds": None, + "hover_budget": None, } data.update(overrides) return data @@ -149,23 +149,23 @@ class TestProjectConfigHoverBudget: """Test that _from_dict accepts null hover budget (for inheritance).""" data = self._base_project_dict() config = ProjectConfig._from_dict(data) - assert config.include_info_hover_budget_seconds is None + assert config.hover_budget is None def test_from_dict_accepts_positive_budget(self): """Test that _from_dict accepts positive hover budget.""" - data = self._base_project_dict(include_info_hover_budget_seconds=10.5) + data = self._base_project_dict(hover_budget=10.5) config = ProjectConfig._from_dict(data) - assert config.include_info_hover_budget_seconds == 10.5 + assert config.hover_budget == 10.5 def test_from_dict_accepts_zero_budget(self): """Test that _from_dict accepts zero hover budget (disabled).""" - data = self._base_project_dict(include_info_hover_budget_seconds=0) + data = self._base_project_dict(hover_budget=0) config = ProjectConfig._from_dict(data) - assert config.include_info_hover_budget_seconds == 0.0 + assert config.hover_budget == 0.0 def test_from_dict_rejects_negative_budget(self): """Test that _from_dict raises ValueError for negative hover budget.""" - data = self._base_project_dict(include_info_hover_budget_seconds=-1.0) + data = self._base_project_dict(hover_budget=-1.0) with pytest.raises(ValueError) as exc_info: ProjectConfig._from_dict(data) assert "cannot be negative" in str(exc_info.value) @@ -174,14 +174,14 @@ class TestProjectConfigHoverBudget: """Test that _apply_defaults_to_dict preserves None for hover budget.""" data = {"project_name": "test", "languages": ["python"]} result = ProjectConfig._apply_defaults_to_dict(data) - assert result["include_info_hover_budget_seconds"] is None + assert result["hover_budget"] is None def test_apply_defaults_preserves_explicit_value(self): """Test that _apply_defaults_to_dict preserves explicit hover budget value.""" data = { "project_name": "test", "languages": ["python"], - "include_info_hover_budget_seconds": 15.0, + "hover_budget": 15.0, } result = ProjectConfig._apply_defaults_to_dict(data) - assert result["include_info_hover_budget_seconds"] == 15.0 + assert result["hover_budget"] == 15.0 diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 865d603a..94353f46 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -256,14 +256,14 @@ def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.p @pytest.mark.python class TestHoverBudget: - """Tests for include_info_hover_budget_seconds time budget behavior.""" + """Tests for hover_budget time budget behavior.""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_budget_not_exceeded_all_lookups_performed(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): """With a large budget, all hover lookups are performed.""" # Create symbol retriever with a mock agent that has large budget mock_agent = MagicMock() - mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.serena_config.hover_budget = 10.0 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -293,7 +293,7 @@ class TestHoverBudget: """With a small budget, hover lookups stop and remaining symbols get None info.""" # Create symbol retriever with a mock agent that has small budget (0.1s) mock_agent = MagicMock() - mock_agent.serena_config.include_info_hover_budget_seconds = 0.1 + mock_agent.serena_config.hover_budget = 0.1 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -339,7 +339,7 @@ class TestHoverBudget: """With budget=0, all hover lookups proceed (no early stopping).""" # Create symbol retriever with budget=0 (unlimited) mock_agent = MagicMock() - mock_agent.serena_config.include_info_hover_budget_seconds = 0.0 + mock_agent.serena_config.hover_budget = 0.0 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -368,10 +368,10 @@ class TestHoverBudget: """Project-level budget overrides global budget.""" # Create symbol retriever with global budget 10.0 but project budget 0.05 mock_project = MagicMock() - mock_project.project_config.include_info_hover_budget_seconds = 0.05 + mock_project.project_config.hover_budget = 0.05 mock_agent = MagicMock() - mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.serena_config.hover_budget = 10.0 mock_agent.get_active_project.return_value = mock_project symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -410,10 +410,10 @@ class TestHoverBudget: """When project budget is None, global budget is used.""" # Create symbol retriever with project budget=None (inherit global) mock_project = MagicMock() - mock_project.project_config.include_info_hover_budget_seconds = None + mock_project.project_config.hover_budget = None mock_agent = MagicMock() - mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.serena_config.hover_budget = 10.0 mock_agent.get_active_project.return_value = mock_project symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) From 648d020ea882731c9812bbcf9d47d4d74187d129 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 1 Feb 2026 21:36:35 +0700 Subject: [PATCH 11/16] Removed config tests that were using private methods --- test/serena/config/test_serena_config.py | 61 ------------------------ 1 file changed, 61 deletions(-) diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 79bb905d..93abd5a6 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -124,64 +124,3 @@ class TestProjectConfig: def test_template_is_complete(self): _, is_complete = ProjectConfig._load_yaml(PROJECT_TEMPLATE_FILE) assert is_complete, "Project template YAML is incomplete; all fields must be present (with descriptions)." - - -class TestProjectConfigHoverBudget: - """Test class for hover_budget validation in ProjectConfig.""" - - def _base_project_dict(self, **overrides): - data = { - "project_name": "test_project", - "languages": ["python"], - "ignored_paths": [], - "excluded_tools": [], - "included_optional_tools": [], - "read_only": False, - "ignore_all_files_in_gitignore": True, - "initial_prompt": "", - "encoding": "utf-8", - "hover_budget": None, - } - data.update(overrides) - return data - - def test_from_dict_accepts_null_budget(self): - """Test that _from_dict accepts null hover budget (for inheritance).""" - data = self._base_project_dict() - config = ProjectConfig._from_dict(data) - assert config.hover_budget is None - - def test_from_dict_accepts_positive_budget(self): - """Test that _from_dict accepts positive hover budget.""" - data = self._base_project_dict(hover_budget=10.5) - config = ProjectConfig._from_dict(data) - assert config.hover_budget == 10.5 - - def test_from_dict_accepts_zero_budget(self): - """Test that _from_dict accepts zero hover budget (disabled).""" - data = self._base_project_dict(hover_budget=0) - config = ProjectConfig._from_dict(data) - assert config.hover_budget == 0.0 - - def test_from_dict_rejects_negative_budget(self): - """Test that _from_dict raises ValueError for negative hover budget.""" - data = self._base_project_dict(hover_budget=-1.0) - with pytest.raises(ValueError) as exc_info: - ProjectConfig._from_dict(data) - assert "cannot be negative" in str(exc_info.value) - - def test_apply_defaults_preserves_null(self): - """Test that _apply_defaults_to_dict preserves None for hover budget.""" - data = {"project_name": "test", "languages": ["python"]} - result = ProjectConfig._apply_defaults_to_dict(data) - assert result["hover_budget"] is None - - def test_apply_defaults_preserves_explicit_value(self): - """Test that _apply_defaults_to_dict preserves explicit hover budget value.""" - data = { - "project_name": "test", - "languages": ["python"], - "hover_budget": 15.0, - } - result = ProjectConfig._apply_defaults_to_dict(data) - assert result["hover_budget"] == 15.0 From bb41cef097aed070b2b7ff9026913a1f8412f34f Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 4 Feb 2026 13:01:56 +0700 Subject: [PATCH 12/16] Removed unnecessary input validation of hover_budget --- src/serena/config/serena_config.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 47e4e1d6..8c07a090 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -511,7 +511,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" - hover_budget: float = 5.0 + hover_budget: float = 10.0 """ Time budget (seconds) for LSP hover requests when tools request include_info. @@ -660,16 +660,6 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix instance.log_level = loaded_commented_yaml["gui_log_level"] del loaded_commented_yaml["gui_log_level"] - # Load and validate hover_budget - hover_budget_raw = get_value_or_default("hover_budget") - try: - hover_budget = float(hover_budget_raw) - except (TypeError, ValueError) as e: - raise ValueError(f"hover_budget must be a number, got: {hover_budget_raw}") from e - if hover_budget < 0: - raise ValueError(f"hover_budget cannot be negative, got: {hover_budget}") - instance.hover_budget = hover_budget - # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file") From 9c50d2882ab958c616ad88ae25e2b93248e9fc16 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 20 Feb 2026 14:41:42 +0100 Subject: [PATCH 13/16] Added a base class ToolBehaviorConfig, renamed hover to symbol_info The latter because we likely will eventually start using the same field for JB tools --- src/serena/config/serena_config.py | 54 ++++++++++--------- src/serena/resources/project.template.yml | 4 +- .../resources/serena_config.template.yml | 10 ++-- src/serena/symbol.py | 26 ++++----- test/serena/test_symbol.py | 16 +++--- 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index d0be8257..f0bdbf9b 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -134,6 +134,16 @@ class ModeSelectionDefinition: default_modes: Sequence[str] | None = None +@dataclass +class ToolBehaviorConfig: + """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form + (same as in ModeSelectionDefinition). + The defaults here shall be none and should be set to the global default values in SerenaConfig. + """ + + symbol_info_budget: float | None = None + + class SerenaConfigError(Exception): pass @@ -163,7 +173,7 @@ class LanguageBackend(Enum): @dataclass(kw_only=True) -class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMixin): +class ProjectConfig(ToolInclusionDefinition, ToolBehaviorConfig, ModeSelectionDefinition, ToStringMixin): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) @@ -171,12 +181,6 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING - hover_budget: float | None = None - """ - Per-project override for include_info hover budget (seconds). - - If None, global config is used. 0 disables the budget. Negative values are invalid. - """ SERENA_DEFAULT_PROJECT_FILE = "project.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} @@ -329,16 +333,16 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e - # Validate hover_budget - hover_budget_raw = data["hover_budget"] - hover_budget = hover_budget_raw - if hover_budget is not None: + # Validate symbol_info_budget + symbol_info_budget_raw = data["symbol_info_budget"] + symbol_info_budget = symbol_info_budget_raw + if symbol_info_budget is not None: try: - hover_budget = float(hover_budget_raw) + symbol_info_budget = float(symbol_info_budget_raw) except (TypeError, ValueError) as e: - raise ValueError(f"hover_budget must be a number or null, got: {hover_budget_raw}") from e - if hover_budget < 0: - raise ValueError(f"hover_budget cannot be negative, got: {hover_budget}") + raise ValueError(f"symbol_info_budget must be a number or null, got: {symbol_info_budget_raw}") from e + if symbol_info_budget < 0: + raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}") return cls( project_name=data["project_name"], @@ -353,7 +357,7 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi encoding=data["encoding"], base_modes=data["base_modes"], default_modes=data["default_modes"], - hover_budget=hover_budget, + symbol_info_budget=symbol_info_budget, ) def _to_yaml_dict(self) -> dict: @@ -481,7 +485,7 @@ class RegisteredProject(ToStringMixin): @dataclass(kw_only=True) -class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMixin): +class SerenaConfig(ToolInclusionDefinition, ToolBehaviorConfig, ModeSelectionDefinition, ToStringMixin): """ Holds the Serena agent configuration, which is typically loaded from a YAML configuration file (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. @@ -525,16 +529,16 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. These patterns are merged additively with each project's own ignored_paths.""" - hover_budget: float = 10.0 - """ - Time budget (seconds) for LSP hover requests when tools request include_info. - - If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. - 0 disables the budget (no early stopping). Negative values are invalid. - """ - # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") + symbol_info_budget: float = 10.0 + """ + Time budget (seconds) for requests when tools request include_info (currently + only supported for LSP-based tools). + + If the budget is exceeded, Serena stops issuing further requests and returns partial info results. + 0 disables the budget (no early stopping). Negative values are invalid. + """ # *** fields that are NOT mapped to/from the configuration file *** diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 634566de..1d7bb3c0 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -111,6 +111,6 @@ default_modes: # (contrary to the memories, which are loaded on demand). initial_prompt: "" -# Override of the correspounding setting in serena_config.yml, see the documentation there. +# Override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. -hover_budget: null +symbol_info_budget: null diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 89f172b9..247690f2 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -108,14 +108,16 @@ default_max_tool_answer_chars: 150000 # estimate the token count using the Claude Sonnet 4 tokenizer. token_count_estimator: CHAR_COUNT -# Time budget (seconds) budget for LSP request_hover calls within an MCP tool call (in seconds). +# Time budget (seconds) for requests when tools request symbol info (currently +# only supported for LSP-based tools). +# If the budget is exceeded, Serena stops issuing further symbol-info-related +# requests and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. # This is an advanced setting that can help alleviate problems with LSP servers # that have a slow implementation of request_hover (clangd is one of those) # or with tool calls that find very many symbols. -# If the configured budget is exceeded, Serena stops issuing further hover requests and returns partial info results. -# 0 disables the budget (no early stopping). Negative values are invalid. # Can be overridden in project.yml. -hover_budget: 10 +symbol_info_budget: 10 # the list of registered project paths (updated automatically). projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 6d3a8d01..fbf9f012 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -587,17 +587,17 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] - def _get_hover_budget(self, default_budget: float = 10) -> float: + def _get_symbol_info_budget(self, default_budget: float = 10) -> float: """Project -> global -> default""" - hover_budget = default_budget + symbol_info_budget = default_budget if self.agent is not None: - hover_budget = self.agent.serena_config.hover_budget + symbol_info_budget = self.agent.serena_config.symbol_info_budget active_project = self.agent.get_active_project() if active_project is not None: - project_hover_budget = active_project.project_config.hover_budget - if project_hover_budget is not None: - hover_budget = project_hover_budget - return hover_budget + project_symbol_info_budget = active_project.project_config.symbol_info_budget + if project_symbol_info_budget is not None: + symbol_info_budget = project_symbol_info_budget + return symbol_info_budget def request_info_for_symbol_batch( self, @@ -606,13 +606,13 @@ class LanguageServerSymbolRetriever: """Retrieves information for multiple symbols while staying within a time budget. The request_hover operation used here is potentially expensive, we optimize by grouping by file - and stop executing it (returning the info as None) after the hover_budget is exceeded. + and stop executing it (returning the info as None) after the symbol_info_budget is exceeded. The hover budget is 5s by default Groups symbols by file path to minimize file switching overhead and uses a per-file cache keyed by (line, col) to avoid duplicate hover lookups. - The hover budget (hover_budget) limits total time spent on hover + The hover budget (symbol_info_budget) limits total time spent on hover requests. If exceeded, remaining symbols get info=None (partial results). :param symbols: list of symbols to get info for @@ -640,7 +640,7 @@ class LanguageServerSymbolRetriever: symbols_by_file.setdefault(file_path, []).append(sym) hover_spent_seconds = 0.0 - hover_budget_seconds = self._get_hover_budget() + symbol_info_budget_seconds = self._get_symbol_info_budget() # the vars below are only for debug logging per_file_stats: list[tuple[str, int, float]] = [] total_hover_lookups = 0 @@ -653,8 +653,8 @@ class LanguageServerSymbolRetriever: for sym in file_symbols: # Check budget before starting a new hover request - # hover_budget_seconds=0 disables the budget mechanism (the first inequality) - if 0 < hover_budget_seconds <= hover_spent_seconds: + # symbol_info_budget_seconds=0 disables the budget mechanism (the first inequality) + if 0 < symbol_info_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 info = None # log once when budget exceeded @@ -685,7 +685,7 @@ class LanguageServerSymbolRetriever: log.debug( f"perf: request_info_for_symbols {total_elapsed_ms=:.2f} {total_symbols=} {skipped_symbols=} " f"{total_hover_lookups=} {hover_cache_hits=} {unique_files=} " - f"{hover_budget_seconds=:.1f} {hover_spent_seconds=:.2f} {budget_exceeded=} {skipped_due_to_budget=}" + f"{symbol_info_budget_seconds=:.1f} {hover_spent_seconds=:.2f} {budget_exceeded=} {skipped_due_to_budget=}" ) for file_path, lookup_count, elapsed_ms in per_file_stats: diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index a83ab1f5..7b88136e 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -278,14 +278,14 @@ def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.p @pytest.mark.python class TestHoverBudget: - """Tests for hover_budget time budget behavior.""" + """Tests for symbol_info_budget time budget behavior.""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_budget_not_exceeded_all_lookups_performed(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): """With a large budget, all hover lookups are performed.""" # Create symbol retriever with a mock agent that has large budget mock_agent = MagicMock() - mock_agent.serena_config.hover_budget = 10.0 + mock_agent.serena_config.symbol_info_budget = 10.0 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -315,7 +315,7 @@ class TestHoverBudget: """With a small budget, hover lookups stop and remaining symbols get None info.""" # Create symbol retriever with a mock agent that has small budget (0.1s) mock_agent = MagicMock() - mock_agent.serena_config.hover_budget = 0.1 + mock_agent.serena_config.symbol_info_budget = 0.1 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -361,7 +361,7 @@ class TestHoverBudget: """With budget=0, all hover lookups proceed (no early stopping).""" # Create symbol retriever with budget=0 (unlimited) mock_agent = MagicMock() - mock_agent.serena_config.hover_budget = 0.0 + mock_agent.serena_config.symbol_info_budget = 0.0 mock_agent.get_active_project.return_value = None symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -390,10 +390,10 @@ class TestHoverBudget: """Project-level budget overrides global budget.""" # Create symbol retriever with global budget 10.0 but project budget 0.05 mock_project = MagicMock() - mock_project.project_config.hover_budget = 0.05 + mock_project.project_config.symbol_info_budget = 0.05 mock_agent = MagicMock() - mock_agent.serena_config.hover_budget = 10.0 + mock_agent.serena_config.symbol_info_budget = 10.0 mock_agent.get_active_project.return_value = mock_project symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) @@ -432,10 +432,10 @@ class TestHoverBudget: """When project budget is None, global budget is used.""" # Create symbol retriever with project budget=None (inherit global) mock_project = MagicMock() - mock_project.project_config.hover_budget = None + mock_project.project_config.symbol_info_budget = None mock_agent = MagicMock() - mock_agent.serena_config.hover_budget = 10.0 + mock_agent.serena_config.symbol_info_budget = 10.0 mock_agent.get_active_project.return_value = mock_project symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) From e62f518e4e4a8ca0b821599854eb86205acfc3d6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 20 Feb 2026 14:56:10 +0100 Subject: [PATCH 14/16] FindReferences tools - remove include_info parameter Rationale: requesting the info is just not worth it. The risk of the tool being slow or the info bloating the answer unnecessarily are too high. If an agent needs more info about the found references, it can get this info by itself in subsequent tool calls, --- src/serena/tools/jetbrains_tools.py | 5 +---- src/serena/tools/symbol_tools.py | 11 ----------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 67f7ac28..8ec84a07 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -98,7 +98,6 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark self, name_path: str, relative_path: str, - include_info: bool = False, max_answer_chars: int = -1, ) -> str: """ @@ -108,8 +107,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 info (hover-like, typically including docstring and signature) - about the referencing symbols. Default False. :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 @@ -118,7 +115,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_quick_info=False, # TODO: Hotfix for serena-jetbrains-plugin/issues/13; revert once fixed + include_quick_info=False, ) symbol_dicts = response_dict["symbols"] result = self.symbol_dict_grouper.group(symbol_dicts) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 434f8afe..07d6ad8c 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -180,7 +180,6 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): self, name_path: str, relative_path: str, - include_info: bool = False, include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 max_answer_chars: int = -1, @@ -192,8 +191,6 @@ 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; can be slow depending on the language (e.g. C/C++). :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. @@ -212,12 +209,6 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): exclude_kinds=parsed_exclude_kinds, ) - # Batch-request hover info for referencing symbols (optional and potentially slow). - info_by_symbol = {} - if not include_body and include_info: - ref_symbols = [ref.symbol for ref in references_in_symbols] - info_by_symbol = symbol_retriever.request_info_for_symbol_batch(ref_symbols) - reference_dicts = [] for ref in references_in_symbols: ref_dict_orig = ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True) @@ -225,8 +216,6 @@ 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 := info_by_symbol.get(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 f6874be852dc955c44071a1152440ac486efda26 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 20 Feb 2026 17:33:16 +0100 Subject: [PATCH 15/16] Minor: empty instead of null in yaml [ci skip] --- src/serena/resources/project.template.yml | 4 ++-- src/serena/resources/serena_config.template.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 1d7bb3c0..1f7ca87b 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -111,6 +111,6 @@ default_modes: # (contrary to the memories, which are loaded on demand). initial_prompt: "" -# Override of the corresponding setting in serena_config.yml, see the documentation there. +# override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. -symbol_info_budget: null +symbol_info_budget: diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 247690f2..0631be11 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -108,7 +108,7 @@ default_max_tool_answer_chars: 150000 # estimate the token count using the Claude Sonnet 4 tokenizer. token_count_estimator: CHAR_COUNT -# Time budget (seconds) for requests when tools request symbol info (currently +# time budget (seconds) for requests when tools request symbol info (currently # only supported for LSP-based tools). # If the budget is exceeded, Serena stops issuing further symbol-info-related # requests and returns partial info results. From a7bda849f4da51998e2ce99bbd7332fcdcb2a727 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 20 Feb 2026 17:37:35 +0100 Subject: [PATCH 16/16] Use a single SharedConfig instead of 4 classes from which both configs inherit --- src/serena/config/serena_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index f0bdbf9b..78dec2e3 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -135,7 +135,7 @@ class ModeSelectionDefinition: @dataclass -class ToolBehaviorConfig: +class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form (same as in ModeSelectionDefinition). The defaults here shall be none and should be set to the global default values in SerenaConfig. @@ -173,7 +173,7 @@ class LanguageBackend(Enum): @dataclass(kw_only=True) -class ProjectConfig(ToolInclusionDefinition, ToolBehaviorConfig, ModeSelectionDefinition, ToStringMixin): +class ProjectConfig(SharedConfig): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) @@ -485,7 +485,7 @@ class RegisteredProject(ToStringMixin): @dataclass(kw_only=True) -class SerenaConfig(ToolInclusionDefinition, ToolBehaviorConfig, ModeSelectionDefinition, ToStringMixin): +class SerenaConfig(SharedConfig): """ Holds the Serena agent configuration, which is typically loaded from a YAML configuration file (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed.