From c1215471143a27cf569bc058accd1f7645a3e25d Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 8 Apr 2026 18:31:40 +0800 Subject: [PATCH] refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make tool_hint_prefix configurable in FeishuConfig (default: šŸ”§) - Delegate tool hint card updates from send() to send_delta() so hints automatically benefit from _STREAM_EDIT_INTERVAL throttling - Fix staticmethod calls to use self.__class__ instead of self - Document all supported metadata keys in send_delta docstring - Add test for empty/whitespace-only tool hint with active stream buffer --- nanobot/channels/feishu.py | 45 ++++++++++++------------- nanobot/utils/tool_hints.py | 11 ------ tests/channels/test_feishu_streaming.py | 44 +++++++++++++++--------- 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 365732595..e57fcef85 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -251,6 +251,7 @@ class FeishuConfig(Base): allow_from: list[str] = Field(default_factory=list) react_emoji: str = "THUMBSUP" done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK") + tool_hint_prefix: str = "\U0001f527" # Prefix for inline tool hints (default: šŸ”§) group_policy: Literal["open", "mention"] = "mention" reply_to_message: bool = False # If True, bot replies quote the user's original message streaming: bool = True @@ -267,7 +268,6 @@ class _FeishuStreamBuf: card_id: str | None = None sequence: int = 0 last_edit: float = 0.0 - tool_hint_len: int = 0 class FeishuChannel(BaseChannel): @@ -1265,7 +1265,15 @@ class FeishuChannel(BaseChannel): async def send_delta( self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None ) -> None: - """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.""" + """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent. + + Supported metadata keys: + _stream_end: Finalize the streaming card. + _resuming: Mid-turn pause – flush but keep the buffer alive. + _tool_hint: Delta is a formatted tool hint (for display only). + message_id: Original message id (used with _stream_end for reaction cleanup). + reaction_id: Reaction id to remove on stream end. + """ if not self._client: return meta = metadata or {} @@ -1287,7 +1295,6 @@ class FeishuChannel(BaseChannel): # next segment appends to the same card. buf = self._stream_bufs.get(chat_id) if buf and buf.card_id and buf.text: - buf.tool_hint_len = 0 buf.sequence += 1 await loop.run_in_executor( None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, @@ -1297,7 +1304,6 @@ class FeishuChannel(BaseChannel): buf = self._stream_bufs.pop(chat_id, None) if not buf or not buf.text: return - buf.tool_hint_len = 0 if buf.card_id: buf.sequence += 1 await loop.run_in_executor( @@ -1333,8 +1339,6 @@ class FeishuChannel(BaseChannel): if buf is None: buf = _FeishuStreamBuf() self._stream_bufs[chat_id] = buf - if buf.tool_hint_len > 0: - buf.tool_hint_len = 0 buf.text += delta if not buf.text.strip(): return @@ -1377,22 +1381,17 @@ class FeishuChannel(BaseChannel): return buf = self._stream_bufs.get(msg.chat_id) if buf and buf.card_id: - if buf.tool_hint_len > 0: - buf.text = buf.text[:-buf.tool_hint_len] - 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 - await loop.run_in_executor( - None, self._stream_update_text_sync, - buf.card_id, buf.text, buf.sequence, - ) - else: - await self._send_tool_hint_card( - receive_id_type, msg.chat_id, hint - ) + # Delegate to send_delta so tool hints get the same + # throttling (and card creation) as regular text deltas. + lines = self.__class__._format_tool_hint_lines(hint).split("\n") + delta = "\n\n" + "\n".join( + f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip() + ) + "\n\n" + await self.send_delta(msg.chat_id, delta) + return + await self._send_tool_hint_card( + receive_id_type, msg.chat_id, hint + ) return # Determine whether the first message should quote the user's message. @@ -1701,7 +1700,7 @@ class FeishuChannel(BaseChannel): loop = asyncio.get_running_loop() # Put each top-level tool call on its own line without altering commas inside arguments. - formatted_code = self._format_tool_hint_lines(tool_hint) + formatted_code = self.__class__._format_tool_hint_lines(tool_hint) card = { "config": {"wide_screen_mode": True}, diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index 5468fcb2b..9b6d29911 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -63,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) diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 6f5fc0580..a047c8c5f 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -310,7 +310,6 @@ class TestToolHintInlineStreaming: buf = ch._stream_bufs["oc_chat1"] 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() ch._client.im.v1.message.create.assert_not_called() @@ -319,11 +318,9 @@ 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šŸ”§ web_fetch(\"url\")\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Partial answer" + suffix, + text="Partial answer\n\nšŸ”§ web_fetch(\"url\")\n\n", card_id="card_1", sequence=3, last_edit=0.0, - tool_hint_len=len(suffix), ) ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() @@ -333,7 +330,6 @@ class TestToolHintInlineStreaming: assert "Partial answer" in buf.text assert "šŸ”§ web_fetch" in buf.text assert buf.text.endswith(" continued") - assert buf.tool_hint_len == 0 @pytest.mark.asyncio async def test_tool_hint_fallback_when_no_stream(self): @@ -352,8 +348,8 @@ class TestToolHintInlineStreaming: ch._client.im.v1.message.create.assert_called_once() @pytest.mark.asyncio - async def test_consecutive_tool_hints_replace_previous(self): - """When multiple tool hints arrive consecutively, each replaces the previous one.""" + async def test_consecutive_tool_hints_append(self): + """When multiple tool hints arrive consecutively, each appends to the card.""" ch = _make_channel() ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0, @@ -373,20 +369,19 @@ class TestToolHintInlineStreaming: await ch.send(msg2) buf = ch._stream_bufs["oc_chat1"] - assert buf.text.count("$ cd /project") == 0 - assert buf.text.count("$ git status") == 1 + assert "$ cd /project" in buf.text + assert "$ git status" in buf.text assert buf.text.startswith("Partial answer") + assert "šŸ”§ $ cd /project" in buf.text 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šŸ”§ $ cd /project\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Partial answer" + suffix, + text="Partial answer\n\nšŸ”§ $ cd /project\n\n", card_id="card_1", sequence=2, last_edit=0.0, - tool_hint_len=len(suffix), ) ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() @@ -395,17 +390,14 @@ class TestToolHintInlineStreaming: buf = ch._stream_bufs["oc_chat1"] assert "Partial answer" in buf.text assert "šŸ”§ $ cd /project" in buf.text - assert buf.tool_hint_len == 0 @pytest.mark.asyncio 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šŸ”§ web_fetch(\"url\")\n\n" ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Final content" + suffix, + text="Final content\n\nšŸ”§ web_fetch(\"url\")\n\n", card_id="card_1", sequence=3, last_edit=0.0, - tool_hint_len=len(suffix), ) ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() @@ -416,6 +408,26 @@ class TestToolHintInlineStreaming: update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0] assert "šŸ”§" in update_call.body.content + @pytest.mark.asyncio + async def test_empty_tool_hint_is_noop(self): + """Empty or whitespace-only tool hint content is silently ignored.""" + ch = _make_channel() + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0, + ) + + for content in ("", " ", "\t\n"): + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content=content, metadata={"_tool_hint": True}, + ) + await ch.send(msg) + + buf = ch._stream_bufs["oc_chat1"] + assert buf.text == "Partial answer" + assert buf.sequence == 2 + ch._client.cardkit.v1.card_element.content.assert_not_called() + class TestSendMessageReturnsId: def test_returns_message_id_on_success(self):