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
This commit is contained in:
Michael Panchenko committed 2026-02-20 14:41:42 +01:00
1 parent 4086ce5e6e
commit 9c50d2882a
5 files changed
+58 -52

No files matched your search

+29 -25
View File
@@ -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 ***
+2 -2
View File
@@ -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
@@ -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: []
+13 -13
View File
@@ -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:
+8 -8
View File
@@ -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)