FunctionCallResultFrame arriving while the user is still speaking is silently dropped, the LLM is never re-invoked and nothing retries
pipecat version
1.9.0
Python version
3.13
Operating System
Pipecat Cloud
Issue description
In LLMAssistantAggregator._handle_function_call_result, the context push that
re-invokes the LLM after a tool call is guarded by _user_speaking:
# pipecat/processors/aggregators/llm_response_universal.py:1946
if run_llm and not self._user_speaking:
await self._maybe_push_context_after_function_result()If a FunctionCallResultFrame lands while _user_speaking is True, the push
is skipped — and unlike the sibling _bot_speaking branch, there is no retry
flag. _maybe_push_context_after_function_result sets
_push_context_on_bot_stopped_speaking (line 1985) which is consumed on
BotStoppedSpeakingFrame (line 1717), but there is no
_push_context_on_user_stopped_speaking equivalent. The tool result sits in the
context and inference is never run for it. The bot goes silent until something
else triggers a new turn.
This is a race, not a deterministic failure. _user_speaking is driven by raw
UserStartedSpeakingFrame / UserStoppedSpeakingFrame, which is a different
signal from the turn-stop strategy. When the LLM emits a tool call while the user
is still trailing off, the two cross and the turn is lost.
We hit this on a live call. The LLM spent its whole turn on a tool call
(completion tokens: 17, no text), the result came back in ~5 ms, and the bot
then said nothing for 73 seconds until the user asked "Did you hear me?".
Seven tool calls in that session; six logged Pushing context frame!
immediately after the result — the seventh, the silent one, did not. The only
difference between them is whether the user-turn-stop landed before or after
FunctionCallsStartedFrame, about 1 ms apart.
Setup: Daily transport, Soniox STT with use_external_vad=True,
ExternalUserTurnStopStrategy, Google Gemini LLM, Cartesia TTS. We believe
external-VAD setups are more exposed, since turn boundaries and speaking frames
come from two independent sources.
Reproduction steps
- Create an
LLMAssistantAggregatorwith anLLMContext. - Send a
UserStartedSpeakingFrame(no matching stop yet — the user is mid-utterance). - Send a completed tool call:
FunctionCallsStartedFrame→FunctionCallInProgressFrame→FunctionCallResultFramewith a non-emptyresultand noproperties. - Observe that no
LLMContextFrameis pushed upstream, and that_maybe_push_context_after_function_resultis never reached (noPushing context frame!/deferring context frame pushdebug line is logged). - Send
UserStoppedSpeakingFrameafterwards and wait — the push is still never made. There is no recovery path. - Repeat steps 1–4 without the
UserStartedSpeakingFrameto see the control case push exactly oneLLMContextFrame.
Minimal reproducible example
"""A FunctionCallResultFrame that arrives while the user is still speaking is
dropped: the LLM is never re-invoked, and nothing ever retries it.
pip install "pipecat-ai==1.9.0"
python repro_silence.py
Output on 1.9.0:
user silent when result lands -> context pushes: 1 (expected 1)
user speaking when result lands -> context pushes: 0 (expected 1)
...and the user then stops speaking -> context pushes: 0 (expected 1)
"""
import asyncio
from pipecat.frames.frames import (
FunctionCallFromLLM,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
LLMContextFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import LLMAssistantAggregator
from pipecat.tests.utils import SleepFrame, run_test
NAME, CALL_ID, ARGS = "get_gate_notions", "call_1", {"level": 7}
def function_call_frames():
"""The three frames a completed tool call emits, in order."""
return [
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name=NAME, tool_call_id=CALL_ID, arguments=ARGS, context=None
)
]
),
FunctionCallInProgressFrame(function_name=NAME, tool_call_id=CALL_ID, arguments=ARGS),
FunctionCallResultFrame(
function_name=NAME,
tool_call_id=CALL_ID,
arguments=ARGS,
result={"level": 7, "notions": ["..."]},
),
]
async def context_pushes(frames) -> int:
"""How many LLMContextFrames the aggregator pushes upstream (= inference runs)."""
aggregator = LLMAssistantAggregator(context=LLMContext())
_down, up = await run_test(aggregator, frames_to_send=frames, start_timeout=10.0)
return len([f for f in up if isinstance(f, LLMContextFrame)])
async def main():
n = await context_pushes(function_call_frames())
print(f"user silent when result lands -> context pushes: {n} (expected 1)")
n = await context_pushes([UserStartedSpeakingFrame(), *function_call_frames()])
print(f"user speaking when result lands -> context pushes: {n} (expected 1)")
n = await context_pushes(
[
UserStartedSpeakingFrame(),
*function_call_frames(),
SleepFrame(sleep=0.3),
UserStoppedSpeakingFrame(),
SleepFrame(sleep=0.3),
]
)
print(f"...and the user then stops speaking -> context pushes: {n} (expected 1)")
asyncio.run(main())Eval scenario or recordings (optional)
No response
Expected behavior
A completed function call re-invokes the LLM so the bot can speak, regardless of whether the user happens to be speaking at the moment the result arrives.
If the push must be deferred while the user speaks, it should be retried when the
user stops — mirroring the existing _bot_speaking handling, which sets
_push_context_on_bot_stopped_speaking and fires it on BotStoppedSpeakingFrame.
A _push_context_on_user_stopped_speaking flag consumed by
UserStoppedSpeakingFrame would close the gap.
Actual behavior
The context push is skipped and never retried. The tool result is written into
the context but no inference runs, so the bot produces no speech at all for that
turn. The session recovers only when the user speaks again and triggers a fresh
inference — 73 seconds later in our case, and the assistant turn is then reported
as empty: Assistant turn stopped: '' (interrupted=True).
_handle_function_call_result logs its FunctionCallResultFrame line, neither of
its two early-return warnings fires, and run_llm is True (non-empty result,
no properties, no sibling calls in the group) — so _user_speaking is the only
thing that can suppress the push, and nothing brings it back.
bot-logs-2026-09-17-17-30-58.txt
Logs
**The failing call.** Note the `FunctionCallResultFrame` with no `Pushing context frame!` after it:
14:05:11.442 | DEBUG | pipecat.services.google.llm:_process_context:721 | Function call: get_gate_notions:call_715757
14:05:11.490 | DEBUG | ...frame_processor_metrics:start_llm_usage_metrics:337 | prompt tokens: 25395, completion tokens: 17
14:05:11.492 | DEBUG | pipecat.services.llm_service:_run_function_call:1616 | GoogleLLMService#0 Calling function [get_gate_notions:call_715757] with arguments {'level': 7}
14:05:11.494 | DEBUG | ..._handle_function_calls_started:1842 | LLMAssistantAggregator#0 FunctionCallsStartedFrame: ['get_gate_notions:call_715757']
14:05:11.495 | DEBUG | ..._on_user_turn_stopped:1407 | LLMUserAggregator#0: User stopped speaking (strategy: LLMTurnCompletionUserTurnStopStrategy#0)
14:05:11.497 | DEBUG | ..._handle_function_call_in_progress:1847 | LLMAssistantAggregator#0 FunctionCallInProgressFrame: [get_gate_notions:call_715757]
14:05:11.497 | DEBUG | ..._handle_function_call_result:1883 | LLMAssistantAggregator#0 FunctionCallResultFrame: [get_gate_notions:call_715757]
| | <<< nothing — no context push, no LLM, no TTS >>>
14:05:42.430 | WARN | pipecat.services.websocket_service:_maybe_try_reconnect:325 | ElevenLabsTTSService#0 connection closed by server (TTS idle timeout — symptom)
14:06:24.452 | DEBUG | ..._on_user_turn_started:1318 | LLMUserAggregator#0: User started speaking (user asks "Did you hear me?")
14:06:24.455 | INFO | <our session code> | Assistant turn stopped: '' (interrupted=True)
**A working call from the same session, 9 minutes earlier.** Identical frames; the
only difference is that the user-turn-stop lands *before* `FunctionCallsStartedFrame`:
13:56:40.018 | DEBUG | pipecat.services.llm_service:_run_function_call:1616 | GoogleLLMService#0 Calling function [get_gate_notions:call_865925] with arguments {'level': 6}
13:56:40.019 | DEBUG | ..._on_user_turn_stopped:1407 | LLMUserAggregator#0: User stopped speaking
13:56:40.020 | DEBUG | ..._handle_function_calls_started:1842 | LLMAssistantAggregator#0 FunctionCallsStartedFrame: ['get_gate_notions:call_865925']
13:56:40.022 | DEBUG | ..._handle_function_call_in_progress:1847 | LLMAssistantAggregator#0 FunctionCallInProgressFrame: [get_gate_notions:call_865925]
13:56:40.022 | DEBUG | ..._handle_function_call_result:1883 | LLMAssistantAggregator#0 FunctionCallResultFrame: [get_gate_notions:call_865925]
13:56:40.022 | DEBUG | ..._maybe_push_context_after_function_result:1987 | LLMAssistantAggregator#0: Pushing context frame!
Seven `get_gate_notions` calls in this session; six logged `Pushing context frame!`
within ~5 ms of the result. Only the 14:05:11 one did not.Before you submit
- I have personally reproduced this issue on the latest Pipecat release (or
main). - I have searched existing issues for this bug.
- If AI tools helped write this report, I have verified the behavior myself and will personally answer follow-up questions.
Source: pipecat-ai/pipecat