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)