mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 00:15:05 +00:00
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:
1 parent
483538a293
commit
c6250aeeab
7 files changed
+511
-67
No files matched your search
@@ -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
@@ -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)
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user