diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index a907a2700..bdf8f993b 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,6 +19,11 @@ _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.""" @@ -85,10 +92,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..c716fdf66 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"'