mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-21 17:12:32 +00:00
fix(tool-hint): fold paths in exec commands and deduplicate by formatted string
1. exec tool hints previously used val[:40] blind character truncation, cutting paths mid-segment. Now detects file paths via regex and abbreviates them with abbreviate_path. Supports Windows, Unix absolute, and ~/ home paths. 2. Deduplication now compares fully formatted hint strings instead of tool names alone. Fixes ls /Desktop and ls /Downloads being incorrectly merged as "ls /Desktop × 2". Co-authored-by: xzq.xu <zhiqiang.xu@nodeskai.com>
This commit is contained in:
parent
af6c75141f
commit
b16865722b
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from nanobot.utils.path import abbreviate_path
|
from nanobot.utils.path import abbreviate_path
|
||||||
|
|
||||||
# Registry: tool_name -> (key_args, template, is_path, is_command)
|
# Registry: tool_name -> (key_args, template, is_path, is_command)
|
||||||
@ -17,27 +19,37 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
|
|||||||
"list_dir": (["path"], "ls {}", True, False),
|
"list_dir": (["path"], "ls {}", True, False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Matches file paths embedded in shell commands (Windows drive, ~/, or absolute after space)
|
||||||
|
_PATH_IN_CMD_RE = re.compile(
|
||||||
|
r"(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_tool_hints(tool_calls: list) -> str:
|
def format_tool_hints(tool_calls: list) -> str:
|
||||||
"""Format tool calls as concise hints with smart abbreviation."""
|
"""Format tool calls as concise hints with smart abbreviation."""
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
hints = []
|
formatted = []
|
||||||
for name, count, example_tc in _group_consecutive(tool_calls):
|
for tc in tool_calls:
|
||||||
fmt = _TOOL_FORMATS.get(name)
|
fmt = _TOOL_FORMATS.get(tc.name)
|
||||||
if fmt:
|
if fmt:
|
||||||
hint = _fmt_known(example_tc, fmt)
|
formatted.append(_fmt_known(tc, fmt))
|
||||||
elif name.startswith("mcp_"):
|
elif tc.name.startswith("mcp_"):
|
||||||
hint = _fmt_mcp(example_tc)
|
formatted.append(_fmt_mcp(tc))
|
||||||
else:
|
else:
|
||||||
hint = _fmt_fallback(example_tc)
|
formatted.append(_fmt_fallback(tc))
|
||||||
|
|
||||||
if count > 1:
|
hints = []
|
||||||
hint = f"{hint} \u00d7 {count}"
|
for hint in formatted:
|
||||||
hints.append(hint)
|
if hints and hints[-1][0] == hint:
|
||||||
|
hints[-1] = (hint, hints[-1][1] + 1)
|
||||||
|
else:
|
||||||
|
hints.append((hint, 1))
|
||||||
|
|
||||||
return ", ".join(hints)
|
return ", ".join(
|
||||||
|
f"{h} \u00d7 {c}" if c > 1 else h for h, c in hints
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_args(tc) -> dict:
|
def _get_args(tc) -> dict:
|
||||||
@ -51,17 +63,6 @@ def _get_args(tc) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _group_consecutive(calls: list) -> list[tuple[str, int, object]]:
|
|
||||||
"""Group consecutive calls to the same tool: [(name, count, first), ...]."""
|
|
||||||
groups: list[tuple[str, int, object]] = []
|
|
||||||
for tc in calls:
|
|
||||||
if groups and groups[-1][0] == tc.name:
|
|
||||||
groups[-1] = (groups[-1][0], groups[-1][1] + 1, groups[-1][2])
|
|
||||||
else:
|
|
||||||
groups.append((tc.name, 1, tc))
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_arg(tc, key_args: list[str]) -> str | None:
|
def _extract_arg(tc, key_args: list[str]) -> str | None:
|
||||||
"""Extract the first available value from preferred key names."""
|
"""Extract the first available value from preferred key names."""
|
||||||
args = _get_args(tc)
|
args = _get_args(tc)
|
||||||
@ -85,10 +86,20 @@ def _fmt_known(tc, fmt: tuple) -> str:
|
|||||||
if fmt[2]: # is_path
|
if fmt[2]: # is_path
|
||||||
val = abbreviate_path(val)
|
val = abbreviate_path(val)
|
||||||
elif fmt[3]: # is_command
|
elif fmt[3]: # is_command
|
||||||
val = val[:40] + "\u2026" if len(val) > 40 else val
|
val = _abbreviate_command(val)
|
||||||
return fmt[1].format(val)
|
return fmt[1].format(val)
|
||||||
|
|
||||||
|
|
||||||
|
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
||||||
|
"""Abbreviate paths in a command string, then truncate."""
|
||||||
|
abbreviated = _PATH_IN_CMD_RE.sub(
|
||||||
|
lambda m: abbreviate_path(m.group(), max_len=25), cmd
|
||||||
|
)
|
||||||
|
if len(abbreviated) <= max_len:
|
||||||
|
return abbreviated
|
||||||
|
return abbreviated[:max_len - 1] + "\u2026"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_mcp(tc) -> str:
|
def _fmt_mcp(tc) -> str:
|
||||||
"""Format MCP tool as server::tool."""
|
"""Format MCP tool as server::tool."""
|
||||||
name = tc.name
|
name = tc.name
|
||||||
|
|||||||
@ -52,6 +52,37 @@ class TestToolHintKnownTools:
|
|||||||
assert result.startswith("$ ")
|
assert result.startswith("$ ")
|
||||||
assert len(result) <= 50 # reasonable limit
|
assert len(result) <= 50 # reasonable limit
|
||||||
|
|
||||||
|
def test_exec_abbreviates_paths_in_command(self):
|
||||||
|
"""Windows paths in exec commands should be folded, not blindly truncated."""
|
||||||
|
cmd = "cd D:\\Documents\\GitHub\\nanobot\\.worktree\\tomain\\nanobot && git diff origin/main...pr-2706 --name-only 2>&1"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})])
|
||||||
|
assert "\u2026/" in result # path should be folded with …/
|
||||||
|
assert "worktree" not in result # middle segments should be collapsed
|
||||||
|
|
||||||
|
def test_exec_abbreviates_linux_paths(self):
|
||||||
|
"""Unix absolute paths in exec commands should be folded."""
|
||||||
|
cmd = "cd /home/user/projects/nanobot/.worktree/tomain && make build"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})])
|
||||||
|
assert "\u2026/" in result
|
||||||
|
assert "projects" not in result
|
||||||
|
|
||||||
|
def test_exec_abbreviates_home_paths(self):
|
||||||
|
"""~/ paths in exec commands should be folded."""
|
||||||
|
cmd = "cd ~/projects/nanobot/workspace && pytest tests/"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})])
|
||||||
|
assert "\u2026/" in result
|
||||||
|
|
||||||
|
def test_exec_short_command_unchanged(self):
|
||||||
|
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
||||||
|
assert result == "$ npm install typescript"
|
||||||
|
|
||||||
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||||
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||||
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})])
|
||||||
|
assert "\u2026/" in result # path folded
|
||||||
|
assert "npm" in result # chained command still visible
|
||||||
|
|
||||||
def test_web_search(self):
|
def test_web_search(self):
|
||||||
result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})])
|
result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})])
|
||||||
assert result == 'search "Claude 4 vs GPT-4"'
|
assert result == 'search "Claude 4 vs GPT-4"'
|
||||||
@ -105,22 +136,30 @@ class TestToolHintFolding:
|
|||||||
result = _hint(calls)
|
result = _hint(calls)
|
||||||
assert "\u00d7" not in result
|
assert "\u00d7" not in result
|
||||||
|
|
||||||
def test_two_consecutive_same_folded(self):
|
def test_two_consecutive_different_args_not_folded(self):
|
||||||
calls = [
|
calls = [
|
||||||
_tc("grep", {"pattern": "*.py"}),
|
_tc("grep", {"pattern": "*.py"}),
|
||||||
_tc("grep", {"pattern": "*.ts"}),
|
_tc("grep", {"pattern": "*.ts"}),
|
||||||
]
|
]
|
||||||
result = _hint(calls)
|
result = _hint(calls)
|
||||||
|
assert "\u00d7" not in result
|
||||||
|
|
||||||
|
def test_two_consecutive_same_args_folded(self):
|
||||||
|
calls = [
|
||||||
|
_tc("grep", {"pattern": "TODO"}),
|
||||||
|
_tc("grep", {"pattern": "TODO"}),
|
||||||
|
]
|
||||||
|
result = _hint(calls)
|
||||||
assert "\u00d7 2" in result
|
assert "\u00d7 2" in result
|
||||||
|
|
||||||
def test_three_consecutive_same_folded(self):
|
def test_three_consecutive_different_args_not_folded(self):
|
||||||
calls = [
|
calls = [
|
||||||
_tc("read_file", {"path": "a.py"}),
|
_tc("read_file", {"path": "a.py"}),
|
||||||
_tc("read_file", {"path": "b.py"}),
|
_tc("read_file", {"path": "b.py"}),
|
||||||
_tc("read_file", {"path": "c.py"}),
|
_tc("read_file", {"path": "c.py"}),
|
||||||
]
|
]
|
||||||
result = _hint(calls)
|
result = _hint(calls)
|
||||||
assert "\u00d7 3" in result
|
assert "\u00d7" not in result
|
||||||
|
|
||||||
def test_different_tools_not_folded(self):
|
def test_different_tools_not_folded(self):
|
||||||
calls = [
|
calls = [
|
||||||
@ -187,7 +226,7 @@ class TestToolHintMixedFolding:
|
|||||||
"""G4: Mixed folding groups with interleaved same-tool segments."""
|
"""G4: Mixed folding groups with interleaved same-tool segments."""
|
||||||
|
|
||||||
def test_read_read_grep_grep_read(self):
|
def test_read_read_grep_grep_read(self):
|
||||||
"""read×2, grep×2, read — should produce two separate groups."""
|
"""All different args — each hint listed separately."""
|
||||||
calls = [
|
calls = [
|
||||||
_tc("read_file", {"path": "a.py"}),
|
_tc("read_file", {"path": "a.py"}),
|
||||||
_tc("read_file", {"path": "b.py"}),
|
_tc("read_file", {"path": "b.py"}),
|
||||||
@ -196,7 +235,6 @@ class TestToolHintMixedFolding:
|
|||||||
_tc("read_file", {"path": "c.py"}),
|
_tc("read_file", {"path": "c.py"}),
|
||||||
]
|
]
|
||||||
result = _hint(calls)
|
result = _hint(calls)
|
||||||
assert "\u00d7 2" in result
|
assert "\u00d7" not in result
|
||||||
# Should have 3 groups: read×2, grep×2, read
|
|
||||||
parts = result.split(", ")
|
parts = result.split(", ")
|
||||||
assert len(parts) == 3
|
assert len(parts) == 5
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user