fix(realtime): complete workflow message transitions
This commit is contained in:
@@ -909,7 +909,22 @@ async def run_realtime_pipeline(
|
|||||||
|
|
||||||
call_end = CallEndCoordinator(queue_call_end)
|
call_end = CallEndCoordinator(queue_call_end)
|
||||||
client_tools = ClientToolBroker()
|
client_tools = ClientToolBroker()
|
||||||
client_tools.set_interrupt_handler(realtime.interrupt)
|
|
||||||
|
async def interrupt_for_client_tool_result() -> None:
|
||||||
|
"""Release confirmations after the realtime response is interrupted.
|
||||||
|
|
||||||
|
Fixed Message speech intentionally suppresses the provider transcript,
|
||||||
|
so the text aggregator may have no assistant turn from which to emit an
|
||||||
|
``on_interruption_processed`` event. The realtime adapter owns and
|
||||||
|
clears that response state itself. Wait for the provider boundary as
|
||||||
|
well, matching Pipeline's rule that the next node cannot generate until
|
||||||
|
the interrupted response has fully finished.
|
||||||
|
"""
|
||||||
|
await realtime.interrupt()
|
||||||
|
await realtime.wait_for_response_boundary()
|
||||||
|
client_tools.on_interruption_processed()
|
||||||
|
|
||||||
|
client_tools.set_interrupt_handler(interrupt_for_client_tool_result)
|
||||||
user_input = RealtimeUserInputProcessor(
|
user_input = RealtimeUserInputProcessor(
|
||||||
should_ignore_input=lambda: (
|
should_ignore_input=lambda: (
|
||||||
call_end.ending or not input_state["enabled"]
|
call_end.ending or not input_state["enabled"]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pipecat.frames.frames import (
|
|||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.ai_service import AIService
|
from pipecat.services.ai_service import AIService
|
||||||
@@ -120,6 +121,7 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
self._assistant_text = ""
|
self._assistant_text = ""
|
||||||
self._assistant_timestamp = ""
|
self._assistant_timestamp = ""
|
||||||
self._greeting_request_item_id: str | None = None
|
self._greeting_request_item_id: str | None = None
|
||||||
|
self._entry_trigger_item_id: str | None = None
|
||||||
self._user_transcript_pending = False
|
self._user_transcript_pending = False
|
||||||
self._user_transcript_item_id = ""
|
self._user_transcript_item_id = ""
|
||||||
self._user_transcript_timestamp = ""
|
self._user_transcript_timestamp = ""
|
||||||
@@ -215,9 +217,27 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
await self._cancel_active_response()
|
await self._cancel_active_response()
|
||||||
await self.broadcast_interruption()
|
await self.broadcast_interruption()
|
||||||
|
|
||||||
async def request_response(self) -> None:
|
async def request_response(self, *, trigger_text: str | None = None) -> None:
|
||||||
|
if trigger_text:
|
||||||
|
item_id = f"item_{uuid4().hex}"
|
||||||
|
self._entry_trigger_item_id = item_id
|
||||||
|
await self._send_event(
|
||||||
|
{
|
||||||
|
"type": "conversation.item.create",
|
||||||
|
"item": {
|
||||||
|
"id": item_id,
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": trigger_text}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
await self._send_event({"type": "response.create"})
|
await self._send_event({"type": "response.create"})
|
||||||
|
|
||||||
|
async def wait_for_response_boundary(self) -> None:
|
||||||
|
"""Wait until Qwen has finished or cancelled the current response."""
|
||||||
|
await self._response_done.wait()
|
||||||
|
|
||||||
def set_speech_started_handler(
|
def set_speech_started_handler(
|
||||||
self,
|
self,
|
||||||
handler: SpeechStartedHandler | None,
|
handler: SpeechStartedHandler | None,
|
||||||
@@ -387,6 +407,7 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
self._deferred_assistant_messages.clear()
|
self._deferred_assistant_messages.clear()
|
||||||
self._tool_session.clear()
|
self._tool_session.clear()
|
||||||
self._function_names.clear()
|
self._function_names.clear()
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
if websocket and websocket.state is State.OPEN:
|
if websocket and websocket.state is State.OPEN:
|
||||||
try:
|
try:
|
||||||
@@ -412,6 +433,7 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
self._session_ready.clear()
|
self._session_ready.clear()
|
||||||
self._response_done.set()
|
self._response_done.set()
|
||||||
self._greeting_request_item_id = None
|
self._greeting_request_item_id = None
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
if self._receive_task is asyncio.current_task():
|
if self._receive_task is asyncio.current_task():
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
@@ -480,8 +502,10 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
status = response.get("status") if isinstance(response, dict) else None
|
status = response.get("status") if isinstance(response, dict) else None
|
||||||
interrupted = status in {"cancelled", "incomplete", "interrupted", "failed"}
|
interrupted = status in {"cancelled", "incomplete", "interrupted", "failed"}
|
||||||
self._response_active = False
|
self._response_active = False
|
||||||
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self._finish_assistant_text(interrupted=interrupted)
|
await self._finish_assistant_text(interrupted=interrupted)
|
||||||
await self._delete_greeting_request()
|
await self._delete_greeting_request()
|
||||||
|
await self._delete_entry_trigger()
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
self._response_done.set()
|
self._response_done.set()
|
||||||
elif event_type == "response.output_item.added":
|
elif event_type == "response.output_item.added":
|
||||||
@@ -579,6 +603,15 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
wait_until_ready=False,
|
wait_until_ready=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _delete_entry_trigger(self) -> None:
|
||||||
|
item_id = self._entry_trigger_item_id
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
|
if item_id:
|
||||||
|
await self._send_event(
|
||||||
|
{"type": "conversation.item.delete", "item_id": item_id},
|
||||||
|
wait_until_ready=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def _start_user_transcript_turn(
|
async def _start_user_transcript_turn(
|
||||||
self,
|
self,
|
||||||
event: dict[str, Any],
|
event: dict[str, Any],
|
||||||
@@ -643,6 +676,10 @@ class QwenAudioRealtimeService(AIService):
|
|||||||
async def _send_event(
|
async def _send_event(
|
||||||
self, payload: dict[str, Any], *, wait_until_ready: bool = True
|
self, payload: dict[str, Any], *, wait_until_ready: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if payload.get("type") == "response.create":
|
||||||
|
# Mark the response busy before the server's response.created
|
||||||
|
# arrives so an immediate confirmation cannot pass the boundary.
|
||||||
|
self._response_done.clear()
|
||||||
if wait_until_ready and not self._session_ready.is_set():
|
if wait_until_ready and not self._session_ready.is_set():
|
||||||
self._pending_events.append(payload)
|
self._pending_events.append(payload)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from pipecat.frames.frames import (
|
|||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.ai_service import AIService
|
from pipecat.services.ai_service import AIService
|
||||||
@@ -85,6 +86,7 @@ class StepFunRealtimeService(AIService):
|
|||||||
)
|
)
|
||||||
self._fixed_speech_completion: asyncio.Future[None] | None = None
|
self._fixed_speech_completion: asyncio.Future[None] | None = None
|
||||||
self._fixed_speech_request_item_id: str | None = None
|
self._fixed_speech_request_item_id: str | None = None
|
||||||
|
self._entry_trigger_item_id: str | None = None
|
||||||
self._suppress_response_transcript = False
|
self._suppress_response_transcript = False
|
||||||
self._speech_started_handler: SpeechStartedHandler | None = None
|
self._speech_started_handler: SpeechStartedHandler | None = None
|
||||||
self._function_names: dict[str, str] = {}
|
self._function_names: dict[str, str] = {}
|
||||||
@@ -164,9 +166,27 @@ class StepFunRealtimeService(AIService):
|
|||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
await self.broadcast_interruption()
|
await self.broadcast_interruption()
|
||||||
|
|
||||||
async def request_response(self) -> None:
|
async def request_response(self, *, trigger_text: str | None = None) -> None:
|
||||||
|
if trigger_text:
|
||||||
|
item_id = f"item_{uuid4().hex}"
|
||||||
|
self._entry_trigger_item_id = item_id
|
||||||
|
await self._send_event(
|
||||||
|
{
|
||||||
|
"type": "conversation.item.create",
|
||||||
|
"item": {
|
||||||
|
"id": item_id,
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": trigger_text}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
await self._send_event({"type": "response.create"})
|
await self._send_event({"type": "response.create"})
|
||||||
|
|
||||||
|
async def wait_for_response_boundary(self) -> None:
|
||||||
|
"""Wait until StepFun has finished or cancelled the current response."""
|
||||||
|
await self._response_done.wait()
|
||||||
|
|
||||||
def set_speech_started_handler(
|
def set_speech_started_handler(
|
||||||
self,
|
self,
|
||||||
handler: SpeechStartedHandler | None,
|
handler: SpeechStartedHandler | None,
|
||||||
@@ -246,6 +266,7 @@ class StepFunRealtimeService(AIService):
|
|||||||
self._tool_session.clear()
|
self._tool_session.clear()
|
||||||
self._function_names.clear()
|
self._function_names.clear()
|
||||||
self._fixed_speech_request_item_id = None
|
self._fixed_speech_request_item_id = None
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
if websocket and websocket.state is State.OPEN:
|
if websocket and websocket.state is State.OPEN:
|
||||||
try:
|
try:
|
||||||
@@ -280,6 +301,7 @@ class StepFunRealtimeService(AIService):
|
|||||||
self._session_ready.clear()
|
self._session_ready.clear()
|
||||||
self._response_done.set()
|
self._response_done.set()
|
||||||
self._fixed_speech_request_item_id = None
|
self._fixed_speech_request_item_id = None
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
if self._receive_task is asyncio.current_task():
|
if self._receive_task is asyncio.current_task():
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
@@ -331,8 +353,10 @@ class StepFunRealtimeService(AIService):
|
|||||||
"incomplete",
|
"incomplete",
|
||||||
"interrupted",
|
"interrupted",
|
||||||
}
|
}
|
||||||
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self._finish_assistant_text(interrupted=interrupted)
|
await self._finish_assistant_text(interrupted=interrupted)
|
||||||
await self._delete_fixed_speech_request()
|
await self._delete_fixed_speech_request()
|
||||||
|
await self._delete_entry_trigger()
|
||||||
self._resolve_fixed_speech()
|
self._resolve_fixed_speech()
|
||||||
self._response_done.set()
|
self._response_done.set()
|
||||||
elif event_type == "response.output_item.added":
|
elif event_type == "response.output_item.added":
|
||||||
@@ -474,9 +498,22 @@ class StepFunRealtimeService(AIService):
|
|||||||
wait_until_ready=False,
|
wait_until_ready=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _delete_entry_trigger(self) -> None:
|
||||||
|
item_id = self._entry_trigger_item_id
|
||||||
|
self._entry_trigger_item_id = None
|
||||||
|
if item_id:
|
||||||
|
await self._send_event(
|
||||||
|
{"type": "conversation.item.delete", "item_id": item_id},
|
||||||
|
wait_until_ready=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_event(
|
async def _send_event(
|
||||||
self, payload: dict[str, Any], *, wait_until_ready: bool = True
|
self, payload: dict[str, Any], *, wait_until_ready: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if payload.get("type") == "response.create":
|
||||||
|
# Mark the response busy before the server's response.created
|
||||||
|
# arrives so an immediate confirmation cannot pass the boundary.
|
||||||
|
self._response_done.clear()
|
||||||
if wait_until_ready and not self._session_ready.is_set():
|
if wait_until_ready and not self._session_ready.is_set():
|
||||||
self._pending_events.append(payload)
|
self._pending_events.append(payload)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ from services.workflow_engine import WorkflowEngine
|
|||||||
|
|
||||||
|
|
||||||
MAX_AUTOMATIC_HOPS = 50
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
|
AGENT_ENTRY_TRIGGER = (
|
||||||
|
"[工作流内部事件,不是用户消息] 当前 Agent 节点已激活。"
|
||||||
|
"请严格依据当前 instructions 立即生成进入该节点时的回复,"
|
||||||
|
"不要提及或复述本事件。"
|
||||||
|
)
|
||||||
ToolHandler = Callable[[dict[str, Any]], Awaitable[RealtimeToolResult]]
|
ToolHandler = Callable[[dict[str, Any]], Awaitable[RealtimeToolResult]]
|
||||||
|
|
||||||
|
|
||||||
@@ -80,10 +85,18 @@ class RealtimeWorkflowOutput(WorkflowOutput):
|
|||||||
**({"nodeId": node_id} if node_id else {}),
|
**({"nodeId": node_id} if node_id else {}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return await self._realtime.speak_fixed(
|
track_speech = getattr(self._runtime.call_end, "track_speech", None)
|
||||||
|
playback_completion: Awaitable[None] | None = None
|
||||||
|
if callable(track_speech):
|
||||||
|
playback_completion = track_speech()
|
||||||
|
provider_completion = await self._realtime.speak_fixed(
|
||||||
content,
|
content,
|
||||||
suppress_transcript=True,
|
suppress_transcript=True,
|
||||||
)
|
)
|
||||||
|
# Message playback policy and deterministic continuation must use the
|
||||||
|
# transport boundary. Provider response.done only means generation
|
||||||
|
# has finished; audio may still be buffered at the output transport.
|
||||||
|
return playback_completion or provider_completion
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRealtimeController:
|
class WorkflowRealtimeController:
|
||||||
@@ -138,7 +151,7 @@ class WorkflowRealtimeController:
|
|||||||
allow_visible_actions=True,
|
allow_visible_actions=True,
|
||||||
)
|
)
|
||||||
if activation.continue_response:
|
if activation.continue_response:
|
||||||
await self._runtime.realtime.request_response()
|
await self._request_agent_entry_response()
|
||||||
|
|
||||||
async def on_client_ready(self) -> None:
|
async def on_client_ready(self) -> None:
|
||||||
await self._output.mark_client_ready()
|
await self._output.mark_client_ready()
|
||||||
@@ -160,7 +173,7 @@ class WorkflowRealtimeController:
|
|||||||
if activation and activation.after_output:
|
if activation and activation.after_output:
|
||||||
await activation.after_output()
|
await activation.after_output()
|
||||||
elif activation and activation.continue_response:
|
elif activation and activation.continue_response:
|
||||||
await self._runtime.realtime.request_response()
|
await self._request_agent_entry_response()
|
||||||
return SessionVariableUpdate(
|
return SessionVariableUpdate(
|
||||||
changed=changed,
|
changed=changed,
|
||||||
dynamic_variables=self._store.public_values(),
|
dynamic_variables=self._store.public_values(),
|
||||||
@@ -321,7 +334,13 @@ class WorkflowRealtimeController:
|
|||||||
if activation.after_output:
|
if activation.after_output:
|
||||||
await activation.after_output()
|
await activation.after_output()
|
||||||
elif activation.continue_response:
|
elif activation.continue_response:
|
||||||
await self._runtime.realtime.request_response()
|
await self._request_agent_entry_response()
|
||||||
|
|
||||||
|
async def _request_agent_entry_response(self) -> None:
|
||||||
|
"""Give provider inference a temporary turn for Agent entry speech."""
|
||||||
|
await self._runtime.realtime.request_response(
|
||||||
|
trigger_text=AGENT_ENTRY_TRIGGER,
|
||||||
|
)
|
||||||
|
|
||||||
async def _follow_edge(
|
async def _follow_edge(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in New Issue
Block a user