Skip to content

Commit 0d84345

Browse files
committed
feat: 支持回复频率为零时静默接收
1 parent a611233 commit 0d84345

4 files changed

Lines changed: 238 additions & 16 deletions

File tree

pytests/test_maisaka_timing_gate.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
import asyncio
55
import pytest
66

7+
from src.chat.heart_flow.heartFC_utils import CycleDetail
8+
from src.common.utils.utils_config import ChatConfigUtils
9+
from src.config.config import global_config
710
from src.core.tooling import ToolAvailabilityContext, ToolExecutionResult, ToolInvocation
811
from src.llm_models.payload_content.tool_option import ToolCall
12+
from src.maisaka import reasoning_engine as reasoning_engine_module
913
from src.maisaka.builtin_tool import get_timing_tools
1014
from src.maisaka.chat_loop_service import ChatResponse, MaisakaChatLoopService
1115
from src.maisaka.context_messages import AssistantMessage, TIMING_GATE_INVALID_TOOL_HINT_SOURCE
16+
from src.maisaka.history_post_processor import HistoryPostProcessResult
1217
from src.maisaka.reasoning_engine import MaisakaReasoningEngine
1318
from src.maisaka.runtime import MaisakaHeartFlowChatting
1419

@@ -238,6 +243,7 @@ def test_forced_timing_trigger_bypasses_message_frequency_threshold() -> None:
238243
_internal_turn_queue=asyncio.Queue(),
239244
_has_pending_messages=lambda: True,
240245
_get_pending_message_count=lambda: 1,
246+
_is_reply_frequency_silent=lambda: False,
241247
_has_forced_timing_trigger=lambda: True,
242248
_cancel_deferred_message_turn_task=lambda: None,
243249
)
@@ -253,6 +259,110 @@ def _fail_get_message_trigger_threshold() -> int:
253259
assert runtime._internal_turn_queue.get_nowait() == "message"
254260

255261

262+
def test_zero_reply_frequency_keeps_effective_zero(monkeypatch: pytest.MonkeyPatch) -> None:
263+
runtime = object.__new__(MaisakaHeartFlowChatting)
264+
runtime.session_id = "test-session"
265+
runtime.chat_stream = SimpleNamespace(is_group_session=True)
266+
runtime._talk_frequency_adjust = 1.0
267+
268+
monkeypatch.setattr(global_config.chat, "talk_value", 0.0)
269+
monkeypatch.setattr(
270+
ChatConfigUtils,
271+
"get_talk_value",
272+
staticmethod(lambda session_id, is_group_chat=None: 1.0),
273+
)
274+
275+
assert runtime._get_effective_reply_frequency() == 0.0
276+
assert runtime._is_reply_frequency_silent() is True
277+
278+
279+
def test_zero_reply_frequency_schedules_silent_turn_before_forced_trigger() -> None:
280+
runtime = SimpleNamespace(
281+
_STATE_WAIT="wait",
282+
_agent_state="stop",
283+
_message_turn_scheduled=False,
284+
_internal_turn_queue=asyncio.Queue(),
285+
_has_pending_messages=lambda: True,
286+
_get_pending_message_count=lambda: 1,
287+
_is_reply_frequency_silent=lambda: True,
288+
_cancel_deferred_message_turn_task=lambda: None,
289+
)
290+
291+
def _fail_has_forced_timing_trigger() -> bool:
292+
raise AssertionError("回复频率为 0 时不应进入 @/提及强制触发分支")
293+
294+
runtime._has_forced_timing_trigger = _fail_has_forced_timing_trigger
295+
296+
MaisakaHeartFlowChatting._schedule_message_turn(runtime) # type: ignore[arg-type]
297+
298+
assert runtime._message_turn_scheduled is True
299+
assert runtime._internal_turn_queue.get_nowait() == "message"
300+
301+
302+
@pytest.mark.asyncio
303+
async def test_silent_post_process_skips_mid_term_summary_but_keeps_learning(
304+
monkeypatch: pytest.MonkeyPatch,
305+
) -> None:
306+
removed_messages = [SimpleNamespace(count_in_context=True)]
307+
final_history = [SimpleNamespace(count_in_context=True)]
308+
process_result = HistoryPostProcessResult(
309+
history=final_history,
310+
removed_messages=removed_messages,
311+
removed_count=1,
312+
changed_count=1,
313+
remaining_context_count=1,
314+
)
315+
trim_logs: list[tuple[int, int]] = []
316+
learning_messages: list[object] = []
317+
318+
async def _fake_trigger_learning(messages: object) -> None:
319+
learning_messages.append(messages)
320+
321+
runtime = SimpleNamespace(
322+
_chat_history=[SimpleNamespace(count_in_context=True)],
323+
_max_context_size=1,
324+
log_prefix="[test]",
325+
session_id="test-session",
326+
_log_history_trimmed=lambda removed_count, remaining_count: trim_logs.append(
327+
(removed_count, remaining_count)
328+
),
329+
_trigger_trimmed_history_learning=_fake_trigger_learning,
330+
)
331+
engine = MaisakaReasoningEngine(runtime) # type: ignore[arg-type]
332+
scheduled_coroutines: list[object] = []
333+
334+
def _fake_create_task(coro: object) -> SimpleNamespace:
335+
scheduled_coroutines.append(coro)
336+
return SimpleNamespace()
337+
338+
async def _fail_build_mid_term_memory_message(*args: object, **kwargs: object) -> None:
339+
del args, kwargs
340+
raise AssertionError("静默模式裁切历史不应生成中期记忆摘要")
341+
342+
monkeypatch.setattr(
343+
reasoning_engine_module,
344+
"process_chat_history_after_cycle",
345+
lambda *args, **kwargs: process_result,
346+
)
347+
monkeypatch.setattr(
348+
reasoning_engine_module,
349+
"build_mid_term_memory_message",
350+
_fail_build_mid_term_memory_message,
351+
)
352+
monkeypatch.setattr(reasoning_engine_module.asyncio, "create_task", _fake_create_task)
353+
354+
await engine._post_process_chat_history_after_cycle(
355+
CycleDetail(cycle_id=1),
356+
enable_mid_term_memory=False,
357+
)
358+
for coro in scheduled_coroutines:
359+
await coro
360+
361+
assert runtime._chat_history == final_history
362+
assert trim_logs == [(1, 1)]
363+
assert learning_messages == [removed_messages]
364+
365+
256366
def test_finish_tool_is_not_written_back_to_history() -> None:
257367
finish_call = ToolCall(call_id="finish-call", func_name="finish", args={})
258368
reply_call = ToolCall(call_id="reply-call", func_name="reply", args={})

src/maisaka/builtin_tool/send_emoji.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ async def _select_emoji_with_sub_agent(
381381
selection_duration_ms = round((datetime.now() - selection_started_at).total_seconds() * 1000, 2)
382382

383383
selection_metrics: Dict[str, Any] = {
384+
"model_name": getattr(response, "model_name", "") or "",
384385
"prompt_tokens": response.prompt_tokens,
385386
"completion_tokens": response.completion_tokens,
386387
"total_tokens": response.total_tokens,

src/maisaka/reasoning_engine.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -472,9 +472,12 @@ async def run_loop(self) -> None:
472472
message_triggered, timeout_triggered, proactive_triggered = self._drain_ready_turn_triggers(
473473
queued_trigger
474474
)
475+
silent_reply_frequency = self._runtime._is_reply_frequency_silent()
475476

476-
if self._runtime._agent_state == self._runtime._STATE_WAIT and not (
477-
timeout_triggered or proactive_triggered
477+
if (
478+
self._runtime._agent_state == self._runtime._STATE_WAIT
479+
and not (timeout_triggered or proactive_triggered)
480+
and not silent_reply_frequency
478481
):
479482
self._runtime._message_turn_scheduled = False
480483
logger.debug(f"{self._runtime.log_prefix} 当前仍处于 wait 状态,忽略消息触发并继续等待超时")
@@ -516,6 +519,14 @@ async def run_loop(self) -> None:
516519
self._build_wait_completed_message(has_new_messages=False)
517520
)
518521

522+
if silent_reply_frequency:
523+
await self._handle_silent_turn(
524+
cached_messages=cached_messages,
525+
timeout_triggered=timeout_triggered,
526+
proactive_triggered=proactive_triggered,
527+
)
528+
continue
529+
519530
try:
520531
timing_gate_required = self._is_independent_timing_gate_enabled()
521532
if not timing_gate_required:
@@ -874,6 +885,41 @@ async def run_loop(self) -> None:
874885
logger.error(traceback.format_exc())
875886
raise
876887

888+
async def _handle_silent_turn(
889+
self,
890+
*,
891+
cached_messages: list[SessionMessage],
892+
timeout_triggered: bool,
893+
proactive_triggered: bool,
894+
) -> None:
895+
"""回复频率为 0 时只消费消息和维护历史,不进入 Timing Gate/Planner。"""
896+
897+
self._runtime._clear_force_next_timing_continue_state()
898+
if proactive_triggered:
899+
self._runtime._proactive_anchor_message = None
900+
901+
cycle_detail = CycleDetail(cycle_id=self._runtime._cycle_counter)
902+
await self._post_process_chat_history_after_cycle(
903+
cycle_detail,
904+
enable_mid_term_memory=False,
905+
)
906+
self._runtime._enter_stop_state()
907+
if self._runtime._running:
908+
self._runtime._update_stage_status("等待消息", "回复频率为 0,已静默接收消息")
909+
910+
trigger_labels: list[str] = []
911+
if cached_messages:
912+
trigger_labels.append(f"消息={len(cached_messages)}")
913+
if timeout_triggered:
914+
trigger_labels.append("wait_timeout")
915+
if proactive_triggered:
916+
trigger_labels.append("proactive")
917+
trigger_text = " ".join(trigger_labels) if trigger_labels else "无新消息"
918+
logger.info(
919+
f"{self._runtime.log_prefix} 回复频率为 0,静默接收并完成历史维护,"
920+
f"不进入 Timing Gate/Planner;{trigger_text}"
921+
)
922+
877923
def _drain_ready_turn_triggers(
878924
self,
879925
queued_trigger: Literal["message", "timeout", "proactive"],
@@ -1087,7 +1133,12 @@ async def _end_cycle(self, cycle_detail: CycleDetail, only_long_execution: bool
10871133
self._runtime._log_cycle_completed(cycle_detail, timer_strings)
10881134
return cycle_detail
10891135

1090-
async def _post_process_chat_history_after_cycle(self, cycle_detail: CycleDetail) -> None:
1136+
async def _post_process_chat_history_after_cycle(
1137+
self,
1138+
cycle_detail: CycleDetail,
1139+
*,
1140+
enable_mid_term_memory: bool = True,
1141+
) -> None:
10911142
"""裁剪聊天历史,保证用户消息数量不超过配置限制。"""
10921143
process_result = process_chat_history_after_cycle(
10931144
self._runtime._chat_history,
@@ -1098,7 +1149,11 @@ async def _post_process_chat_history_after_cycle(self, cycle_detail: CycleDetail
10981149
return
10991150

11001151
final_history = process_result.history
1101-
if process_result.removed_messages and bool(global_config.chat.mid_term_memory):
1152+
if (
1153+
process_result.removed_messages
1154+
and enable_mid_term_memory
1155+
and bool(global_config.chat.mid_term_memory)
1156+
):
11021157
logger.info(
11031158
f"{self._runtime.log_prefix} 开始生成中期聊天记录摘要: "
11041159
f"裁切上下文消息数量={len(process_result.removed_messages)} "

0 commit comments

Comments
 (0)