From dcc9c057bb9b2227259ae87a6bc4893d5fb5e398 Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Wed, 8 Apr 2026 13:12:47 +0800 Subject: [PATCH] fix(tool-hints): deduplicate by formatted string + per-line inline display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display fixes based on real-world Feishu testing: 1. tool_hints.py: format_tool_hints now deduplicates by comparing the fully formatted hint string instead of tool name alone. This fixes `ls /Desktop` and `ls /Downloads` being incorrectly merged as `ls /Desktop × 2`. Truly identical calls still fold correctly. (_group_consecutive and all abbreviation logic preserved unchanged.) 2. feishu.py: inline tool hints now display one tool per line with 🔧 prefix, and use double-newline trailing to prevent Setext heading rendering when followed by markdown `---`. Made-with: Cursor --- nanobot/channels/feishu.py | 4 +++- nanobot/utils/tool_hints.py | 27 +++++++++++++++---------- tests/agent/test_tool_hint.py | 21 ++++++++++++------- tests/channels/test_feishu_streaming.py | 10 ++++----- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2441c20c5..365732595 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1379,7 +1379,9 @@ class FeishuChannel(BaseChannel): if buf and buf.card_id: if buf.tool_hint_len > 0: buf.text = buf.text[:-buf.tool_hint_len] - suffix = f"\n\n---\n🔧 {hint}" + lines = self._format_tool_hint_lines(hint).split("\n") + formatted = "\n".join(f"🔧 {ln}" for ln in lines if ln.strip()) + suffix = f"\n\n{formatted}\n\n" buf.text += suffix buf.tool_hint_len = len(suffix) buf.sequence += 1 diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index bdf8f993b..5468fcb2b 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -30,21 +30,26 @@ def format_tool_hints(tool_calls: list) -> str: 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: diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index c716fdf66..080a0b1e3 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -136,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 = [ @@ -218,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"}), @@ -227,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 diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 1559ef8d3..6f5fc0580 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -309,7 +309,7 @@ class TestToolHintInlineStreaming: await ch.send(msg) buf = ch._stream_bufs["oc_chat1"] - assert buf.text.endswith('🔧 web_fetch("https://example.com")') + assert '🔧 web_fetch("https://example.com")' in buf.text assert buf.tool_hint_len > 0 assert buf.sequence == 3 ch._client.cardkit.v1.card_element.content.assert_called_once() @@ -319,7 +319,7 @@ class TestToolHintInlineStreaming: async def test_tool_hint_preserved_on_next_delta(self): """When new delta arrives, the tool hint is kept as permanent content and delta appends after it.""" ch = _make_channel() - suffix = "\n\n---\n🔧 web_fetch(\"url\")" + suffix = "\n\n🔧 web_fetch(\"url\")\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( text="Partial answer" + suffix, card_id="card_1", sequence=3, last_edit=0.0, @@ -376,13 +376,13 @@ class TestToolHintInlineStreaming: assert buf.text.count("$ cd /project") == 0 assert buf.text.count("$ git status") == 1 assert buf.text.startswith("Partial answer") - assert buf.text.endswith("🔧 $ git status") + assert "🔧 $ git status" in buf.text @pytest.mark.asyncio async def test_tool_hint_preserved_on_resuming_flush(self): """When _resuming flushes the buffer, tool hint is kept as permanent content.""" ch = _make_channel() - suffix = "\n\n---\n🔧 $ cd /project" + suffix = "\n\n🔧 $ cd /project\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( text="Partial answer" + suffix, card_id="card_1", sequence=2, last_edit=0.0, @@ -401,7 +401,7 @@ class TestToolHintInlineStreaming: async def test_tool_hint_preserved_on_final_stream_end(self): """When final _stream_end closes the card, tool hint is kept in the final text.""" ch = _make_channel() - suffix = "\n\n---\n🔧 web_fetch(\"url\")" + suffix = "\n\n🔧 web_fetch(\"url\")\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( text="Final content" + suffix, card_id="card_1", sequence=3, last_edit=0.0,