diff --git a/backend/services/pipecat/processors.py b/backend/services/pipecat/processors.py index e8628ff..702cf0d 100644 --- a/backend/services/pipecat/processors.py +++ b/backend/services/pipecat/processors.py @@ -257,8 +257,12 @@ class UserInputProcessor(FrameProcessor): return if self._should_ignore_input(): - logger.debug("通话正在结束,忽略后续文字输入") - await self._emit_result(user_input.input_id, "error", "当前不能接收新的用户输入") + logger.debug("当前会话输入已暂停,忽略用户输入") + await self._emit_result( + user_input.input_id, + "error", + "当前暂不能接收新的用户输入", + ) return await self._call_event_handler("on_user_input", user_input) @@ -438,8 +442,22 @@ class ToolInterruptionUserMuteStrategy(BaseUserMuteStrategy): elif isinstance(frame, FunctionCallResultFrame): call = self._calls.get(frame.tool_call_id) if call is not None: - call.tool_finished = True - self._release_completed() + properties = frame.properties + is_final = properties.is_final if properties else True + if is_final: + call.tool_finished = True + run_llm = ( + properties.run_llm + if properties and properties.run_llm is not None + else frame.run_llm + ) + if run_llm is False and not call.response_started: + # A tool-only result has no following bot response whose + # speech boundary could release the mute. If a preamble + # is already playing, BotStoppedSpeakingFrame still owns + # the release so allow_interruptions=False is respected. + call.response_finished = True + self._release_completed() elif isinstance(frame, FunctionCallCancelFrame): # A canceled call cannot reliably produce a follow-up response. self._calls.pop(frame.tool_call_id, None) diff --git a/backend/tests/test_tool_policy.py b/backend/tests/test_tool_policy.py index ae07d52..20c0c19 100644 --- a/backend/tests/test_tool_policy.py +++ b/backend/tests/test_tool_policy.py @@ -8,6 +8,7 @@ from pipecat.frames.frames import ( FunctionCallResultFrame, FunctionCallsStartedFrame, ) +from pipecat.services.llm_service import FunctionCallResultProperties from services.pipecat.processors import ToolInterruptionUserMuteStrategy from services.tool_policy import policy_for_tool @@ -143,6 +144,106 @@ class ToolInterruptionStrategyTests(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(BotStoppedSpeakingFrame()) ) + async def test_async_tool_only_result_releases_without_followup_speech(self): + strategy = ToolInterruptionUserMuteStrategy( + {"set_photo_button_visible": "async"} + ) + await strategy.process_frame( + FunctionCallsStartedFrame( + function_calls=[ + SimpleNamespace( + function_name="set_photo_button_visible", + tool_call_id="call_4", + ) + ] + ) + ) + + self.assertFalse( + await strategy.process_frame( + FunctionCallResultFrame( + function_name="set_photo_button_visible", + tool_call_id="call_4", + arguments={"visible": True}, + result={"status": "ok"}, + properties=FunctionCallResultProperties(run_llm=False), + ) + ) + ) + self.assertFalse(strategy.is_muted) + + async def test_async_tool_only_result_waits_for_active_preamble(self): + strategy = ToolInterruptionUserMuteStrategy( + {"set_photo_button_visible": "async"} + ) + await strategy.process_frame(BotStartedSpeakingFrame()) + await strategy.process_frame( + FunctionCallsStartedFrame( + function_calls=[ + SimpleNamespace( + function_name="set_photo_button_visible", + tool_call_id="call_5", + ) + ] + ) + ) + + self.assertTrue( + await strategy.process_frame( + FunctionCallResultFrame( + function_name="set_photo_button_visible", + tool_call_id="call_5", + arguments={"visible": True}, + result={"status": "ok"}, + properties=FunctionCallResultProperties(run_llm=False), + ) + ) + ) + self.assertFalse( + await strategy.process_frame(BotStoppedSpeakingFrame()) + ) + + async def test_async_intermediate_result_does_not_release_mute(self): + strategy = ToolInterruptionUserMuteStrategy( + {"set_photo_button_visible": "async"} + ) + await strategy.process_frame( + FunctionCallsStartedFrame( + function_calls=[ + SimpleNamespace( + function_name="set_photo_button_visible", + tool_call_id="call_6", + ) + ] + ) + ) + + self.assertTrue( + await strategy.process_frame( + FunctionCallResultFrame( + function_name="set_photo_button_visible", + tool_call_id="call_6", + arguments={"visible": True}, + result={"status": "pending"}, + properties=FunctionCallResultProperties( + run_llm=False, + is_final=False, + ), + ) + ) + ) + self.assertFalse( + await strategy.process_frame( + FunctionCallResultFrame( + function_name="set_photo_button_visible", + tool_call_id="call_6", + arguments={"visible": True}, + result={"status": "ok"}, + properties=FunctionCallResultProperties(run_llm=False), + ) + ) + ) + if __name__ == "__main__": unittest.main()