feat(dingtalk): AI Cards streaming, emoji reactions, and media handling

Cherry-picked from #10985 by pedh, adapted to current main:

* Keeps main's full group-chat gating (require_mention + allowed_users +
  free_response_chats + mention_patterns) — PR's simpler subset dropped.
* Keeps main's fire-and-forget process() dispatch + session_webhook
  fallback for SDK >= 0.24.
* Picks up PR's REQUIRES_EDIT_FINALIZE capability flag on
  BasePlatformAdapter + finalize kwarg on edit_message(), plumbed through
  stream_consumer.  Default False so Telegram/Slack/Discord/Matrix stay
  on the zero-overhead fast path.
* DingTalk AI Card lifecycle: per-chat _message_contexts, two-card flow
  (tool-progress + final response) with sibling auto-close driven by
  reply_to, idempotent 🤔Thinking → 🥳Done swap, $alibabacloud-dingtalk$
  for media URL resolution (replaces raw HTTP that was 403-ing).
* pyproject: dingtalk extra now dingtalk-stream>=0.20,<1 +
  alibabacloud-dingtalk>=2.0.0 + qrcode.

Closes #10991

Co-authored-by: pedh
This commit is contained in:
pedh 2026-04-17 19:13:09 -07:00 committed by Teknium
parent d7ef562a05
commit 4459913f40
7 changed files with 1482 additions and 82 deletions

View file

@ -1045,16 +1045,40 @@ class BasePlatformAdapter(ABC):
""" """
pass pass
# Default: the adapter treats ``finalize=True`` on edit_message as a
# no-op and is happy to have the stream consumer skip redundant final
# edits. Subclasses that *require* an explicit finalize call to close
# out the message lifecycle (e.g. rich card / AI assistant surfaces
# such as DingTalk AI Cards) override this to True (class attribute or
# property) so the stream consumer knows not to short-circuit.
REQUIRES_EDIT_FINALIZE: bool = False
async def edit_message( async def edit_message(
self, self,
chat_id: str, chat_id: str,
message_id: str, message_id: str,
content: str, content: str,
*,
finalize: bool = False,
) -> SendResult: ) -> SendResult:
""" """
Edit a previously sent message. Optional platforms that don't Edit a previously sent message. Optional platforms that don't
support editing return success=False and callers fall back to support editing return success=False and callers fall back to
sending a new message. sending a new message.
``finalize`` signals that this is the last edit in a streaming
sequence. Most platforms (Telegram, Slack, Discord, Matrix,
etc.) treat it as a no-op because their edit APIs have no notion
of message lifecycle state an edit is an edit. Platforms that
render streaming updates with a distinct "in progress" state and
require explicit closure (e.g. rich card / AI assistant surfaces
such as DingTalk AI Cards) use it to finalize the message and
transition the UI out of the streaming indicator those should
also set ``REQUIRES_EDIT_FINALIZE = True`` so callers route a
final edit through even when content is unchanged. Callers
should set ``finalize=True`` on the final edit of a streamed
response (typically when ``got_done`` fires in the stream
consumer) and leave it ``False`` on intermediate edits.
""" """
return SendResult(success=False, error="Not supported") return SendResult(success=False, error="Not supported")

File diff suppressed because it is too large Load diff

View file

@ -100,6 +100,14 @@ class GatewayStreamConsumer:
self._flood_strikes = 0 # Consecutive flood-control edit failures self._flood_strikes = 0 # Consecutive flood-control edit failures
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
self._final_response_sent = False self._final_response_sent = False
# Cache adapter lifecycle capability: only platforms that need an
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
# a redundant final edit. Everyone else keeps the fast path.
# Use ``is True`` (not ``bool(...)``) so MagicMock attribute access
# in tests doesn't incorrectly enable this path.
self._adapter_requires_finalize: bool = (
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
)
# Think-block filter state (mirrors CLI's _stream_delta tag suppression) # Think-block filter state (mirrors CLI's _stream_delta tag suppression)
self._in_think_block = False self._in_think_block = False
@ -361,7 +369,16 @@ class GatewayStreamConsumer:
if not got_done and not got_segment_break and commentary_text is None: if not got_done and not got_segment_break and commentary_text is None:
display_text += self.cfg.cursor display_text += self.cfg.cursor
current_update_visible = await self._send_or_edit(display_text) # Segment break: finalize the current message so platforms
# that need explicit closure (e.g. DingTalk AI Cards) don't
# leave the previous segment stuck in a loading state when
# the next segment (tool progress, next chunk) creates a
# new message below it. got_done has its own finalize
# path below so we don't finalize here for it.
current_update_visible = await self._send_or_edit(
display_text,
finalize=got_segment_break,
)
self._last_edit_time = time.monotonic() self._last_edit_time = time.monotonic()
if got_done: if got_done:
@ -372,10 +389,22 @@ class GatewayStreamConsumer:
if self._accumulated: if self._accumulated:
if self._fallback_final_send: if self._fallback_final_send:
await self._send_fallback_final(self._accumulated) await self._send_fallback_final(self._accumulated)
elif current_update_visible: elif (
current_update_visible
and not self._adapter_requires_finalize
):
# Mid-stream edit above already delivered the
# final accumulated content. Skip the redundant
# final edit — but only for adapters that don't
# need an explicit finalize signal.
self._final_response_sent = True self._final_response_sent = True
elif self._message_id: elif self._message_id:
self._final_response_sent = await self._send_or_edit(self._accumulated) # Either the mid-stream edit didn't run (no
# visible update this tick) OR the adapter needs
# explicit finalize=True to close the stream.
self._final_response_sent = await self._send_or_edit(
self._accumulated, finalize=True,
)
elif not self._already_sent: elif not self._already_sent:
self._final_response_sent = await self._send_or_edit(self._accumulated) self._final_response_sent = await self._send_or_edit(self._accumulated)
return return
@ -633,12 +662,15 @@ class GatewayStreamConsumer:
logger.error("Commentary send error: %s", e) logger.error("Commentary send error: %s", e)
return False return False
async def _send_or_edit(self, text: str) -> bool: async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
"""Send or edit the streaming message. """Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited), Returns True if the text was successfully delivered (sent or edited),
False otherwise. Callers like the overflow split loop use this to False otherwise. Callers like the overflow split loop use this to
decide whether to advance past the delivered chunk. decide whether to advance past the delivered chunk.
``finalize`` is True when this is the last edit in a streaming
sequence.
""" """
# Strip MEDIA: directives so they don't appear as visible text. # Strip MEDIA: directives so they don't appear as visible text.
# Media files are delivered as native attachments after the stream # Media files are delivered as native attachments after the stream
@ -672,14 +704,22 @@ class GatewayStreamConsumer:
try: try:
if self._message_id is not None: if self._message_id is not None:
if self._edit_supported: if self._edit_supported:
# Skip if text is identical to what we last sent # Skip if text is identical to what we last sent.
if text == self._last_sent_text: # Exception: adapters that require an explicit finalize
# call (REQUIRES_EDIT_FINALIZE) must still receive the
# finalize=True edit even when content is unchanged, so
# their streaming UI can transition out of the in-
# progress state. Everyone else short-circuits.
if text == self._last_sent_text and not (
finalize and self._adapter_requires_finalize
):
return True return True
# Edit existing message # Edit existing message
result = await self.adapter.edit_message( result = await self.adapter.edit_message(
chat_id=self.chat_id, chat_id=self.chat_id,
message_id=self._message_id, message_id=self._message_id,
content=text, content=text,
finalize=finalize,
) )
if result.success: if result.success:
self._already_sent = True self._already_sent = True

View file

@ -76,7 +76,7 @@ termux = [
"hermes-agent[honcho]", "hermes-agent[honcho]",
"hermes-agent[acp]", "hermes-agent[acp]",
] ]
dingtalk = ["dingtalk-stream>=0.1.0,<1", "qrcode>=7.0,<8"] dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"] feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [ rl = [

View file

@ -198,7 +198,7 @@ class TestSend:
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response) mock_client.post = AsyncMock(return_value=mock_response)
adapter._http_client = mock_client adapter._http_client = mock_client
adapter._session_webhooks["chat-123"] = "https://cached.example/webhook" adapter._session_webhooks["chat-123"] = ("https://cached.example/webhook", 9999999999999)
result = await adapter.send("chat-123", "Hello!") result = await adapter.send("chat-123", "Hello!")
assert result.success is True assert result.success is True
@ -681,3 +681,290 @@ class TestIncomingHandlerProcess:
processing_gate.set() processing_gate.set()
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
# ---------------------------------------------------------------------------
# Text extraction — mention preservation + platform sanity
# ---------------------------------------------------------------------------
class TestExtractTextMentions:
def test_preserves_at_mentions_in_text(self):
"""@mentions are routing signals (via isInAtList), not text to strip.
Stripping all @handles collateral-damages emails, SSH URLs, and
literal references the user wrote.
"""
from gateway.platforms.dingtalk import DingTalkAdapter
cases = [
("@bot hello", "@bot hello"),
("contact alice@example.com", "contact alice@example.com"),
("git@github.com:foo/bar.git", "git@github.com:foo/bar.git"),
("what does @openai think", "what does @openai think"),
("@机器人 转发给 @老王", "@机器人 转发给 @老王"),
]
for text, expected in cases:
msg = MagicMock()
msg.text = text
msg.rich_text = None
msg.rich_text_content = None
assert DingTalkAdapter._extract_text(msg) == expected, (
f"mangled: {text!r} -> {DingTalkAdapter._extract_text(msg)!r}"
)
def test_dingtalk_in_platform_enum(self):
assert Platform.DINGTALK.value == "dingtalk"
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Concurrency — chat-scoped message context
# ---------------------------------------------------------------------------
class TestMessageContextIsolation:
def test_contexts_keyed_by_chat_id(self):
"""Two concurrent chats must not clobber each other's context."""
from gateway.platforms.dingtalk import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
msg_b = MagicMock(conversation_id="chat-B", sender_staff_id="user-B")
adapter._message_contexts["chat-A"] = msg_a
adapter._message_contexts["chat-B"] = msg_b
assert adapter._message_contexts["chat-A"] is msg_a
assert adapter._message_contexts["chat-B"] is msg_b
# ---------------------------------------------------------------------------
# Card lifecycle: finalize via metadata["streaming"]
# ---------------------------------------------------------------------------
class TestCardLifecycle:
@pytest.fixture
def adapter_with_card(self):
from gateway.platforms.dingtalk import DingTalkAdapter
a = DingTalkAdapter(PlatformConfig(
enabled=True,
extra={"card_template_id": "tmpl-1"},
))
a._card_sdk = MagicMock()
a._card_sdk.create_card_with_options_async = AsyncMock()
a._card_sdk.deliver_card_with_options_async = AsyncMock()
a._card_sdk.streaming_update_with_options_async = AsyncMock()
a._http_client = AsyncMock()
a._get_access_token = AsyncMock(return_value="token")
# Minimal message context
msg = MagicMock(
conversation_id="chat-1",
conversation_type="1",
sender_staff_id="staff-1",
message_id="user-msg-1",
)
a._message_contexts["chat-1"] = msg
a._session_webhooks["chat-1"] = (
"https://api.dingtalk.com/x", 9999999999999,
)
return a
@pytest.mark.asyncio
async def test_final_reply_finalizes_card(self, adapter_with_card):
"""send(reply_to=...) creates a closed card (final response path)."""
a = adapter_with_card
result = await a.send("chat-1", "Hello", reply_to="user-msg-1")
assert result.success
call = a._card_sdk.streaming_update_with_options_async.call_args
assert call[0][0].is_finalize is True
# Not tracked as streaming — it's already closed.
assert "chat-1" not in a._streaming_cards
@pytest.mark.asyncio
async def test_intermediate_send_stays_streaming(self, adapter_with_card):
"""send() without reply_to creates an OPEN card (tool progress /
commentary / streaming first chunk). No flicker closedstreaming
when edit_message follows."""
a = adapter_with_card
result = await a.send("chat-1", "💻 terminal: ls")
assert result.success
call = a._card_sdk.streaming_update_with_options_async.call_args
assert call[0][0].is_finalize is False
# Tracked for sibling cleanup.
assert result.message_id in a._streaming_cards.get("chat-1", {})
@pytest.mark.asyncio
async def test_done_fires_only_when_reply_to_is_set(self, adapter_with_card):
"""reply_to distinguishes final response (base.py) from tool-progress
sends (run.py). Done must only fire for the former."""
a = adapter_with_card
fired: list[str] = []
a._fire_done_reaction = lambda cid: fired.append(cid)
# Tool-progress / commentary path: no reply_to — no Done.
await a.send("chat-1", "tool line")
assert fired == []
# Final response path: reply_to set — Done fires.
await a.send("chat-1", "final", reply_to="user-msg-1")
assert fired == ["chat-1"]
@pytest.mark.asyncio
async def test_edit_message_finalize_fires_done(self, adapter_with_card):
"""Stream consumer's final edit_message(finalize=True) fires Done."""
a = adapter_with_card
fired: list[str] = []
a._fire_done_reaction = lambda cid: fired.append(cid)
await a.send("chat-1", "initial")
# Reopen via edit_message(finalize=False) then close.
await a.edit_message(
chat_id="chat-1", message_id="track-X",
content="streaming...", finalize=False,
)
await a.edit_message(
chat_id="chat-1", message_id="track-X",
content="final", finalize=True,
)
assert "chat-1" in fired
@pytest.mark.asyncio
async def test_edit_message_finalize_false_tracks_sibling(self, adapter_with_card):
"""After edit_message(finalize=False), card is tracked as open."""
a = adapter_with_card
await a.edit_message(
chat_id="chat-1", message_id="track-1",
content="partial", finalize=False,
)
assert "chat-1" in a._streaming_cards
assert a._streaming_cards["chat-1"].get("track-1") == "partial"
@pytest.mark.asyncio
async def test_next_send_auto_closes_sibling_streaming_cards(
self, adapter_with_card,
):
"""Tool-progress card left open (send without reply_to + edits) must
be auto-closed when the final-reply send arrives."""
a = adapter_with_card
# First tool: intermediate send — card stays open.
r1 = await a.send("chat-1", "💻 tool1")
# Second tool: edit_message(finalize=False) — keeps streaming.
await a.edit_message(
chat_id="chat-1", message_id=r1.message_id,
content="💻 tool1\n💻 tool2", finalize=False,
)
assert r1.message_id in a._streaming_cards.get("chat-1", {})
a._card_sdk.streaming_update_with_options_async.reset_mock()
# Final response send auto-closes the sibling.
await a.send("chat-1", "final answer", reply_to="user-msg")
calls = a._card_sdk.streaming_update_with_options_async.call_args_list
assert len(calls) >= 2
# First call was the sibling close with last-seen tool-progress content.
first_req = calls[0][0][0]
assert first_req.out_track_id == r1.message_id
assert first_req.is_finalize is True
assert "tool1" in first_req.content
# Streaming tracking is cleared after close.
assert "chat-1" not in a._streaming_cards
@pytest.mark.asyncio
async def test_edit_message_requires_message_id(self, adapter_with_card):
a = adapter_with_card
result = await a.edit_message(
chat_id="chat-1", message_id="", content="x", finalize=True,
)
assert result.success is False
a._card_sdk.streaming_update_with_options_async.assert_not_called()
def test_fire_done_reaction_is_idempotent(self, adapter_with_card):
a = adapter_with_card
captured = []
def _capture(coro):
captured.append(coro)
a._spawn_bg = _capture
a._fire_done_reaction("chat-1")
a._fire_done_reaction("chat-1")
assert len(captured) == 1
captured[0].close()
# ---------------------------------------------------------------------------
# AI Card Tests
# ---------------------------------------------------------------------------
class TestDingTalkAdapterAICards:
@pytest.fixture
def config(self):
return PlatformConfig(
enabled=True,
extra={
"client_id": "test_id",
"client_secret": "test_secret",
"card_template_id": "test_card_template",
},
)
@pytest.fixture
def mock_stream_client(self):
client = MagicMock()
client.get_access_token = MagicMock(return_value="test_token")
return client
@pytest.fixture
def mock_http_client(self):
return AsyncMock()
@pytest.fixture
def mock_message(self):
msg = MagicMock()
msg.message_id = "test_msg_id"
msg.conversation_id = "test_conv_id"
msg.conversation_type = "1"
msg.sender_id = "sender1"
msg.sender_nick = "Test User"
msg.sender_staff_id = "staff1"
msg.text = MagicMock(content="Hello")
msg.session_webhook = "https://api.dingtalk.com/robot/sendBySession?session=test"
msg.session_webhook_expired_time = 999999999999
msg.create_at = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
msg.at_users = []
return msg
@pytest.mark.asyncio
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
from gateway.platforms.dingtalk import DingTalkAdapter
adapter = DingTalkAdapter(config)
adapter._stream_client = mock_stream_client
adapter._http_client = mock_http_client
adapter._message_contexts["test_conv_id"] = mock_message
adapter._session_webhooks = {"test_conv_id": ("https://api.dingtalk.com/robot/sendBySession?session=test", 9999999999999)}
adapter._card_template_id = "test_card_template"
# Mock the card SDK with proper async methods
mock_card_sdk = MagicMock()
mock_card_sdk.create_card_with_options_async = AsyncMock()
mock_card_sdk.deliver_card_with_options_async = AsyncMock()
mock_card_sdk.streaming_update_with_options_async = AsyncMock()
adapter._card_sdk = mock_card_sdk
# Mock access token
adapter._get_access_token = AsyncMock(return_value="test_token")
result = await adapter.send("test_conv_id", "Hello World")
mock_card_sdk.create_card_with_options_async.assert_called_once()
mock_card_sdk.deliver_card_with_options_async.assert_called_once()
mock_card_sdk.streaming_update_with_options_async.assert_called_once()
assert result.success is True

View file

@ -88,6 +88,51 @@ class TestCleanForDisplay:
# ── Integration: _send_or_edit strips MEDIA: ───────────────────────────── # ── Integration: _send_or_edit strips MEDIA: ─────────────────────────────
class TestFinalizeCapabilityGate:
"""Verify REQUIRES_EDIT_FINALIZE gates the redundant final edit.
Platforms that don't need an explicit finalize signal (Telegram,
Slack, Matrix, ) should skip the redundant final edit when the
mid-stream edit already delivered the final content. Platforms that
*do* need it (DingTalk AI Cards) must always receive a finalize=True
edit at the end of the stream.
"""
@pytest.mark.asyncio
async def test_identical_text_skip_respects_adapter_flag(self):
"""_send_or_edit short-circuits identical-text only when the
adapter doesn't require an explicit finalize signal."""
# Adapter without finalize requirement — should skip identical edit.
plain = MagicMock()
plain.REQUIRES_EDIT_FINALIZE = False
plain.send = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
plain.edit_message = AsyncMock()
plain.MAX_MESSAGE_LENGTH = 4096
c1 = GatewayStreamConsumer(plain, "chat_1")
await c1._send_or_edit("hello") # first send
await c1._send_or_edit("hello", finalize=True) # identical → skip
plain.edit_message.assert_not_called()
# Adapter that requires finalize — must still fire the edit.
picky = MagicMock()
picky.REQUIRES_EDIT_FINALIZE = True
picky.send = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
picky.edit_message = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
picky.MAX_MESSAGE_LENGTH = 4096
c2 = GatewayStreamConsumer(picky, "chat_1")
await c2._send_or_edit("hello")
await c2._send_or_edit("hello", finalize=True)
# Finalize edit must go through even on identical content.
picky.edit_message.assert_called_once()
assert picky.edit_message.call_args[1]["finalize"] is True
class TestSendOrEditMediaStripping: class TestSendOrEditMediaStripping:
"""Verify _send_or_edit strips MEDIA: before sending to the platform.""" """Verify _send_or_edit strips MEDIA: before sending to the platform."""

169
uv.lock generated
View file

@ -174,6 +174,120 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
] ]
[[package]]
name = "alibabacloud-credentials"
version = "1.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "alibabacloud-credentials-api" },
{ name = "alibabacloud-tea" },
{ name = "apscheduler" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/15/2b01b4a6cbed4cc2c8a1c801efec43af945af22fd3ca5f78c932117fd4ce/alibabacloud_credentials-1.0.8.tar.gz", hash = "sha256:364c22abef2d240b259ceadf1ce6800017f19a336729553956928a1edd12e769", size = 40465, upload-time = "2026-03-11T09:13:59.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/24/7c47501b24897a1379cd57cc8b8de376161f2487548fc8233b2b74ab25c7/alibabacloud_credentials-1.0.8-py3-none-any.whl", hash = "sha256:66677c3fa54aeb66cfb9cc97da4a787534f38a04d09bbfa0bc6c815fe1af7e28", size = 48799, upload-time = "2026-03-11T09:13:58.113Z" },
]
[[package]]
name = "alibabacloud-credentials-api"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
[[package]]
name = "alibabacloud-dingtalk"
version = "2.2.42"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-endpoint-util" },
{ name = "alibabacloud-gateway-dingtalk" },
{ name = "alibabacloud-gateway-spi" },
{ name = "alibabacloud-openapi-util" },
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/66/36efc03a2a8ed16c2ce176fd5ab6ff9725d0048aef33eaf867e85e625401/alibabacloud_dingtalk-2.2.42.tar.gz", hash = "sha256:220b1d52f5ef82a23ea625d3c8a91a733a685417248e217cf5aa30fe0b3a8978", size = 2023797, upload-time = "2026-04-10T03:58:28.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/80/7d1c1438e17c1fc90d037f1b73debe3fc2dfa348eb91e12818c2584d1865/alibabacloud_dingtalk-2.2.42-py3-none-any.whl", hash = "sha256:5f5c2ef3351b7926eb870af11089e14f802e4caa51d5f72920ad79a67f03d3e4", size = 2142688, upload-time = "2026-04-10T03:58:26.33Z" },
]
[[package]]
name = "alibabacloud-endpoint-util"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
[[package]]
name = "alibabacloud-gateway-dingtalk"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-gateway-spi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/40/751d8bdf133d7fcf053f10c98e8e506810e7bee06458a02eaaa14d30ac26/alibabacloud_gateway_dingtalk-1.0.2.tar.gz", hash = "sha256:acea8b0b1d11e0394913f0b0899ddd19c0bfceab716060449b57fcc250ceb300", size = 2938, upload-time = "2023-04-25T09:48:42.249Z" }
[[package]]
name = "alibabacloud-gateway-spi"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf72ee9bea60bbbeff2bc42cdfe24d2544db52bc517e1a/alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b", size = 4249, upload-time = "2025-02-23T16:29:54.222Z" }
[[package]]
name = "alibabacloud-openapi-util"
version = "0.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea-util" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/51/be5802851a4ed20ac2c6db50ac8354a6e431e93db6e714ca39b50983626f/alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34", size = 7981, upload-time = "2026-01-15T08:05:03.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/46/9b217343648b366eb93447f5d93116e09a61956005794aed5ef95a2e9e2e/alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd", size = 7661, upload-time = "2026-01-15T08:05:01.374Z" },
]
[[package]]
name = "alibabacloud-tea"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
{ name = "alibabacloud-gateway-spi" },
{ name = "alibabacloud-tea-util" },
{ name = "cryptography" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" },
]
[[package]]
name = "alibabacloud-tea-util"
version = "0.3.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/ee/ea90be94ad781a5055db29556744681fc71190ef444ae53adba45e1be5f3/alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb", size = 7515, upload-time = "2025-11-19T06:01:08.504Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
]
[[package]] [[package]]
name = "altair" name = "altair"
version = "6.0.0" version = "6.0.0"
@ -249,6 +363,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "apscheduler"
version = "3.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
]
[[package]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" version = "0.31.0"
@ -860,6 +986,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
] ]
[[package]]
name = "darabonba-core"
version = "1.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "alibabacloud-tea" },
{ name = "requests" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" },
]
[[package]] [[package]]
name = "datasets" name = "datasets"
version = "4.8.4" version = "4.8.4"
@ -1699,7 +1838,7 @@ wheels = [
[[package]] [[package]]
name = "hermes-agent" name = "hermes-agent"
version = "0.8.0" version = "0.9.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anthropic" }, { name = "anthropic" },
@ -1730,6 +1869,7 @@ all = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite", marker = "sys_platform == 'linux'" }, { name = "aiosqlite", marker = "sys_platform == 'linux'" },
{ name = "alibabacloud-dingtalk" },
{ name = "asyncpg", marker = "sys_platform == 'linux'" }, { name = "asyncpg", marker = "sys_platform == 'linux'" },
{ name = "croniter" }, { name = "croniter" },
{ name = "daytona" }, { name = "daytona" },
@ -1737,6 +1877,7 @@ all = [
{ name = "dingtalk-stream" }, { name = "dingtalk-stream" },
{ name = "discord-py", extra = ["voice"] }, { name = "discord-py", extra = ["voice"] },
{ name = "elevenlabs" }, { name = "elevenlabs" },
{ name = "fastapi" },
{ name = "faster-whisper" }, { name = "faster-whisper" },
{ name = "honcho-ai" }, { name = "honcho-ai" },
{ name = "lark-oapi" }, { name = "lark-oapi" },
@ -1756,6 +1897,7 @@ all = [
{ name = "slack-bolt" }, { name = "slack-bolt" },
{ name = "slack-sdk" }, { name = "slack-sdk" },
{ name = "sounddevice" }, { name = "sounddevice" },
{ name = "uvicorn", extra = ["standard"] },
] ]
cli = [ cli = [
{ name = "simple-term-menu" }, { name = "simple-term-menu" },
@ -1774,6 +1916,7 @@ dev = [
{ name = "pytest-xdist" }, { name = "pytest-xdist" },
] ]
dingtalk = [ dingtalk = [
{ name = "alibabacloud-dingtalk" },
{ name = "dingtalk-stream" }, { name = "dingtalk-stream" },
] ]
feishu = [ feishu = [
@ -1842,6 +1985,10 @@ voice = [
{ name = "numpy" }, { name = "numpy" },
{ name = "sounddevice" }, { name = "sounddevice" },
] ]
web = [
{ name = "fastapi" },
{ name = "uvicorn", extra = ["standard"] },
]
yc-bench = [ yc-bench = [
{ name = "yc-bench", marker = "python_full_version >= '3.12'" }, { name = "yc-bench", marker = "python_full_version >= '3.12'" },
] ]
@ -1853,19 +2000,21 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" },
{ name = "anthropic", specifier = ">=0.39.0,<1" }, { name = "anthropic", specifier = ">=0.39.0,<1" },
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" },
{ name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.1.0,<1" }, { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" },
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" }, { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
{ name = "edge-tts", specifier = ">=7.2.7,<8" }, { name = "edge-tts", specifier = ">=7.2.7,<8" },
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
{ name = "exa-py", specifier = ">=2.9.0,<3" }, { name = "exa-py", specifier = ">=2.9.0,<3" },
{ name = "fal-client", specifier = ">=0.13.1,<1" }, { name = "fal-client", specifier = ">=0.13.1,<1" },
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
{ name = "fire", specifier = ">=0.7.1,<1" }, { name = "fire", specifier = ">=0.7.1,<1" },
{ name = "firecrawl-py", specifier = ">=4.16.0,<5" }, { name = "firecrawl-py", specifier = ">=4.16.0,<5" },
@ -1894,6 +2043,7 @@ requires-dist = [
{ name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" },
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" },
{ name = "jinja2", specifier = ">=3.1.5,<4" }, { name = "jinja2", specifier = ">=3.1.5,<4" },
@ -1929,10 +2079,11 @@ requires-dist = [
{ name = "tenacity", specifier = ">=9.1.4,<10" }, { name = "tenacity", specifier = ">=9.1.4,<10" },
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
{ name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" },
] ]
provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "rl", "yc-bench", "all"] provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "web", "rl", "yc-bench", "all"]
[[package]] [[package]]
name = "hf-transfer" name = "hf-transfer"
@ -4950,6 +5101,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
] ]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]] [[package]]
name = "unpaddedbase64" name = "unpaddedbase64"
version = "2.1.0" version = "2.1.0"