From 1af1cd7fedfca49e61387f387b485e44bfbcc3e5 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 4 Aug 2026 11:49:49 +0800 Subject: [PATCH] feat: trigger prompt reply after opening confirmation --- backend/services/brains/prompt_brain.py | 23 ++++++++++++++++++++--- backend/tests/test_brains.py | 10 +++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/services/brains/prompt_brain.py b/backend/services/brains/prompt_brain.py index 070a314..e24dcb2 100644 --- a/backend/services/brains/prompt_brain.py +++ b/backend/services/brains/prompt_brain.py @@ -10,7 +10,11 @@ from uuid import uuid4 from loguru import logger from models import AssistantConfig from pipecat.adapters.schemas.function_schema import FunctionSchema -from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame +from pipecat.frames.frames import ( + LLMRunFrame, + OutputTransportMessageUrgentFrame, + TTSSpeakFrame, +) from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.frame_processor import FrameProcessor from pipecat.services.llm_service import ( @@ -165,6 +169,7 @@ class PromptBrain(BaseBrain): raise RuntimeError("PromptBrain 尚未初始化") opening_message = self._opening_message() opening_actions = self._startup_actions("opening") + generate_after_confirmation = opening_message is not None speech = ( self._render_greeting(self._cfg).strip() if opening_message is not None @@ -179,7 +184,8 @@ class PromptBrain(BaseBrain): speak=self._speak_opening, set_input_enabled=runtime.set_input_enabled, input_already_blocked=self._opening_input_blocked, - release_input_on_success=not bool(opening_actions), + # Keep the gate until the automatic first reply is queued. + release_input_on_success=False, release_input_on_failure=False, ) if not message_result.succeeded: @@ -191,7 +197,13 @@ class PromptBrain(BaseBrain): if opening_actions: result = await self._action_stages.run( self._opening_actions_stage_spec(), - set_input_enabled=runtime.set_input_enabled, + # The Prompt opening lifecycle owns the input gate when a + # confirmation message will trigger an automatic reply. + set_input_enabled=( + None + if generate_after_confirmation + else runtime.set_input_enabled + ), input_already_blocked=self._opening_input_blocked, release_input_on_failure=False, on_outcome=self._publish_opening_outcome, @@ -204,6 +216,11 @@ class PromptBrain(BaseBrain): raise self._opening_finished = True self._opening_input_blocked = False + if generate_after_confirmation and not runtime.call_end.ending: + logger.debug("Prompt 开场确认完成,触发自动首句") + await runtime.queue_frame(LLMRunFrame()) + if runtime.set_input_enabled is not None: + runtime.set_input_enabled(True) async def _speak_opening(self, content: str) -> Awaitable[None] | None: if self._output is None: diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index 953d204..0f1d00b 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -353,7 +353,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): ["preflight_1", "preflight_2"], ) - async def test_opening_stage_starts_speech_and_releases_on_confirmation(self): + async def test_opening_stage_triggers_reply_after_confirmation(self): tool = RuntimeTool( id="opening_data", name="加载开场数据", @@ -454,6 +454,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): await opening_task self.assertEqual(input_states, [False, True]) self.assertEqual(called_tool_ids, ["opening_data"]) + self.assertEqual( + sum(isinstance(frame, LLMRunFrame) for frame in queued), + 1, + ) self.assertEqual( len( [ @@ -469,6 +473,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): # Replayed client-ready must not execute startup actions twice. await brain.on_client_ready() self.assertEqual(brain._actions.execute.await_count, 1) + self.assertEqual( + sum(isinstance(frame, LLMRunFrame) for frame in queued), + 1, + ) async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self): cfg = AssistantConfig(