diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 07006b057..984711975 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -242,6 +242,7 @@ class AgentLoop: else defaults.max_tool_result_chars ) self.provider_retry_mode = provider_retry_mode + self.tool_hint_max_length = defaults.tool_hint_max_length self.web_config = web_config or WebToolsConfig() self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service @@ -471,12 +472,11 @@ class AgentLoop: """Return the chat id shown in runtime metadata for the model.""" return str(msg.metadata.get("context_chat_id") or msg.chat_id) - @staticmethod - def _tool_hint(tool_calls: list) -> str: + def _tool_hint(self, tool_calls: list) -> str: """Format tool calls as concise hints with smart abbreviation.""" from nanobot.utils.tool_hints import format_tool_hints - return format_tool_hints(tool_calls) + return format_tool_hints(tool_calls, max_length=self.tool_hint_max_length) async def _dispatch_command_inline( self, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 2f20eb99e..aa8b0a5e5 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -81,6 +81,13 @@ class AgentDefaults(Base): max_concurrent_subagents: int = Field(default=1, ge=1) max_tool_result_chars: int = 16_000 provider_retry_mode: Literal["standard", "persistent"] = "standard" + tool_hint_max_length: int = Field( + default=40, + ge=20, + le=500, + validation_alias=AliasChoices("toolHintMaxLength"), + serialization_alias="toolHintMaxLength", + ) # Max characters for tool hint display (e.g. "$ cd …/project && npm test") reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" unified_session: bool = False # Share one session across all channels (single-user multi-device) diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index 9758700b1..75d3e3521 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -27,7 +27,7 @@ _PATH_IN_CMD_RE = re.compile( ) -def format_tool_hints(tool_calls: list) -> str: +def format_tool_hints(tool_calls: list, max_length: int = 40) -> str: """Format tool calls as concise hints with smart abbreviation.""" if not tool_calls: return "" @@ -36,11 +36,11 @@ def format_tool_hints(tool_calls: list) -> str: for tc in tool_calls: fmt = _TOOL_FORMATS.get(tc.name) if fmt: - formatted.append(_fmt_known(tc, fmt)) + formatted.append(_fmt_known(tc, fmt, max_length)) elif tc.name.startswith("mcp_"): - formatted.append(_fmt_mcp(tc)) + formatted.append(_fmt_mcp(tc, max_length)) else: - formatted.append(_fmt_fallback(tc)) + formatted.append(_fmt_fallback(tc, max_length)) hints = [] for hint in formatted: @@ -80,7 +80,7 @@ def _extract_arg(tc, key_args: list[str]) -> str | None: return None -def _fmt_known(tc, fmt: tuple) -> str: +def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str: """Format a registered tool using its template.""" val = _extract_arg(tc, fmt[0]) if val is None: @@ -88,18 +88,20 @@ def _fmt_known(tc, fmt: tuple) -> str: if fmt[2]: # is_path val = abbreviate_path(val) elif fmt[3]: # is_command - val = _abbreviate_command(val) + val = _abbreviate_command(val, max_len=max_length) return fmt[1].format(val) def _abbreviate_command(cmd: str, max_len: int = 40) -> str: """Abbreviate paths in a command string, then truncate.""" + path_max = max(max_len // 2, 25) + def _replace_path(match: re.Match[str]) -> str: if match.group("double") is not None: - return f'"{abbreviate_path(match.group("double"), max_len=25)}"' + return f'"{abbreviate_path(match.group("double"), max_len=path_max)}"' if match.group("single") is not None: - return f"'{abbreviate_path(match.group('single'), max_len=25)}'" - return abbreviate_path(match.group("bare"), max_len=25) + return f"'{abbreviate_path(match.group('single'), max_len=path_max)}'" + return abbreviate_path(match.group("bare"), max_len=path_max) abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd) if len(abbreviated) <= max_len: @@ -107,7 +109,7 @@ def _abbreviate_command(cmd: str, max_len: int = 40) -> str: return abbreviated[:max_len - 1] + "\u2026" -def _fmt_mcp(tc) -> str: +def _fmt_mcp(tc, max_length: int = 40) -> str: """Format MCP tool as server::tool.""" name = tc.name if "__" in name: @@ -125,13 +127,13 @@ def _fmt_mcp(tc) -> str: val = next((v for v in args.values() if isinstance(v, str) and v), None) if val is None: return f"{server}::{tool}" - return f'{server}::{tool}("{abbreviate_path(val, 40)}")' + return f'{server}::{tool}("{abbreviate_path(val, max_length)}")' -def _fmt_fallback(tc) -> str: +def _fmt_fallback(tc, max_length: int = 40) -> str: """Original formatting logic for unregistered tools.""" args = _get_args(tc) val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name - return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' + return f'{tc.name}("{abbreviate_path(val, max_length)}")' if len(val) > max_length else f'{tc.name}("{val}")' diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index b8ba99284..ff73fbb5c 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -8,9 +8,9 @@ def _tc(name: str, args) -> ToolCallRequest: return ToolCallRequest(id="c1", name=name, arguments=args) -def _hint(calls): +def _hint(calls, max_length=40): """Shortcut for format_tool_hints.""" - return format_tool_hints(calls) + return format_tool_hints(calls, max_length=max_length) class TestToolHintKnownTools: @@ -254,3 +254,38 @@ class TestToolHintMixedFolding: assert "\u00d7" not in result parts = result.split(", ") assert len(parts) == 5 + + +class TestToolHintMaxLength: + """Test max_length parameter controls truncation of tool hints.""" + + def test_exec_default_truncates_at_40(self): + cmd = "cd /very/long/path/to/some/project && npm run build && npm test" + result = _hint([_tc("exec", {"command": cmd})], max_length=40) + assert len(result) <= 50 # "$ " prefix + 40 + ellipsis + assert "\u2026" in result + + def test_exec_larger_max_length_shows_more(self): + cmd = "cd /very/long/path/to/some/project && npm run build && npm test" + short = _hint([_tc("exec", {"command": cmd})], max_length=40) + long = _hint([_tc("exec", {"command": cmd})], max_length=120) + assert len(long) > len(short) + assert "npm test" in long + + def test_exec_max_length_120_shows_full_command(self): + cmd = "cd /home/user/project && npm install && npm run build" + result = _hint([_tc("exec", {"command": cmd})], max_length=120) + assert "npm run build" in result + + def test_fallback_respects_max_length(self): + long_val = "a" * 100 + result = _hint([_tc("custom_tool", {"data": long_val})], max_length=60) + assert "\u2026" in result + result_40 = _hint([_tc("custom_tool", {"data": long_val})], max_length=40) + assert len(result) > len(result_40) + + def test_mcp_respects_max_length(self): + long_url = "https://example.com/very/long/path/to/resource" + result = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=80) + result_40 = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=40) + assert len(result) >= len(result_40)