mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-09 20:53:38 +00:00
refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling
- 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
This commit is contained in:
parent
dcc9c057bb
commit
c121547114
@ -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},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user