Skip to content

Commit a568b28

Browse files
wehosHongzhi Wenclaude
authored
fix(memory): recall_memory 弱模型健壮性 + 首次调用即时占位语音 (#1528)
* fix(memory): recall_memory 弱模型健壮性 + 首次调用即时占位语音 测试 free-model 时发现问她"昨天聊了啥"会"似掉"(整轮无回复):弱模型在 recall_memory 上反复换措辞死循环调用,达工具迭代上限后无文本产出、整轮静音。 - 工具调用迭代上限 6→3;封顶后做一次去 tools 的 forced-finalize,逼模型基于 已积累的 tool 结果输出最终文本,避免封顶即静默(openai + genai 两条路径对偶) - recall 同时带 query+time 却 0 命中时,返回提示让模型放宽过滤(只用 time 或只用 query)再查一次,而不是干巴巴"没有找到相关记忆"让其直接放弃 - 本轮首次真正发起 recall 时立即喂一段"让我回忆一下"占位语音填补检索空窗:用 独立 worker-sid 自成一段 utterance 立即合成出声,正文用真正 turn sid 入队不受 影响(避免被 worker 当 text_done 残余丢弃);voice 模式 use_tts=False 自动 no-op - 新增 i18n: RECALL_MEMORY_TOOL_NO_RESULT_LOOSEN / RECALL_MEMORY_TOOL_FILLER(全语种) - 更新 test_offline_iteration_cap_breaks_runaway_loop 覆盖 forced-finalize Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): recall filler 音频对前端归一回 turn sid,修打断失效 Codex P1:filler 用合成 worker-sid (`{turn_sid}::recall-filler`) 切 utterance,但 在「把 request-id 透传进音频事件」的 provider(minimax 的 ("__audio__", sid, ...) 路径)下,该合成 sid 会经 send_speech 原样发到前端。前端打断 skip 按 turn sid 精确 匹配,filler chunk 匹配不上 → 用户打断后 filler 仍继续播,barge-in 失效。 在唯一的客户端音频出口 send_speech 把 `::recall-filler` 后缀剥掉、归一回 turn sid: worker 层仍用合成 sid 切分 utterance,前端永远只看到 turn sid,打断正常取消 filler。 StepFun/free 路径发裸音频(无 sid,回落 current_speech_id) 不受影响。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): 处理 CodeRabbit 评审 — filler 入队成功才标记、loosen 仅限请求成功、genai forced-finalize 不吞异常 - _recall_filler_spoken_sid 改为仅在 _emit_recall_filler_tts 真正入队成功后才置位: 否则 worker 未 ready 返回 False 时会误标"已预热",同轮后续 recall 不再补发 filler、 barge-in 守卫也误跳 - NO_RESULT_LOOSEN 仅在 memory server 请求成功且 0 命中时返回:加 recall_request_ok 守卫,避免 non-2xx / 异常的临时故障被当成"过滤太窄"误导模型换条件重试、白烧迭代预算 - genai forced-finalize 不再 try/except 吞异常:与 openai 路径一致让 SDK 失败上抛, 交给上层 retry / 状态上报 / 清泡泡逻辑,避免把真实失败伪装成空回复 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): 移除 filler 对正文首包 TTS 清理的例外,恢复无条件 barge-in Codex P1:handle_text_data 首包清理是某些路径(no-server-VAD 的 response.done 只 rotate sid、不清 TTS)下唯一的打断点;为 recall filler 跳过它会让上一轮残留音频漏 清、与新轮重叠,破坏 barge-in。 该例外是早期同-sid 设计的遗留(防正文首包冲掉同 sid 的 filler pending)。现在 filler 走独立 worker sid 并在检索期间立即 flush + 发往前端,正文首包到达时 filler 早已送达, pending / response_queue 里不再有它,清理碰不到——例外已无必要。移除后恢复无条件清理。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): genai forced-finalize 补齐空回复诊断字段 CodeRabbit Minor:新增的 genai forced-finalize 只透传文本,未像常规 genai 分支那样 回填 finish_reason / block_reason / prompt_tokens。若兜底生成被 safety / recitation / max-tokens 挡住而无文本,上层 empty-completion 诊断只能引用上一轮 tool-iteration 的 过期 finish_reason。现采集这三个字段落 self._last_*,并在无文本时打 INFO 诊断,与 常规 genai 分支对偶。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): openai forced-finalize 过滤 reasoning-only chunk + filler 冷启动有界等待 - openai forced-finalize 与常规 tool-loop 一致,跳过 thinking 模型的纯 reasoning chunk(有 reasoning_content、无 content/tool delta/finish/usage):否则 stream_text 会在隐藏推理 token 上记 TTFT,污染封顶轮的延迟埋点(Codex P2)。genai 兜底已靠 part.thought 过滤,不受影响。 - recall filler 冷启动有界等待:ensure_tts_pipeline_alive 不等 __ready__,首轮 recall 紧接 emit 时 tts_ready 可能仍 False 导致 filler 被丢、首轮空窗。加 ~1s 有界轮询等 待 tts_ready(TTS 通常 ~0.1s 就绪),超时优雅放弃、sid 变化提前退出,不阻塞检索主 流程(CodeRabbit Major)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): genai forced-finalize 用局部 final_prompt_tokens,避免回填上一轮 token CodeRabbit Minor:之前 self._last_prompt_tokens 只在本次 forced-finalize 有 usage 时 更新;若被 safety/recitation 挡住且无 usage,INFO log 与上层 LLM_NO_RESPONSE 警告会 沿用上一轮 tool-iteration 的 token 数(诊断串台)。改用局部 final_prompt_tokens (默认 None)流内收集、流结束后无条件回填 self._last_prompt_tokens(可能为 None), 与 finish_reason / block_reason 同口径。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): filler 冷启动等待提前退出 + openai forced-finalize 局部 prompt_tokens - recall filler 冷启动等待循环:sid 变化、worker 没起/已挂、或已进入 NO_RETRY_TTS_CODES 不可恢复错误时立即退出,不再白等满 ~1s;否则 TTS 确定失败时同轮每次 recall 都吃满 这段延迟(CodeRabbit Minor)。 - openai forced-finalize 与 genai 路径对齐:prompt_tokens 走局部 final_prompt_tokens、 流结束后无条件回填 self._last_prompt_tokens(无 usage 写 None),避免上层 empty- completion 诊断沿用上一轮 tool-iteration 的旧 token 数(CodeRabbit outside-diff)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): filler 切 sid 前先 flush+commit 本轮 pre-tool 正文,防截断 Codex P2:provider 在同一 turn 先吐 content 再进 tool_calls 时,pre-tool 正文可能还 卡在 turn_sid 的 normalizer/stripper 里没 flush。直接用合成 filler_sid 调 _enqueue_tts_text_chunk 会因 sid 变化 reset stripper 丢掉这段 pending,且 worker 换 连接也会丢 server 端缓冲,造成同轮正文缺字。 切 filler sid 前:若本轮确有 turn_sid 文本入队(_tts_norm_speech_id == turn_sid), 先把 stripper pending flush 出去、再用 (None,None) 把 turn_sid utterance commit 掉 (worker 发 text.done 后才换 sid,不丢内容);直接放 (None,None) 不置 _tts_done_queued_for_turn,正文/收尾仍各自正常 flush。模型首动作即调 recall(无 pre-tool 文本)时该块跳过,行为不变。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(memory): NO_RETRY_TTS 态下不为 filler 重启 worker CodeRabbit Minor:ensure_tts_pipeline_alive 在 NO_RETRY_TTS_CODES 检查之前就无条件 调用,而它直接调 _start_tts_thread、绕过 _respawn_tts_worker 的 no-retry 闸。于是 API_ARREARS / API_KEY_REJECTED 这类不可恢复态下,同轮每次 recall 都会重启一次注定 失败的 worker。改为仅在非 NO_RETRY 态才拉起管线,不可恢复时直接走下面的早退放弃 filler。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b07a59 commit a568b28

4 files changed

Lines changed: 294 additions & 12 deletions

File tree

config/prompts/prompts_memory.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3029,6 +3029,31 @@ def get_memory_recall_rerank_prompt(lang: str = "zh") -> str:
30293029
"pt": "Nenhuma memória relevante encontrada.",
30303030
}
30313031

3032+
# 同时给了 query 和 time 却 0 命中时返回这条——提示模型放宽过滤条件,
3033+
# 用「只带时间」或「只带 query」再查一次,而不是直接当作没有记忆放弃。
3034+
RECALL_MEMORY_TOOL_NO_RESULT_LOOSEN = {
3035+
"zh": "在该时间范围内没有找到匹配「{query}」的记忆。建议放宽过滤条件重试一次:要么只用 time(按时间回溯该时段的记忆),要么只用 query(不限时间地语义检索)。",
3036+
"en": "No memory matched \"{query}\" within that time range. Try loosening the filter and querying once more: either with time only (recall memories from that period) or with query only (semantic search without a time limit).",
3037+
"ja": "その時間範囲で「{query}」に一致する記憶は見つかりませんでした。フィルタを緩めてもう一度試してください:time だけ(その期間の記憶を回想)か、query だけ(時間制限なしの意味検索)のどちらかで。",
3038+
"ko": "해당 시간 범위에서 \"{query}\"에 일치하는 기억을 찾지 못했습니다. 필터를 완화해 다시 시도해 보세요: time만 사용(해당 기간의 기억 회상)하거나 query만 사용(시간 제한 없는 의미 검색)하세요.",
3039+
"ru": "В этом диапазоне времени не нашлось воспоминаний по запросу «{query}». Попробуйте ослабить фильтр и запросить ещё раз: либо только time (вспомнить воспоминания за тот период), либо только query (семантический поиск без ограничения по времени).",
3040+
"es": "No se encontró ninguna memoria que coincidiera con \"{query}\" en ese rango de tiempo. Prueba a aflojar el filtro y consultar de nuevo: con solo time (recordar memorias de ese período) o con solo query (búsqueda semántica sin límite de tiempo).",
3041+
"pt": "Nenhuma memória correspondeu a \"{query}\" nesse intervalo de tempo. Tente afrouxar o filtro e consultar novamente: apenas com time (recordar memórias daquele período) ou apenas com query (busca semântica sem limite de tempo).",
3042+
}
3043+
3044+
# 本轮首次调用 recall_memory 时立即喂给 TTS 的占位语音,填补检索 + 多轮
3045+
# 工具调用的空窗,避免冷场。只进 TTS,不进前端气泡 / 不进对话历史。带省略号
3046+
# 让 http_sentence normalizer 当作完整句子立即 flush 合成,不与随后的正文黏连。
3047+
RECALL_MEMORY_TOOL_FILLER = {
3048+
"zh": "让我回忆一下哦……",
3049+
"en": "Let me recall that for a moment...",
3050+
"ja": "ちょっと思い出してみるね……",
3051+
"ko": "잠깐 떠올려 볼게……",
3052+
"ru": "Дай-ка вспомню…",
3053+
"es": "Déjame recordar un momento...",
3054+
"pt": "Deixa eu lembrar um pouquinho...",
3055+
}
3056+
30323057
# 召回到 N 条记忆时的总览首句;后面接渲染条目,每条按
30333058
# ``[tier/entity] text (事件日期, 相对标签)`` 格式(tier/entity 是英文
30343059
# enum,不翻译;text 是原始记忆内容,按用户拍板"不翻译";时间锚点优先

main_logic/core.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,18 @@
7575
RECALL_MEMORY_TOOL_QUERY_DESCRIPTION,
7676
RECALL_MEMORY_TOOL_TIME_DESCRIPTION,
7777
RECALL_MEMORY_TOOL_NO_RESULT,
78+
RECALL_MEMORY_TOOL_NO_RESULT_LOOSEN,
79+
RECALL_MEMORY_TOOL_FILLER,
7880
RECALL_MEMORY_TOOL_FOUND_HEADER,
7981
)
8082

83+
# recall 占位语音用的合成 worker-sid 后缀。仅用于在 TTS worker 层把 filler 切成
84+
# 一段独立 utterance(见 _emit_recall_filler_tts);``send_speech`` 在发往前端前会
85+
# 把它剥掉、归一回本轮 turn sid。否则在「把 request-id 透传进音频事件」的 provider
86+
# (如 minimax 的 ("__audio__", sid, ...) 路径)下,filler 音频会带着合成 sid 到前端,
87+
# 用户打断时前端按 turn sid 匹配不到 filler chunk,barge-in 取消不掉 filler。
88+
_RECALL_FILLER_SID_SUFFIX = "::recall-filler"
89+
8190

8291
# 内部 item 渲染时的视觉标记。状态信息已在外层 SYSTEM_NOTIFICATION_TASK_ACTIVE
8392
# 表达,emoji 仅作快速视觉识别用。
@@ -1174,6 +1183,61 @@ async def _request_tts_done_for_turn(
11741183

11751184
return status
11761185

1186+
async def _emit_recall_filler_tts(self, text: str, turn_sid: str) -> bool:
1187+
"""把 recall 占位语音作为一个**独立 worker utterance 立即合成播放**。
1188+
1189+
关键设计——用一个区别于本轮 turn sid 的 *worker-only* filler sid 入队,
1190+
随后发 ``(None, None)`` flush:
1191+
1192+
- TTS worker 把 filler 当成一段完整 utterance 立即 commit 合成出声(填补
1193+
检索空窗);
1194+
- 之后正文用真正的 turn sid 入队时,worker 看到 ``current_speech_id != sid``
1195+
会自动开新 utterance 并 reset ``text_done_sent``。若 filler 复用同一个
1196+
turn sid,worker 的 ``sid is None`` 分支只置 ``text_done_sent=True`` 却不
1197+
换 sid,正文就会在 ``if text_done_sent: 丢弃残余文本`` 处被整段丢掉
1198+
(= 正文没声音)。用独立 sid 正是绕开这个 worker 行为。
1199+
1200+
注意:worker 内部 sid 仅用于切分 utterance;发往前端的音频仍带 core 的
1201+
``self.current_speech_id``(= turn sid),所以前端看到的是同一轮连续音频,
1202+
无需改动前端。
1203+
1204+
未就绪时直接放弃即时 filler(返回 False),**不**退化成"塞进 pending 等
1205+
正文一起 flush"——那正是之前"filler 粘在正文前"的旧 bug。
1206+
"""
1207+
if not self.use_tts:
1208+
return False
1209+
async with self.tts_cache_lock:
1210+
if self.current_speech_id != turn_sid:
1211+
return False
1212+
if not (self.tts_ready and self.tts_thread and self.tts_thread.is_alive()):
1213+
return False
1214+
# 切到 filler 的 worker-sid 之前,先处理本轮 turn_sid 可能还在管线里的
1215+
# pre-tool 正文(provider 先吐 content 再进 tool_calls 时会有,见
1216+
# _astream_openai_with_tools 的 streamed_text_buffer)。直接 _enqueue
1217+
# filler_sid 会让 _enqueue_tts_text_chunk 因 sid 变化 reset stripper、丢掉
1218+
# turn_sid 仍 pending 的文本,且 worker 换连接也会丢 server 端缓冲,造成同轮
1219+
# 正文缺字(Codex P2)。所以先把 stripper pending flush 出去、并用 (None,None)
1220+
# 把 turn_sid utterance commit 掉(worker 发 text.done 后才换 sid,不丢内容)。
1221+
# 仅当本轮确有 turn_sid 文本入过队(_tts_norm_speech_id == turn_sid)才触发;
1222+
# 模型首动作即调 recall(无 pre-tool 文本)时跳过,行为不变。
1223+
if self._tts_norm_speech_id == turn_sid:
1224+
pre_tool = self._tts_markdown_stripper.flush()
1225+
if pre_tool:
1226+
pre_tool = self._tts_bracket_stripper.feed(pre_tool)
1227+
self._tts_bracket_stripper.flush()
1228+
if pre_tool:
1229+
self.tts_request_queue.put((turn_sid, pre_tool))
1230+
self._remember_pending_ai_voice_echo(turn_sid, pre_tool)
1231+
# 直接放 (None,None),不走 _request_tts_done_locked,故不置
1232+
# _tts_done_queued_for_turn——正文/收尾仍各自正常 flush。
1233+
self.tts_request_queue.put((None, None))
1234+
filler_sid = f"{turn_sid}{_RECALL_FILLER_SID_SUFFIX}"
1235+
self._enqueue_tts_text_chunk(filler_sid, text)
1236+
# flush 这段独立 utterance。用 filler_sid 而非 turn sid,所以**不**触碰
1237+
# 本轮 _tts_done_queued_for_turn——正文之后仍按正常 turn-end 流程 flush。
1238+
self.tts_request_queue.put((None, None))
1239+
return True
1240+
11771241
def _remember_avatar_interaction_id(self, interaction_id: str) -> None:
11781242
if interaction_id in self._recent_avatar_interaction_id_set:
11791243
return
@@ -1409,6 +1473,13 @@ async def handle_text_data(
14091473
# 如果是新消息的第一个chunk,清空TTS队列和缓存以打断之前的语音。
14101474
# summary epilogue 触发的 TTS-only 注入 is_first_chunk=False,不会
14111475
# 误清掉本轮已经播放/排队的 prefix 音频。
1476+
#
1477+
# 注意:这里**不**为 recall 占位语音(filler)开例外。filler 走独立 worker
1478+
# sid 并在检索期间就立即 flush + 经 tts_response_handler 发往前端,正文首
1479+
# chunk 到达时 filler 早已送达,pending / response_queue 里不再有它,清理碰
1480+
# 不到。反过来,这个首包清理在某些路径(如 no-server-VAD 的 response.done
1481+
# 只 rotate sid、不清 TTS)是下一个唯一的打断点,若为 filler 跳过会让上一轮
1482+
# 残留音频漏清、与新轮重叠,破坏 barge-in(Codex P1)。故保持无条件清理。
14121483
if is_first_chunk and self.use_tts and tts_enabled:
14131484
async with self.tts_cache_lock:
14141485
self.tts_pending_chunks.clear()
@@ -2853,6 +2924,63 @@ async def _handle_recall_memory_call(self, arguments: dict) -> str:
28532924
logger.debug("[recall_memory] empty-query args=%s", args_dict)
28542925
return _loc(RECALL_MEMORY_TOOL_NO_RESULT, _lang)
28552926

2927+
# 本轮首次真正发起回忆检索时,立刻喂一段"让我回忆一下"占位语音给 TTS,
2928+
# 填补 hybrid_recall + 可能的多轮工具调用造成的空窗,避免猫娘那边长时间
2929+
# 沉默。用 current_speech_id 去重,保证一轮只播一次(模型一轮里可能连调
2930+
# 好几次 recall)。只进 TTS,不进前端气泡 / 不进历史;voice 模式下
2931+
# feed_tts_chunk 因 use_tts=False 自动 no-op。
2932+
cur_sid = self.current_speech_id
2933+
if cur_sid and self.use_tts and getattr(self, "_recall_filler_spoken_sid", None) != cur_sid:
2934+
try:
2935+
# 关键:这一轮的 TTS worker 通常在正文首个 chunk 才懒启动,而
2936+
# recall 发生在正文之前——若此时 worker 没起,filler 只会进
2937+
# tts_pending_chunks,等正文来了 worker ready 才一起 flush,导致
2938+
# 占位语音被粘在正文前一起播、失去"填补空窗"的意义。所以这里
2939+
# 主动把管线(worker 线程 + response handler 任务)拉起来,让 worker
2940+
# 在检索这几秒内就绪,filler 一就绪即被 handler flush 合成播放。
2941+
# 但 NO_RETRY_TTS_CODES(API_ARREARS / API_KEY_REJECTED 等不可恢复态)下
2942+
# 不要拉起:ensure_tts_pipeline_alive 直接调 _start_tts_thread,会绕过
2943+
# _respawn_tts_worker 的 no-retry 闸,等于同轮每次 recall 都重启一次注定
2944+
# 失败的 worker。此时跳过,直接走下面的早退放弃 filler。
2945+
if getattr(self, "_last_tts_error_code", None) not in NO_RETRY_TTS_CODES:
2946+
await self.ensure_tts_pipeline_alive()
2947+
# 冷启动有界等待:ensure_tts_pipeline_alive 只拉起 worker/handler,
2948+
# 不等 __ready__。首轮 recall 紧接着 emit 时 tts_ready 可能还是 False,
2949+
# 导致 _emit_recall_filler_tts 直接返回 False、首轮空窗依旧。TTS 通常
2950+
# ~0.1s 就绪,这里给 ~1s 有界等待;超时则优雅放弃 filler(不阻塞回忆
2951+
# 检索主流程),由后续 recall 调用或正文兜底。
2952+
# 提前退出:sid 变化(用户打断)、worker 没起来/已挂、或已进入
2953+
# NO_RETRY_TTS_CODES 这类不可恢复错误时,TTS 不可能再 ready,别白等
2954+
# 满 1s——否则 TTS 确定失败时同轮每次 recall 都会吃满这段延迟。
2955+
if not self.tts_ready:
2956+
for _ in range(20):
2957+
if (
2958+
self.current_speech_id != cur_sid
2959+
or not (self.tts_thread and self.tts_thread.is_alive())
2960+
or getattr(self, "_last_tts_error_code", None) in NO_RETRY_TTS_CODES
2961+
):
2962+
break
2963+
await asyncio.sleep(0.05)
2964+
if self.tts_ready:
2965+
break
2966+
# 用独立 worker-sid 把 filler 作为一段完整 utterance 立即合成出声,
2967+
# 既能在检索空窗里马上播,又不会让正文(同 turn sid)被 worker 当成
2968+
# "text_done 之后的残余文本"丢弃。详见 _emit_recall_filler_tts。
2969+
_filler_ok = await self._emit_recall_filler_tts(
2970+
_loc(RECALL_MEMORY_TOOL_FILLER, _lang), cur_sid,
2971+
)
2972+
# 仅在真正入队成功后才标记"本轮已播过":否则(worker 未 ready 等
2973+
# 返回 False)会误判已预热,本轮后续 recall 不再补发 filler,且
2974+
# handle_text_data 的 barge-in 守卫也会按"已预热"误跳过。
2975+
if _filler_ok:
2976+
self._recall_filler_spoken_sid = cur_sid
2977+
logger.debug(
2978+
"[recall_memory] filler TTS emitted=%s (sid=%s tts_ready=%s)",
2979+
_filler_ok, cur_sid, self.tts_ready,
2980+
)
2981+
except Exception as _filler_err:
2982+
logger.debug("[recall_memory] filler TTS skipped: %s", _filler_err)
2983+
28562984
# POST 到 memory_server。query 始终原样下传,不能因为带了 time 就清空
28572985
# —— 下游路由:query + time → hybrid_recall(query, time_window=...) 做
28582986
# "语义 + 时间"联合检索(窗口内按 query 排序,语义匹配保留);只有 time
@@ -2861,6 +2989,7 @@ async def _handle_recall_memory_call(self, arguments: dict) -> str:
28612989
if time_arg:
28622990
post_body["time"] = time_arg
28632991
result_payload: dict = {}
2992+
recall_request_ok = False # 仅当 memory server 真正成功返回时才置真
28642993
try:
28652994
from utils.internal_http_client import get_internal_http_client
28662995
client = get_internal_http_client()
@@ -2884,6 +3013,7 @@ async def _handle_recall_memory_call(self, arguments: dict) -> str:
28843013
)
28853014
else:
28863015
result_payload = resp.json()
3016+
recall_request_ok = True
28873017
except Exception as exc:
28883018
logger.warning(
28893019
"[recall_memory] memory_server call failed (%s: %s); "
@@ -2911,6 +3041,14 @@ async def _handle_recall_memory_call(self, arguments: dict) -> str:
29113041
)
29123042

29133043
if not results:
3044+
# 同时带了 query 和 time 却 0 命中:八成是两个过滤条件叠加太窄
3045+
# (时间窗口里没有语义匹配的条目)。别直接报"没有记忆"让模型放弃,
3046+
# 提示它放宽——只留 time 或只留 query 再查一次。
3047+
# 仅在请求**真正成功返回**时才给放宽提示:non-2xx / 异常也会落到
3048+
# results=[],那是 memory server 临时故障,不该误导模型"换条件重试"
3049+
# 白烧刚收紧的工具迭代预算。
3050+
if recall_request_ok and query and time_arg:
3051+
return _loc(RECALL_MEMORY_TOOL_NO_RESULT_LOOSEN, _lang).format(query=query)
29143052
return _loc(RECALL_MEMORY_TOOL_NO_RESULT, _lang)
29153053

29163054
# 渲染:首行 i18n 总览 + 每条 markdown bullet
@@ -6862,6 +7000,11 @@ async def send_speech(self, tts_audio, speech_id: Optional[str] = None):
68627000
try:
68637001
if self.websocket and hasattr(self.websocket, 'client_state') and self.websocket.client_state == self.websocket.client_state.CONNECTED:
68647002
effective_speech_id = speech_id if speech_id is not None else self.current_speech_id
7003+
# recall 占位语音在 worker 层用合成 sid 切分 utterance;对前端必须归一回
7004+
# turn sid,否则透传 request-id 的 provider 下,filler 音频带着合成 sid,
7005+
# 打断时前端按 turn sid 匹配不到 → barge-in 取消不掉 filler。
7006+
if isinstance(effective_speech_id, str) and effective_speech_id.endswith(_RECALL_FILLER_SID_SUFFIX):
7007+
effective_speech_id = effective_speech_id[: -len(_RECALL_FILLER_SID_SUFFIX)]
68657008
await self.websocket.send_json({
68667009
"type": "audio_chunk",
68677010
"speech_id": effective_speech_id

0 commit comments

Comments
 (0)