diff --git a/scripts/demo_progressive_tool_shortening.py b/scripts/demo_progressive_tool_shortening.py new file mode 100644 index 00000000..f54d1946 --- /dev/null +++ b/scripts/demo_progressive_tool_shortening.py @@ -0,0 +1,191 @@ +""" +Demonstrates the progressive shortening of tool results when max_answer_chars is exceeded. +It exercises all tools that use _limit_length with shortened_results, +printing the full result, then progressively tighter max_answer_chars +to show the successive shortening stages. Both LSP and JetBrains backends +are tested (JB is skipped if no IDE is running). +""" + +import json +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, SerenaConfig +from serena.constants import REPO_ROOT +from serena.tools import ( + FindReferencingSymbolsTool, + FindSymbolTool, + GetSymbolsOverviewTool, + JetBrainsFindReferencingSymbolsTool, + JetBrainsFindSymbolTool, + JetBrainsGetSymbolsOverviewTool, + SearchForPatternTool, +) + +SEPARATOR = "=" * 80 + +# symbol with many references across multiple files, good for testing shortening +REF_SYMBOL = "_limit_length" +REF_FILE = "src/serena/tools/tools_base.py" + +# file with many symbols, good for testing overview shortening +OVERVIEW_FILE = "src/serena/tools/tools_base.py" + + +def run_with_shrinking(agent: SerenaAgent, label: str, fn, char_limits: list[int]) -> None: + """Run a tool call at several max_answer_chars limits and print results.""" + for limit in char_limits: + tag = f"{label} (max_answer_chars={limit})" + print(f"\n{SEPARATOR}") + print(tag) + print(SEPARATOR) + result = agent.execute_task(lambda lim=limit: fn(lim)) + n = len(result) + print(f"[length={n}]") + try: + pprint(json.loads(result), width=200) + except (json.JSONDecodeError, ValueError): + print(result) + + +def run_lsp_tools(agent: SerenaAgent) -> None: + print("\n\n### LSP BACKEND ###\n") + + # LSP: FindReferencingSymbolsTool — three shortening stages: + # 1. refs without context lines 2. per-file counts 3. total summary + lsp_refs = agent.get_tool(FindReferencingSymbolsTool) + run_with_shrinking( + agent, + "LSP FindReferencingSymbolsTool", + lambda lim: lsp_refs.apply(REF_SYMBOL, REF_FILE, max_answer_chars=lim), + char_limits=[50000, 3000, 500, 200], + ) + + # LSP: FindSymbolTool — one shortening stage: names with kind only + lsp_find = agent.get_tool(FindSymbolTool) + run_with_shrinking( + agent, + "LSP FindSymbolTool (depth=1)", + lambda lim: lsp_find.apply("Tool", relative_path=REF_FILE, depth=1, max_answer_chars=lim), + char_limits=[50000, 200], + ) + + # LSP: FindSymbolTool with max_matches exceeded — tests the early-return shortened path + print(f"\n{SEPARATOR}") + print("LSP FindSymbolTool (max_matches=1, broad search)") + print(SEPARATOR) + result = agent.execute_task(lambda: lsp_find.apply("apply", max_matches=1)) + print(f"[length={len(result)}]") + print(result) + + # LSP: GetSymbolsOverviewTool — two shortening stages for depth>0: + # 1. depth-0 overview 2. counts by kind + # one stage for depth==0: counts by kind + lsp_overview = agent.get_tool(GetSymbolsOverviewTool) + run_with_shrinking( + agent, + "LSP GetSymbolsOverviewTool (depth=1)", + lambda lim: lsp_overview.apply(OVERVIEW_FILE, depth=1, max_answer_chars=lim), + char_limits=[50000, 500, 200], + ) + run_with_shrinking( + agent, + "LSP GetSymbolsOverviewTool (depth=0)", + lambda lim: lsp_overview.apply(OVERVIEW_FILE, depth=0, max_answer_chars=lim), + char_limits=[50000, 200], + ) + + +def run_backend_independent_tools(agent: SerenaAgent) -> None: + print("\n\n### BACKEND-INDEPENDENT TOOLS ###\n") + + # SearchForPatternTool — three shortening stages: + # 1. match lines per file (no context) 2. match counts per file 3. total summary + search_tool = agent.get_tool(SearchForPatternTool) + run_with_shrinking( + agent, + "SearchForPatternTool (with context)", + lambda lim: search_tool.apply( + "_limit_length", + context_lines_before=1, + context_lines_after=1, + relative_path="src/serena/tools", + max_answer_chars=lim, + ), + char_limits=[50000, 1000, 200], + ) + + +def run_jb_tools(agent: SerenaAgent) -> None: + print("\n\n### JETBRAINS BACKEND ###\n") + + # JB: FindReferencingSymbolsTool — two shortening stages: + # 1. per-file counts 2. total summary + jb_refs = agent.get_tool(JetBrainsFindReferencingSymbolsTool) + run_with_shrinking( + agent, + "JB FindReferencingSymbolsTool", + lambda lim: jb_refs.apply(REF_SYMBOL, REF_FILE, max_answer_chars=lim), + char_limits=[50000, 500, 200], + ) + + # JB: FindSymbolTool — one shortening stage: names with kind only + jb_find = agent.get_tool(JetBrainsFindSymbolTool) + run_with_shrinking( + agent, + "JB FindSymbolTool (depth=1)", + lambda lim: jb_find.apply("Tool", relative_path=REF_FILE, depth=1, max_answer_chars=lim), + char_limits=[50000, 200], + ) + + # JB: FindSymbolTool with max_matches exceeded — tests the early-return shortened path + print(f"\n{SEPARATOR}") + print("JB FindSymbolTool (max_matches=1, broad search)") + print(SEPARATOR) + result = agent.execute_task(lambda: jb_find.apply("apply", max_matches=1)) + print(f"[length={len(result)}]") + print(result) + + # JB: GetSymbolsOverviewTool — two shortening stages for depth>0: + # 1. depth-0 overview 2. counts by type + # two stages for depth==0: grouped symbols, then counts by type + jb_overview = agent.get_tool(JetBrainsGetSymbolsOverviewTool) + run_with_shrinking( + agent, + "JB GetSymbolsOverviewTool (depth=1)", + lambda lim: jb_overview.apply(OVERVIEW_FILE, depth=1, max_answer_chars=lim), + char_limits=[50000, 500, 200], + ) + run_with_shrinking( + agent, + "JB GetSymbolsOverviewTool (depth=0)", + lambda lim: jb_overview.apply(OVERVIEW_FILE, depth=0, max_answer_chars=lim), + char_limits=[50000, 200], + ) + + +def make_agent(backend: LanguageBackend) -> SerenaAgent: + config = SerenaConfig.from_config_file() + config.web_dashboard = False + config.language_backend = backend + return SerenaAgent(project=REPO_ROOT, serena_config=config) + + +if __name__ == "__main__": + # LSP backend + lsp_agent = make_agent(LanguageBackend.LSP) + try: + run_lsp_tools(lsp_agent) + run_backend_independent_tools(lsp_agent) + finally: + lsp_agent.shutdown() + + # JetBrains backend (requires a running IDE) + try: + jb_agent = make_agent(LanguageBackend.JETBRAINS) + try: + run_jb_tools(jb_agent) + finally: + jb_agent.shutdown() + except Exception as e: + print(f"\nJetBrains backend not available, skipping: {e}") diff --git a/src/serena/resources/dashboard/news/20262103.html b/src/serena/resources/dashboard/news/20262103.html new file mode 100644 index 00000000..f56221eb --- /dev/null +++ b/src/serena/resources/dashboard/news/20262103.html @@ -0,0 +1,14 @@ +
+

Progressive Shortening and General Improvements in Tool Responses

+

March 21, 2026

+

+ Tools that may deliver too long results now output informative shorter + messages instead of just stating "result is too long". The mechanism uses + progressive shortening so that the contained information is optimized + for the length limit. +

+

+ In addition, several tool responses have been optimized without losing + content, making Serena even more token efficient in general. +

+
\ No newline at end of file diff --git a/src/serena/symbol.py b/src/serena/symbol.py index f8ba9dd2..72aa4bbd 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -1,3 +1,4 @@ +import copy import json import logging import os @@ -409,8 +410,23 @@ class LanguageServerSymbol(Symbol, ToStringMixin): string representation of the symbol kind (name attribute of the `SymbolKind` enum item) """ children: NotRequired[list["LanguageServerSymbol.OutputDict"]] + content_around_reference: NotRequired[str] + """set by :class:`FindReferencingSymbolsTool` when including surrounding code lines""" + reference_line: NotRequired[int] + """line number of the reference, set by :class:`FindReferencingSymbolsTool`""" - OutputDictKey = Literal["name", "name_path", "relative_path", "location", "body_location", "body", "kind", "children"] + OutputDictKey = Literal[ + "name", + "name_path", + "relative_path", + "location", + "body_location", + "body", + "kind", + "children", + "content_around_reference", + "reference_line", + ] def to_dict( self, @@ -423,6 +439,8 @@ class LanguageServerSymbol(Symbol, ToStringMixin): body: bool = False, body_location: bool = False, children_body: bool = False, + children_name_path: bool | None = None, + children_name: bool | None = None, relative_path: bool = False, child_inclusion_predicate: Callable[[Self], bool] | None = None, ) -> OutputDict: @@ -439,6 +457,8 @@ class LanguageServerSymbol(Symbol, ToStringMixin): Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. + :param children_name_path: whether to include the name path of the children; if None, defaults to the value of `name_path` + :param children_name: whether to include the name of the children; if None, defaults to the value of `name` :param relative_path: whether to include the relative path of the symbol. If `location` is True, this defines whether to include the path in the location entry. If `location` is False, this defines whether to include the relative path as a top-level entry. @@ -449,6 +469,11 @@ class LanguageServerSymbol(Symbol, ToStringMixin): """ result: LanguageServerSymbol.OutputDict = {} + if children_name_path is None: + children_name_path = name_path + if children_name is None: + children_name = name + if name_path: result["name_path"] = self.get_name_path() if name: @@ -479,8 +504,8 @@ class LanguageServerSymbol(Symbol, ToStringMixin): continue children.append( c.to_dict( - name_path=name_path, - name=name, + name_path=children_name_path, + name=children_name, kind=kind, location=location, body_location=body_location, @@ -941,35 +966,42 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): self._group_children_keys = group_children_keys self._collapse_singleton = collapse_singleton - def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str]) -> dict[str, Any]: - assert len(keys) > 0, "keys must not be empty" - # group by the first key - grouped: dict[str, Any] = {} - for item in l: - key_value = item.pop(keys[0], "unknown") - if key_value not in grouped: - grouped[key_value] = [] - grouped[key_value].append(item) - if len(keys) > 1: + def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str], is_children: bool) -> dict[str, Any] | list[Any]: + """ + :param l: the list of symbol dictionaries to group + :param keys: the keys to group by + :param children_keys: the keys to group the children by + :param is_children: whether this is a children grouping operation + :return: the (grouped) symbols + """ + if len(keys) > 0: + # group by the first key + grouped: dict[str, Any] = {} + for item in l: + key_value = item.pop(keys[0], "unknown") + if key_value not in grouped: + grouped[key_value] = [] + grouped[key_value].append(item) # continue grouping by the remaining keys for k, group in grouped.items(): - grouped[k] = self._group_by(group, keys[1:], children_keys) + grouped[k] = self._group_by(group, keys[1:], children_keys, is_children=is_children) + return grouped else: # grouping is complete; now group the children if necessary - if children_keys: - for k, group in grouped.items(): - for item in group: - if self._children_key in item: - children = item[self._children_key] - item[self._children_key] = self._group_by(children, children_keys, children_keys) - # post-process final group items - grouped = {k: [self._transform_item(i) for i in v] for k, v in grouped.items()} - return grouped + for item in l: + if self._children_key in item: + children = item[self._children_key] + item[self._children_key] = self._group_by(children, children_keys, children_keys, is_children=True) + # post-process final items + return [self._transform_item(item, is_children) for item in l] - def _transform_item(self, item: dict) -> dict: + def _transform_item(self, item: dict, is_child: bool) -> dict: """ Post-processes a final group item (which has been regrouped, i.e. some keys may have been removed), collapsing singleton items (and items containing only a single non-children key) + + :param item: the item to post-process + :param is_child: whether the item is a child item """ if self._collapse_singleton: if len(item) == 1: @@ -985,12 +1017,15 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): return new_item return item - def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict: + def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict | list: """ :param symbols: the symbols to group - :return: dictionary with the symbols grouped as defined at construction + :return: dictionary with the symbols grouped as defined at construction if at least one key was used for grouping, + otherwise the list of symbols (potentially transformed) """ - return self._group_by(symbols, self._group_keys, self._group_children_keys) # type: ignore + # avoid side effects by working on a deep-copy + symbols_copy = copy.deepcopy(symbols) + return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) # type: ignore class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): @@ -1011,16 +1046,22 @@ class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]): collapse_singleton: bool = False, map_name_path_to_name: bool = False, ) -> None: + """ + :param group_keys: keys to group main symbols by + :param group_children_keys: keys to group child symbols by + :param collapse_singleton: whether to collapse singleton symbol dictionaries + :param map_name_path_to_name: whether to transform the "name_path" key of child symbols to the bare "name" + """ super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys, collapse_singleton) self._map_name_path_to_name = map_name_path_to_name - def _transform_item(self, item: dict) -> dict: - if self._map_name_path_to_name: + def _transform_item(self, item: dict, is_child: bool) -> dict: + if self._map_name_path_to_name and is_child: # {"name_path: "Class/myMethod"} -> {"name: "myMethod"} new_item = dict(item) if "name_path" in item: name_path = new_item.pop("name_path") new_item["name"] = name_path.split("/")[-1] - return super()._transform_item(new_item) + return super()._transform_item(new_item, is_child) else: - return super()._transform_item(item) + return super()._transform_item(item, is_child) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index d4c4e64a..5f27ddf9 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -404,6 +404,8 @@ class SearchForPatternTool(Tool): matches = search_files( rel_paths_to_search, substring_pattern, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, file_reader=self.project.read_file, root_path=self.get_project_root(), paths_include_glob=paths_include_glob, @@ -414,5 +416,26 @@ class SearchForPatternTool(Tool): for match in matches: assert match.source_file_path is not None file_to_matches[match.source_file_path].append(match.to_display_string()) + + # capture lightweight match data for shortening before serialization + match_lines_by_file: dict[str, list[int]] = defaultdict(list) + for match in matches: + assert match.source_file_path is not None + match_lines_by_file[match.source_file_path].append(match.matched_lines[0].line_number) + + # shortened result closures, from least to most aggressive shortening + def make_lines_only() -> str: + """Match locations without surrounding context""" + return f"Match lines per file:\n{self._to_json(match_lines_by_file)}" + + def make_per_file_counts() -> str: + counts = {path: len(lines) for path, lines in match_lines_by_file.items()} + return f"Match counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return f"Found {len(matches)} matches in {len(match_lines_by_file)} files." + result = self._to_json(file_to_matches) - return self._limit_length(result, max_answer_chars) + return self._limit_length( + result, max_answer_chars, shortened_result_factories=[make_lines_only, make_per_file_counts, make_summary] + ) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 119a5b5f..bc0b99bc 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,8 +1,10 @@ import logging +from collections import Counter from typing import Any, Literal import serena.jetbrains.jetbrains_types as jb from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient +from serena.jetbrains.jetbrains_types import SymbolDTO from serena.symbol import JetBrainsSymbolDictGrouper from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead @@ -14,6 +16,11 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): Performs a global (or local) search for symbols using the JetBrains backend """ + # groups top-level symbols only; children are grouped separately by _group_children_by_type + symbol_dict_grouper = JetBrainsSymbolDictGrouper( + ["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True + ) + def apply( self, name_path_pattern: str, @@ -22,6 +29,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): include_body: bool = False, include_info: bool = False, search_deps: bool = False, + max_matches: int = -1, max_answer_chars: int = -1, ) -> str: """ @@ -46,21 +54,21 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; for the case where the symbol is a class, this will return its methods). - Default 0. - :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. - If a directory is passed, the search will be restricted to the files in that directory. - If a file is passed, the search will be restricted to that file. - If you have some knowledge about the codebase, you should use this parameter, as it will significantly - speed up the search as well as reduce the number of results. + Ignored if `include_body=True`. Default 0. + :param relative_path: Optional. Restrict search to this file or directory. If not specified, searches entire codebase. :param include_body: If True, include the symbol's source code. Use judiciously. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), - about the symbol (ignored if include_body is True). - Default False; info is never included for child symbols and is not included when body is requested. + about the symbol. + Default False; info is never included for child symbols or if include_body is True. :param search_deps: If True, also search in project dependencies (e.g., libraries). - :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. - -1 means the default value from the config will be used. - :return: JSON string: a list of symbols (with locations) matching the name. + :param max_matches: Maximum number of permitted matches. If exceeded, a shortened result is returned + which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol. + :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. + :return: symbols matching the name. """ + if include_body: + depth = 0 # ignore user-specified depth if body is requested + if relative_path == ".": relative_path = None with JetBrainsPluginClient.from_project(self.project) as client: @@ -75,7 +83,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): # If no additional information is requested, we still include the quick info (type signature) include_documentation = False include_quick_info = True - response_dict = client.find_symbol( + symbol_collection_response = client.find_symbol( name_path=name_path_pattern, relative_path=relative_path, depth=depth, @@ -84,8 +92,23 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): include_quick_info=include_quick_info, search_deps=search_deps, ) - result = self._to_json(response_dict) - return self._limit_length(result, max_answer_chars) + symbols = symbol_collection_response["symbols"] + + def create_shortened_result() -> str: + """Shortened results containing symbol types and identifiers (path + name_path) only, without children""" + dicts: list[SymbolDTO] = [ + {"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in symbols + ] + grouped = self.symbol_dict_grouper.group(dicts) + return f"Names with paths:\n{self._to_json(grouped)}" + + n_matches = len(symbols) + if 0 < max_matches < n_matches: + return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_shortened_result() + + grouped_symbols = self.symbol_dict_grouper.group(symbols) + result = self._to_json(grouped_symbols) + return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result]) class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): @@ -109,8 +132,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark :param name_path: name path of the symbol for which to find references; matching logic as described in find symbol tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. - :param 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. + :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :return: a list of JSON objects with the symbols referencing the requested symbol """ with JetBrainsPluginClient.from_project(self.project) as client: @@ -120,9 +142,24 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark include_quick_info=False, ) symbol_dicts = response_dict["symbols"] + + # capture file paths before grouping, which mutates the dicts + ref_paths = [s.get("relative_path", "unknown") for s in symbol_dicts] + result = self.symbol_dict_grouper.group(symbol_dicts) + + def create_shortened_result_counts_per_file() -> str: + return f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}" + + def create_shortened_result_num_results() -> str: + return f"Found {len(ref_paths)} references." + result_json = self._to_json(result) - return self._limit_length(result_json, max_answer_chars) + return self._limit_length( + result_json, + max_answer_chars, + shortened_result_factories=[create_shortened_result_counts_per_file, create_shortened_result_num_results], + ) class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): @@ -149,8 +186,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp :param relative_path: the relative path to the file to get the overview of :param depth: depth up to which descendants shall be retrieved (e.g., use 1 to also retrieve immediate children). - :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. + :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :param include_file_documentation: whether to include the file's docstring. Default False. :return: a JSON object containing the symbols grouped by kind in a compact format. """ @@ -158,16 +194,44 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp symbol_overview = client.get_symbols_overview( relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation ) + if self.USE_COMPACT_FORMAT: symbols = symbol_overview["symbols"] - result: dict[str, Any] = {"symbols": self.symbol_dict_grouper.group(symbols)} + + grouped_symbols = self.symbol_dict_grouper.group(symbols) + + shortened_result_factories = [] + + # create full result + result: dict[str, Any] = {"symbols": grouped_symbols} documentation = symbol_overview.pop("documentation", None) if documentation: result["docstring"] = documentation + shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring json_result = self._to_json(result) + + if depth > 0: + + def create_short_result_depth_0() -> str: + depth_0_symbols = [d.copy() for d in symbols] + for d in depth_0_symbols: + d.pop("children", None) + compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_symbols) + return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result) + + shortened_result_factories.append(create_short_result_depth_0) + + def create_short_result_type_counts() -> str: + type_names = [d.get("type", "unknown") for d in symbols] + return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}" + + shortened_result_factories.append(create_short_result_type_counts) else: + # this path is currently abandoned, consider introducing shortened results if ever needed + shortened_result_factories = None json_result = self._to_json(symbol_overview) - return self._limit_length(json_result, max_answer_chars) + + return self._limit_length(json_result, max_answer_chars, shortened_result_factories=shortened_result_factories) class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 148611f5..2df83bc1 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -2,7 +2,9 @@ Language server-related tools """ +import copy import os +from collections import Counter, defaultdict from collections.abc import Sequence from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper @@ -49,9 +51,32 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): :return: a JSON object containing symbols grouped by kind in a compact format. """ result = self.get_symbol_overview(relative_path, depth=depth) + + # capture kind names and depth-0 snapshots before grouping, which mutates the dicts + kind_names = [d.get("kind", "unknown") for d in result] + if depth > 0: + depth_0_result = [d.copy() for d in result] + for d in depth_0_result: + d.pop("children", None) + compact_result = self.symbol_dict_grouper.group(result) result_json_str = self._to_json(compact_result) - return self._limit_length(result_json_str, max_answer_chars) + + # shortened result closures + def make_kind_counts() -> str: + return f"Symbol counts by kind:\n{self._to_json(Counter(kind_names))}" + + if depth == 0: + shortened_results = [make_kind_counts] + else: + + def make_depth_0_result() -> str: + compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_result) + return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result) + + shortened_results = [make_depth_0_result, make_kind_counts] + + return self._limit_length(result_json_str, max_answer_chars, shortened_result_factories=shortened_results) def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]: """ @@ -99,6 +124,10 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): Performs a global (or local) search using the language server backend. """ + # group children by kind, keeping just the name (the parent's name_path makes it unambiguous); + # we don't group the top-level result list because many tests rely on it being a flat list of symbol dicts + symbol_dict_grouper = LanguageServerSymbolDictGrouper([], ["kind"], collapse_singleton=True) + # noinspection PyDefaultArgument def apply( self, @@ -110,6 +139,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 substring_matching: bool = False, + max_matches: int = -1, max_answer_chars: int = -1, ) -> str: """ @@ -131,7 +161,8 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; - for the case where the symbol is a class, this will return its methods). Default 0. + for the case where the symbol is a class, this will return its methods). + Ignored if `include_body=True`. Default 0. :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. @@ -147,10 +178,15 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): If not provided, no kinds are excluded. :param substring_matching: If True, use substring matching for the last element of the pattern, such that "Foo/get" would match "Foo/getValue" and "Foo/getData". + :param max_matches: Maximum number of permitted matches. If exceeded, a shortened result is returned + which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol. :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 symbols (with locations) matching the name. + :return: symbols (with locations) matching the name. """ + if include_body: + depth = 0 # ignore user-specified depth if include_body is True + assert max_matches != 0, "max_matches must be > 0 or equal to -1." parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None symbol_retriever = self.create_language_server_symbol_retriever() @@ -161,15 +197,43 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): substring_matching=substring_matching, within_relative_path=relative_path, ) - symbol_dicts = [dict(s.to_dict(kind=True, relative_path=True, body_location=True, depth=depth, body=include_body)) for s in symbols] + n_matches = len(symbols) + + def create_short_result_relative_path_to_name_paths() -> str: + relative_path_to_name_paths: defaultdict[str, list[str]] = defaultdict(list) + for s in symbols: + relative_path_to_name_paths[s.location.relative_path or "unknown"].append(s.get_name_path()) + return f"Shortened result:\n{self._to_json(relative_path_to_name_paths)}" + + if 0 < max_matches < n_matches: + return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_short_result_relative_path_to_name_paths() + + symbol_dicts = [ + s.to_dict( + kind=True, + name_path=True, + name=False, + relative_path=True, + body_location=True, + depth=depth, + body=include_body, + children_name=True, + children_name_path=False, + ) + for s in symbols + ] if not include_body and include_info: 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 - 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) + # In python 3.15 we could specify extra_items=True in the TypedDict definition, + # https://peps.python.org/pep-0728/ + # If we ever upgrade to 3.15, we can remove the type: ignore[typeddict-unknown-key] + s_dict["info"] = symbol_info # type: ignore[typeddict-unknown-key] + + grouped_symbol_dicts = self.symbol_dict_grouper.group(symbol_dicts) + result = self._to_json(grouped_symbol_dicts) + return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_short_result_relative_path_to_name_paths]) class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): @@ -226,10 +290,37 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): ref_dict["content_around_reference"] = content_around_ref.to_display_string() reference_dicts.append(ref_dict) + # capture lightweight reference data before grouping, which mutates the dicts + ref_summaries = [] + for ref, d in zip(references_in_symbols, reference_dicts, strict=True): + ref_summaries.append( + { + "name_path": d.get("name_path"), + "kind": d.get("kind"), + "relative_path": d.get("relative_path"), + "reference_line": ref.line, + } + ) + result = self.symbol_dict_grouper.group(reference_dicts) # type: ignore + # shortened result closures, from least to most aggressive shortening + def make_refs_without_context() -> str: + """References with name_path and reference line, without surrounding code lines""" + grouped = self.symbol_dict_grouper.group(copy.deepcopy(ref_summaries)) # type: ignore + return f"References without surrounding lines:\n{self._to_json(grouped)}" + + def make_per_file_counts() -> str: + counts = Counter(str(r["relative_path"]) for r in ref_summaries) + return f"Reference counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return f"Found {len(ref_summaries)} references." + + shortened_results = [make_refs_without_context, make_per_file_counts, make_summary] + result_json = self._to_json(result) - return self._limit_length(result_json, max_answer_chars) + return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 503ab341..18eed68f 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,7 +1,7 @@ import inspect import json from abc import ABC -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast @@ -221,16 +221,36 @@ class Tool(Component): params[param] = value log.info(f"{self.get_name_from_cls()}: {dict_string(params)}") - def _limit_length(self, result: str, max_answer_chars: int) -> str: + def _limit_length( + self, + result: str, + max_answer_chars: int, + shortened_result_factories: list[Callable[[], str]] | None = None, + ) -> str: + """Limit the length of the result string, optionally trying progressively shorter versions. + + :param result: the full result string + :param max_answer_chars: maximum allowed characters. -1 means use the default from config. + :param shortened_result_factories: optional list of closures, each producing a progressively shorter + version of the result. They are tried in order until one fits within ``max_answer_chars``. + :return: the result string, potentially replaced by a shortened version + """ if max_answer_chars == -1: max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars if max_answer_chars <= 0: raise ValueError(f"Must be positive or the default (-1), got: {max_answer_chars=}") if (n_chars := len(result)) > max_answer_chars: - result = ( - f"The answer is too long ({n_chars} characters). " - + "Please try a more specific tool query or raise the max_answer_chars parameter." + too_long_msg = ( + f"The answer is too long ({n_chars} characters). " + "You can adjust your query or raise the max_answer_chars parameter." ) + if shortened_result_factories is not None: + # try each shortening closure in order; + for make_shorter in shortened_result_factories: + shortened = make_shorter() + candidate = f"{too_long_msg}\n{shortened}" + if len(candidate) <= max_answer_chars: + return candidate + result = too_long_msg return result def is_active(self) -> bool: