feat(config): add toolHintMaxLength to control tool hint truncation

Add  to  config (default: 40, range: 20-500).
Controls how many characters of tool hints are shown in progress updates
(e.g. '$ cd …/project && npm test').

Set to 120+ to see full commands instead of truncated hints:

```json
{
  "agents": {
    "defaults": {
      "toolHintMaxLength": 120
    }
  }
}
```

- Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback
- Make path abbreviation in _abbreviate_command proportional to max_length
- Add TestToolHintMaxLength test class with 5 tests
- All 41 existing tests pass
This commit is contained in:
Tim O'Brien 2026-05-04 17:56:49 +00:00 committed by chengyongru
parent 3baa869fdb
commit f256d7ab9b
4 changed files with 62 additions and 18 deletions

View File

@ -237,6 +237,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
@ -466,12 +467,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,

View File

@ -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)

View File

@ -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}")'

View File

@ -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)