Make end call tool can be cancelled

This commit is contained in:
Xin Wang
2026-08-12 09:18:40 +08:00
parent fc026e765e
commit d0bb5b3868
15 changed files with 350 additions and 48 deletions
+39 -3
View File
@@ -64,11 +64,12 @@ def playback_marker_for(
class CallEndCoordinator:
"""End immediately or after the currently armed closing speech finishes."""
"""Coordinate committed and user-cancellable conversation endings."""
def __init__(self, queue_end: Callable[[str], Awaitable[None]]):
self._queue_end = queue_end
self._ending = False
self._interruptible = False
self._armed = False
self._speaking = False
self._speech_stopped = asyncio.Event()
@@ -83,6 +84,15 @@ class CallEndCoordinator:
def ending(self) -> bool:
return self._ending
@property
def accepts_input(self) -> bool:
"""Keep input open while an interruptible ending is still pending."""
return not self._finished and (not self._ending or self._interruptible)
@property
def interruptible(self) -> bool:
return self._ending and self._interruptible and not self._finished
@property
def speaking(self) -> bool:
"""Whether transport output is currently producing bot speech."""
@@ -92,16 +102,37 @@ class CallEndCoordinator:
"""Wait for the transport-owned bot speech boundary."""
await self._speech_stopped.wait()
def begin(self, reason: str) -> None:
def begin(self, reason: str, *, interruptible: bool = False) -> None:
if self._finished:
return
self._ending = True
self._interruptible = interruptible
self._armed = False
self._finish_after_tracked_speech = False
self._reason = reason or "completed"
def cancel_pending_end(self) -> bool:
"""Cancel one pending ending when its tool explicitly allows it."""
if not self.interruptible:
return False
self._ending = False
self._interruptible = False
self._armed = False
self._finish_after_tracked_speech = False
self._response_speech_started = False
# Transport speech state remains authoritative: if audio is currently
# playing, the interruption path will still emit BotStoppedSpeakingFrame.
logger.info("用户打断结束语,已撤销待挂断状态")
return True
def begin_response(self) -> None:
"""Start tracking speech produced by one LLM response."""
self._response_speech_started = False
def arm_after_speech(self) -> None:
"""Wait for the next observed bot speech to finish."""
if not self._ending:
return
self._armed = True
def track_speech(self) -> SpeechPlaybackCompletion:
@@ -128,12 +159,16 @@ class CallEndCoordinator:
async def arm_after_tracked_speech(self) -> None:
"""Finish after every already queued fixed utterance has played."""
if not self._ending:
return
self._finish_after_tracked_speech = True
if not self._tracked_speech_completions:
await self.finish()
async def finish_after_current_speech(self, *, has_text: bool) -> None:
"""Finish now if speech is absent/done, otherwise wait for its stop."""
if not self._ending:
return
if not has_text:
await self.finish()
return
@@ -143,9 +178,10 @@ class CallEndCoordinator:
self._armed = True
async def finish(self) -> None:
if self._finished:
if self._finished or not self._ending:
return
self._finished = True
self._interruptible = False
await self._queue_end(self._reason)
async def observe(self, frame) -> None: