Improved retrieval tool responses (#1190)

* Improved symbol retrieval tool responses

1. FindSymbol (LS and JB) now has max_matches. Both tools now perform grouping like other symbol retrieval tools
2. When a result exceeds max_answer_chars, the caller may pass a shortened message. This is used in references and overview tools

* Progressive shortening for tool results that exceed max_answer_chars

Change _limit_length to accept an optional list of closures instead of
a single shortened_result string. Each closure produces a progressively
shorter version of the result, tried in order until one fits. The last
closure is assumed to always fit and is used unconditionally.

* Extended grouper

More flexible grouping logic was needed to achieve the above results. Grouper also no longer
mutates the input (using deepcopy)

---------

Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
Michael PanchenkoandDominik Jain authored and GitHub committed 2026-03-27 08:34:21 +01:00
1 parent 483538a293
commit c6250aeeab
7 files changed
+511 -67

No files matched your search

+191
View File
@@ -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}")
@@ -0,0 +1,14 @@
<div class="news-item">
<h3>Progressive Shortening and General Improvements in Tool Responses</h3>
<p class="date">March 21, 2026</p>
<p>
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.
</p>
<p>
In addition, several tool responses have been optimized without losing
content, making Serena even more token efficient in general.
</p>
</div>
+72 -31
View File
@@ -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)
+24 -1
View File
@@ -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]
)
+85 -21
View File
@@ -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):
+100 -9
View File
@@ -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):
+25 -5
View File
@@ -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: