Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions line/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ class AgentToolReturned(BaseModel):

class AgentEndCall(BaseModel):
type: Literal["end_call"] = "end_call"
after_speech: bool = False


class AgentTransferCall(BaseModel):
type: Literal["agent_transfer_call"] = "agent_transfer_call"
target_phone_number: str
after_speech: bool = False


class AgentSendDtmf(BaseModel):
Expand Down
4 changes: 2 additions & 2 deletions line/llm_agent/tools/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ async def _end_call_impl(
ctx: ToolEnv,
reason: Annotated[str, "The reason for ending the call"],
):
yield AgentEndCall()
yield AgentEndCall(after_speech=True)

return construct_function_tool(
_end_call_impl,
Expand Down Expand Up @@ -463,7 +463,7 @@ async def transfer_call(

if message is not None:
yield AgentSendText(text=message)
yield AgentTransferCall(target_phone_number=normalized_number)
yield AgentTransferCall(target_phone_number=normalized_number, after_speech=True)


def agent_as_handoff(
Expand Down
23 changes: 23 additions & 0 deletions line/voice_agent_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ def __init__(self, websocket: WebSocket, agent_spec: AgentSpec, env: AgentEnv):
self.env = env
# Lazy-init: asyncio.Event() requires a running event loop on Python 3.9.
self._shutdown_event: Optional[asyncio.Event] = None
self._speech_done: Optional[asyncio.Event] = None
self.history: List[InputEvent] = []
self.emitted_agent_text: List[Tuple[str, bool]] = [] # (content, interruptible)

Expand All @@ -302,6 +303,14 @@ def shutdown_event(self) -> asyncio.Event:
self._shutdown_event = asyncio.Event()
return self._shutdown_event

@property
def speech_done(self) -> asyncio.Event:
"""Event that is set when the agent is not speaking (TTS idle)."""
if self._speech_done is None:
self._speech_done = asyncio.Event()
self._speech_done.set() # Start in "done" state (no speech pending)
return self._speech_done

######### Initialization Methods #########

def _prepare_agent(
Expand Down Expand Up @@ -423,16 +432,28 @@ async def _start_agent_task(self, turn_env: TurnEnv, event: InputEvent) -> None:
await self._cancel_agent_task()

async def runner():
has_sent_text = False
try:
async for output in self.agent_callable(turn_env, event):
if isinstance(output, AgentSendText):
self.emitted_agent_text.append((output.text, output.interruptible))
has_sent_text = True
mapped = self._map_output_event(output)

if self.shutdown_event.is_set():
break
if mapped is None:
continue

# Wait for TTS to finish speaking before sending after_speech events
if getattr(output, "after_speech", False) and has_sent_text:
try:
await asyncio.wait_for(self.speech_done.wait(), timeout=30.0)
except asyncio.TimeoutError:
logger.warning(
f"Timed out waiting for speech to complete before {type(output).__name__}"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: speech_done wait returns immediately, defeating feature

High Severity

The speech_done event is initialized as SET and only cleared when AgentStateInput(SPEAKING) arrives from the remote client. But in the primary use case (LLM generates text + end_call/transfer in the same turn), the runner task sends text and then immediately checks speech_done.wait() — long before the client has received the text, started TTS, and sent back the SPEAKING signal over the network. Since speech_done is still SET, wait() returns instantly, and the transfer/end_call fires immediately, cutting off the agent mid-sentence. The speech_done event needs to be cleared locally when AgentSendText is sent (where has_sent_text is set), not when the remote SPEAKING acknowledgement arrives.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e5d41ca. Configure here.


await self.websocket.send_json(mapped.model_dump())
except asyncio.CancelledError:
pass
Expand Down Expand Up @@ -481,8 +502,10 @@ def _convert_input_message(self, message: InputMessage) -> Optional[InputEvent]:

elif isinstance(message, AgentStateInput):
if message.value == UserState.SPEAKING:
self.speech_done.clear()
return AgentTurnStarted()
elif message.value == UserState.IDLE:
self.speech_done.set()
content = self._turn_content(
self.history,
AgentTurnStarted,
Expand Down
Loading