From b16865722bddd9776bd7bdfb74213db8465f7354 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 8 Apr 2026 11:21:29 +0800 Subject: [PATCH] fix(tool-hint): fold paths in exec commands and deduplicate by formatted string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nanobot/utils/tool_hints.py | 57 +++++++++++++++++++++-------------- tests/agent/test_tool_hint.py | 52 +++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index a907a2700..9b6d29911 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + from nanobot.utils.path import abbreviate_path # 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), } +# 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: """Format tool calls as concise hints with smart abbreviation.""" if not tool_calls: return "" - hints = [] - for name, count, example_tc in _group_consecutive(tool_calls): - fmt = _TOOL_FORMATS.get(name) + formatted = [] + for tc in tool_calls: + fmt = _TOOL_FORMATS.get(tc.name) if fmt: - hint = _fmt_known(example_tc, fmt) - elif name.startswith("mcp_"): - hint = _fmt_mcp(example_tc) + formatted.append(_fmt_known(tc, fmt)) + elif tc.name.startswith("mcp_"): + formatted.append(_fmt_mcp(tc)) else: - hint = _fmt_fallback(example_tc) + formatted.append(_fmt_fallback(tc)) - if count > 1: - hint = f"{hint} \u00d7 {count}" - hints.append(hint) + hints = [] + for hint in formatted: + 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: @@ -51,17 +63,6 @@ def _get_args(tc) -> dict: 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: """Extract the first available value from preferred key names.""" args = _get_args(tc) @@ -85,10 +86,20 @@ def _fmt_known(tc, fmt: tuple) -> str: if fmt[2]: # is_path val = abbreviate_path(val) elif fmt[3]: # is_command - val = val[:40] + "\u2026" if len(val) > 40 else val + val = _abbreviate_command(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: """Format MCP tool as server::tool.""" name = tc.name diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index 2384cfbb2..080a0b1e3 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -52,6 +52,37 @@ class TestToolHintKnownTools: assert result.startswith("$ ") 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): result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})]) assert result == 'search "Claude 4 vs GPT-4"' @@ -105,22 +136,30 @@ class TestToolHintFolding: result = _hint(calls) assert "\u00d7" not in result - def test_two_consecutive_same_folded(self): + def test_two_consecutive_different_args_not_folded(self): calls = [ _tc("grep", {"pattern": "*.py"}), _tc("grep", {"pattern": "*.ts"}), ] 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 - def test_three_consecutive_same_folded(self): + def test_three_consecutive_different_args_not_folded(self): calls = [ _tc("read_file", {"path": "a.py"}), _tc("read_file", {"path": "b.py"}), _tc("read_file", {"path": "c.py"}), ] result = _hint(calls) - assert "\u00d7 3" in result + assert "\u00d7" not in result def test_different_tools_not_folded(self): calls = [ @@ -187,7 +226,7 @@ class TestToolHintMixedFolding: """G4: Mixed folding groups with interleaved same-tool segments.""" 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 = [ _tc("read_file", {"path": "a.py"}), _tc("read_file", {"path": "b.py"}), @@ -196,7 +235,6 @@ class TestToolHintMixedFolding: _tc("read_file", {"path": "c.py"}), ] result = _hint(calls) - assert "\u00d7 2" in result - # Should have 3 groups: read×2, grep×2, read + assert "\u00d7" not in result parts = result.split(", ") - assert len(parts) == 3 + assert len(parts) == 5