Skip to content

Commit bb89fe7

Browse files
committed
fix(wecombot): strip chain-of-thought blocks, rotate stream per round, deliver outbox media
The WeCom AI Bot adapter was leaking <think></think> tags in two ways: (1) the LiteLLM requester only stripped the legacy CRETIRE_REASONING_BEGINk marker, missing the public <think> convention used by MiniMax-M3 and similar providers; (2) the WeCom AI Bot stream channel was being closed as soon as the first LLM round finalised, so the second-round push (after a tool call) silently fell back to a markdown reply_text that WeCom does not render as a collapsible "thinking" block. This change fixes both: * LiteLLMRequester._process_thinking_content now strips both <think>…</think> and the legacy marker, with a stateful _ThinkStripState that handles tags split across streaming chunks. LocalAgentRunner._StreamAccumulator exposes the same strip as a final-pass safety net and the second-round accumulator is no longer seeded with the first round's content (which made every intermediate round repeat the opening line in the user reply). * WecomBotWsClient.push_stream_chunk now rotates the stream_id on is_final=True instead of tearing down the stream channel. The new stream_id lets the next LLM round open a fresh streaming message that WeCom renders as its own "thinking" block instead of a raw <think> chunk. * WecomBotMessageConverter.yiri2target no longer drops Image / Voice / File components -- it returns a list of per-component items, and the adapter routes media through a new upload_media (3-step aibot_upload_init / aibot_upload_chunk / aibot_upload_finish protocol) + reply_image / reply_file / reply_voice path. This unblocks the /workspace/outbox/<query_id>/ auto-delivery hook that was silently broken because Image components were dropped before reaching the SDK. Tests cover public-think stripping, stream rotation across tool rounds, and the upload-media / reply-media protocol.
1 parent 00e2103 commit bb89fe7

9 files changed

Lines changed: 1922 additions & 36 deletions

File tree

src/langbot/libs/wecom_ai_bot_api/ws_client.py

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,58 @@
3434
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
3535
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
3636
CMD_SEND_MSG = 'aibot_send_msg'
37+
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
38+
# command names below match the convention used by the official aibot
39+
# Python SDK (``wecom-aibot-sdk-python``). If the WeCom AI Bot rejects
40+
# them, the SDK logs the server ``errcode`` to aid debugging.
41+
CMD_UPLOAD_INIT = 'aibot_upload_init'
42+
CMD_UPLOAD_CHUNK = 'aibot_upload_chunk'
43+
CMD_UPLOAD_FINISH = 'aibot_upload_finish'
44+
45+
# Default upload chunk size: 512 KB (matches the official SDK).
46+
_UPLOAD_CHUNK_SIZE = 512 * 1024
47+
48+
# File-extension to MIME content-type mapping used by ``upload_media``.
49+
# The WeCom AI Bot uses ``content_type`` in the upload-init body to
50+
# decide whether the file is an image, voice, video, or generic
51+
# document.
52+
_UPLOAD_CONTENT_TYPE_BY_EXT: dict[str, str] = {
53+
'.png': 'image/png',
54+
'.jpg': 'image/jpeg',
55+
'.jpeg': 'image/jpeg',
56+
'.gif': 'image/gif',
57+
'.bmp': 'image/bmp',
58+
'.webp': 'image/webp',
59+
'.mp3': 'audio/mpeg',
60+
'.wav': 'audio/wav',
61+
'.amr': 'audio/amr',
62+
'.ogg': 'audio/ogg',
63+
'.mp4': 'video/mp4',
64+
'.pdf': 'application/pdf',
65+
'.doc': 'application/msword',
66+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
67+
'.xls': 'application/vnd.ms-excel',
68+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
69+
'.txt': 'text/plain',
70+
'.md': 'text/markdown',
71+
'.json': 'application/json',
72+
}
73+
74+
75+
def _guess_content_type(filename: str) -> str:
76+
"""Return the MIME type for *filename* based on its extension.
77+
78+
Falls back to ``application/octet-stream`` when the extension is
79+
not recognized, matching the WeCom AI Bot default.
80+
"""
81+
import os as _os
82+
83+
if not filename:
84+
return 'application/octet-stream'
85+
_, ext = _os.path.splitext(str(filename).lower())
86+
return _UPLOAD_CONTENT_TYPE_BY_EXT.get(
87+
ext, 'application/octet-stream'
88+
)
3789

3890

3991
def _generate_req_id(prefix: str) -> str:
@@ -258,6 +310,161 @@ async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdow
258310
body['text'] = {'content': content}
259311
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
260312

313+
# ------------------------------------------------------------------
314+
# Media upload (image / voice / file)
315+
# ------------------------------------------------------------------
316+
317+
async def upload_media(
318+
self,
319+
data: bytes,
320+
filename: str = 'attachment',
321+
) -> Optional[dict]:
322+
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
323+
324+
Implements the three-step protocol documented for the WeCom
325+
AI Bot:
326+
327+
1. ``aibot_upload_init`` — declare file name, size, content
328+
type, MD5 and chunk count; receive ``upload_id``.
329+
2. ``aibot_upload_chunk`` — send each chunk (base64-encoded
330+
bytes) until done; receive per-chunk ACK.
331+
3. ``aibot_upload_finish`` — finalize the upload; receive
332+
``media_id``.
333+
334+
Returns a dict with the final ``media_id`` (and the raw
335+
``finish`` ACK) on success, or ``None`` on any failure. The
336+
caller is expected to ignore the result and continue
337+
gracefully — the framework will keep working without media
338+
delivery.
339+
"""
340+
import base64 as _b64
341+
import hashlib as _hl
342+
343+
if not data:
344+
return None
345+
346+
file_size = len(data)
347+
file_md5 = _hl.md5(data).hexdigest()
348+
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
349+
if total_chunks == 0:
350+
total_chunks = 1
351+
content_type = _guess_content_type(filename)
352+
353+
# Step 1: init.
354+
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
355+
init_body = {
356+
'file_name': filename,
357+
'file_size': file_size,
358+
'file_md5': file_md5,
359+
'content_type': content_type,
360+
'total_chunks': total_chunks,
361+
}
362+
init_ack = await self._send_reply(
363+
init_req_id,
364+
init_body,
365+
cmd=CMD_UPLOAD_INIT,
366+
)
367+
if not init_ack or init_ack.get('errcode', 0) != 0:
368+
await self.logger.warning(
369+
f'upload_media init failed: ack={init_ack!r}'
370+
)
371+
return None
372+
upload_id = init_ack.get('upload_id') or init_ack.get('data', {}).get('upload_id')
373+
if not upload_id:
374+
await self.logger.warning(
375+
f'upload_media init returned no upload_id: ack={init_ack!r}'
376+
)
377+
return None
378+
379+
# Step 2: chunks.
380+
for index in range(total_chunks):
381+
start = index * _UPLOAD_CHUNK_SIZE
382+
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
383+
chunk_bytes = data[start:end]
384+
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
385+
chunk_body = {
386+
'upload_id': upload_id,
387+
'chunk_index': index,
388+
'chunk_base64': _b64.b64encode(chunk_bytes).decode('ascii'),
389+
}
390+
chunk_ack = await self._send_reply(
391+
chunk_req_id,
392+
chunk_body,
393+
cmd=CMD_UPLOAD_CHUNK,
394+
)
395+
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
396+
await self.logger.warning(
397+
f'upload_media chunk {index} failed: ack={chunk_ack!r}'
398+
)
399+
return None
400+
401+
# Step 3: finish.
402+
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
403+
finish_body = {'upload_id': upload_id}
404+
finish_ack = await self._send_reply(
405+
finish_req_id,
406+
finish_body,
407+
cmd=CMD_UPLOAD_FINISH,
408+
)
409+
if not finish_ack or finish_ack.get('errcode', 0) != 0:
410+
await self.logger.warning(
411+
f'upload_media finish failed: ack={finish_ack!r}'
412+
)
413+
return None
414+
415+
media_id = (
416+
finish_ack.get('media_id')
417+
or finish_ack.get('data', {}).get('media_id')
418+
)
419+
if not media_id:
420+
await self.logger.warning(
421+
f'upload_media finish returned no media_id: ack={finish_ack!r}'
422+
)
423+
return None
424+
await self.logger.info(
425+
f'upload_media OK: filename={filename!r} media_id={media_id!r}'
426+
)
427+
return {'media_id': media_id, 'ack': finish_ack}
428+
429+
async def _reply_media(
430+
self,
431+
req_id: str,
432+
media_id: str,
433+
kind: str,
434+
) -> Optional[dict]:
435+
"""Send a media reply (image / voice / file) referencing *media_id*.
436+
437+
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
438+
the standard ``aibot_respond_msg`` command with a per-kind
439+
body key (matches the convention documented for the WeCom
440+
AI Bot SDK).
441+
"""
442+
if kind not in {'image', 'voice', 'file'}:
443+
await self.logger.warning(
444+
f'_reply_media called with unknown kind={kind!r}'
445+
)
446+
return None
447+
body = {
448+
'msgtype': kind,
449+
kind: {'media_id': media_id},
450+
}
451+
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
452+
453+
async def reply_image(
454+
self, req_id: str, media_id: str
455+
) -> Optional[dict]:
456+
return await self._reply_media(req_id, media_id, 'image')
457+
458+
async def reply_file(
459+
self, req_id: str, media_id: str
460+
) -> Optional[dict]:
461+
return await self._reply_media(req_id, media_id, 'file')
462+
463+
async def reply_voice(
464+
self, req_id: str, media_id: str
465+
) -> Optional[dict]:
466+
return await self._reply_media(req_id, media_id, 'voice')
467+
261468
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
262469
"""Push a streaming chunk for a given message ID.
263470
@@ -293,7 +500,18 @@ async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = Fa
293500
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
294501
self._stream_last_content[msg_id] = content
295502
if is_final:
296-
self._stream_ids.pop(msg_id, None)
503+
# End-of-round: keep the stream entry but rotate the
504+
# stream_id so the next LLM round (e.g. after a tool
505+
# call) opens a *new* stream message. Without this, the
506+
# stream channel stays mapped to the same stream_id,
507+
# the next push reuses it, and WeCom — which closes the
508+
# stream when it sees ``finish=true`` — would never render
509+
# the next round as its own streaming message (so the
510+
# chain-of-thought tag would fall back to the markdown
511+
# path and be shown as raw text instead of a
512+
# collapsible "thinking" block).
513+
new_stream_id = _generate_req_id('stream')
514+
self._stream_ids[msg_id] = f'{req_id}|{new_stream_id}'
297515
self._stream_last_content.pop(msg_id, None)
298516
self._stream_sessions.pop(msg_id, None)
299517
return True

0 commit comments

Comments
 (0)