Skip to content

Commit 868ad1b

Browse files
committed
feat: support directory paths in get_symbols_overview with max_files safeguard (issue #1412)
- Add directory support to GetSymbolsOverviewTool via _apply_directory, returning a per-file mapping of grouped symbols - Add max_files parameter (default 20) that raises ValueError when a directory contains more analyzable files than the limit, addressing maintainer feedback on issue #1412 about unbounded top-level overviews - Single-file behavior is unchanged - Tests: directory happy path, single-file regression, max_files guard
1 parent 22c135a commit 868ad1b

2 files changed

Lines changed: 115 additions & 6 deletions

File tree

src/serena/tools/symbol_tools.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,38 @@ def apply(self) -> str:
3535

3636
class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
3737
"""
38-
Gets an overview of the top-level symbols defined in a given file.
38+
Gets an overview of the top-level symbols defined in a given file or directory.
3939
"""
4040

4141
symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True)
4242

43-
def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str:
43+
def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1, max_files: int = 20) -> str:
4444
"""
45-
Use this tool to get a high-level understanding of the code symbols in a file.
45+
Use this tool to get a high-level understanding of the code symbols in a file or directory.
4646
This should be the first tool to call when you want to understand a new file, unless you already know
4747
what you are looking for.
48+
When given a directory path, returns top-level symbols for every analyzable file in the directory.
4849
49-
:param relative_path: the relative path to the file to get the overview of
50+
:param relative_path: the relative path to the file or directory to get the overview of
5051
:param depth: depth up to which descendants of top-level symbols shall be retrieved
5152
(e.g. 1 retrieves immediate children). Default 0.
5253
:param max_answer_chars: if the overview is longer than this number of characters,
5354
no content will be returned. -1 means the default value from the config will be used.
5455
Don't adjust unless there is really no other way to get the content required for the task.
56+
:param max_files: only used when relative_path is a directory. If the directory contains more
57+
analyzable files than this limit, the tool raises ValueError instead of returning a partial
58+
overview — narrow the path to a subdirectory, or learn the layout from memories first.
59+
Default 20. Don't increase unless you really need a broad sweep and accept the token cost.
5560
:return: a JSON object containing symbols grouped by kind in a compact format.
61+
For directories, returns a mapping of file paths to their grouped symbols.
5662
"""
63+
file_path = os.path.join(self.project.project_root, relative_path)
64+
if not os.path.exists(file_path):
65+
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")
66+
67+
if os.path.isdir(file_path):
68+
return self._apply_directory(relative_path, depth=depth, max_answer_chars=max_answer_chars, max_files=max_files)
69+
5770
result = self.get_symbol_overview(relative_path, depth=depth)
5871

5972
# capture kind names and depth-0 snapshots before grouping, which mutates the dicts
@@ -82,6 +95,49 @@ def make_depth_0_result() -> str:
8295

8396
return self._limit_length(result_json_str, max_answer_chars, shortened_result_factories=shortened_results)
8497

98+
def _apply_directory(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1, max_files: int = 20) -> str:
99+
symbol_retriever = self.create_language_server_symbol_retriever()
100+
path_to_symbols = symbol_retriever.get_symbol_overview(relative_path)
101+
102+
total_files = len(path_to_symbols)
103+
if total_files > max_files:
104+
sample = list(path_to_symbols.keys())[:5]
105+
raise ValueError(
106+
f"Directory {relative_path} contains {total_files} analyzable files, which exceeds "
107+
f"max_files={max_files}. Narrow the path to a more specific subdirectory, or learn the "
108+
f"repository layout from memories before asking for a broad overview. "
109+
f"Sample files found: {sample}"
110+
)
111+
112+
def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
113+
return not s.is_low_level()
114+
115+
per_file_result = {}
116+
file_count = 0
117+
for file_rel_path, symbols in path_to_symbols.items():
118+
symbol_dicts = []
119+
for symbol in symbols:
120+
symbol_dicts.append(
121+
symbol.to_dict(
122+
name_path=False,
123+
name=True,
124+
depth=depth,
125+
kind=True,
126+
relative_path=False,
127+
location=False,
128+
child_inclusion_predicate=child_inclusion_predicate,
129+
)
130+
)
131+
per_file_result[file_rel_path] = self.symbol_dict_grouper.group(symbol_dicts)
132+
file_count += 1
133+
134+
result_json_str = self._to_json(per_file_result)
135+
136+
def make_file_counts() -> str:
137+
return f"Analyzed {file_count} files in directory {relative_path}"
138+
139+
return self._limit_length(result_json_str, max_answer_chars, shortened_result_factories=[make_file_counts])
140+
85141
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]:
86142
"""
87143
:param relative_path: relative path to a source file
@@ -90,8 +146,6 @@ def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[Langua
90146
"""
91147
symbol_retriever = self.create_language_server_symbol_retriever()
92148

93-
# The symbol overview is capable of working with both files and directories,
94-
# but we want to ensure that the user provides a file path.
95149
file_path = os.path.join(self.project.project_root, relative_path)
96150
if not os.path.exists(file_path):
97151
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")

test/serena/test_serena_agent.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
FindReferencingSymbolsTool,
2525
FindSymbolTool,
2626
GetDiagnosticsForFileTool,
27+
GetSymbolsOverviewTool,
2728
InitialInstructionsTool,
2829
ReplaceContentTool,
2930
ReplaceSymbolBodyTool,
@@ -1276,6 +1277,60 @@ def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: Sere
12761277
f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content"
12771278
)
12781279

1280+
@pytest.mark.parametrize(
1281+
"serena_agent",
1282+
[
1283+
pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_directory_overview"),
1284+
],
1285+
indirect=["serena_agent"],
1286+
)
1287+
def test_get_symbols_overview_directory_returns_per_file_symbols(self, serena_agent: SerenaAgent):
1288+
"""
1289+
Tests that get_symbols_overview accepts a directory path and returns
1290+
symbols grouped by file (Issue #1412).
1291+
"""
1292+
overview_tool = serena_agent.get_tool(GetSymbolsOverviewTool)
1293+
result = overview_tool.apply(relative_path="test_repo", depth=0)
1294+
result_dict = json.loads(result)
1295+
assert isinstance(result_dict, dict), f"Expected dict result for directory, got: {type(result_dict)}"
1296+
assert len(result_dict) > 0, "Expected at least one file in directory overview"
1297+
for file_path in result_dict:
1298+
assert file_path.endswith(".py"), f"Expected Python file path, got: {file_path}"
1299+
1300+
@pytest.mark.parametrize(
1301+
"serena_agent",
1302+
[
1303+
pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_file_overview_unchanged"),
1304+
],
1305+
indirect=["serena_agent"],
1306+
)
1307+
def test_get_symbols_overview_file_returns_same_format(self, serena_agent: SerenaAgent):
1308+
"""
1309+
Regression test: get_symbols_overview with a file path should return
1310+
the same grouped format as before (list of symbol dicts by kind).
1311+
"""
1312+
overview_tool = serena_agent.get_tool(GetSymbolsOverviewTool)
1313+
result = overview_tool.apply(relative_path="test_repo/services.py", depth=0)
1314+
result_dict = json.loads(result)
1315+
assert isinstance(result_dict, dict), f"Expected dict result, got: {type(result_dict)}"
1316+
assert "test_repo/services.py" not in result_dict, "Single file result should not be wrapped in per-file mapping"
1317+
1318+
@pytest.mark.parametrize(
1319+
"serena_agent",
1320+
[
1321+
pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_directory_exceeds_max_files"),
1322+
],
1323+
indirect=["serena_agent"],
1324+
)
1325+
def test_get_symbols_overview_directory_raises_when_exceeds_max_files(self, serena_agent: SerenaAgent):
1326+
"""
1327+
Tests that giving a directory with more analyzable files than max_files
1328+
raises ValueError with guidance to narrow the path (Issue #1412 maintainer feedback).
1329+
"""
1330+
overview_tool = serena_agent.get_tool(GetSymbolsOverviewTool)
1331+
with pytest.raises(ValueError, match="max_files=1"):
1332+
overview_tool.apply(relative_path="test_repo", depth=0, max_files=1)
1333+
12791334

12801335
class TestPromptProvision:
12811336
class MockContext:

0 commit comments

Comments
 (0)