#5823·pipecat

FunctionCallResultFrame arriving while the user is still speaking is silently dropped, the LLM is never re-invoked and nothing retries

Author: SwAt1563Created Sep 17, 2026Updated Sep 17, 2026

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:

python
# 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

  1. Create an LLMAssistantAggregator with an LLMContext.
  2. Send a UserStartedSpeakingFrame (no matching stop yet — the user is mid-utterance).
  3. Send a completed tool call: FunctionCallsStartedFrameFunctionCallInProgressFrameFunctionCallResultFrame with a non-empty result and no properties.
  4. Observe that no LLMContextFrame is pushed upstream, and that _maybe_push_context_after_function_result is never reached (no Pushing context frame! / deferring context frame push debug line is logged).
  5. Send UserStoppedSpeakingFrame afterwards and wait — the push is still never made. There is no recovery path.
  6. Repeat steps 1–4 without the UserStartedSpeakingFrame to see the control case push exactly one LLMContextFrame.

Minimal reproducible example

python
"""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

bash
**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.