Skip to content

Orphan function call entries block flow transitions after user interruption #246

@OlliTapio

Description

@OlliTapio

Bug Description

When a user interrupts the bot after FunctionCallsStartedFrame is emitted but before FunctionCallInProgressFrame arrives, no FunctionCallCancelFrame is emitted by pipecat. This leaves an orphan entry in _function_calls_in_progress (with value None) that is never cleaned up.

This orphan blocks pipecat-flows transitions: _check_and_execute_transition checks has_function_calls_in_progress, which returns True because of the orphan entry, so the transition callback never fires and the flow gets permanently stuck.

Steps to Reproduce

  1. Set up a pipecat-flows application with an edge function that triggers a node transition
  2. Have the LLM call the function → FunctionCallsStartedFrame is emitted, entry added to _function_calls_in_progress with value None
  3. User interrupts (speaks over the bot) before FunctionCallInProgressFrame arrives
  4. No FunctionCallCancelFrame is emitted for the orphan
  5. LLM retries on the next turn and calls the same function again
  6. The retry completes successfully (FunctionCallsStartedFrameFunctionCallInProgressFrameFunctionCallResultFrame)
  7. _check_and_execute_transition runs but has_function_calls_in_progress is True because the orphan from step 2 is still there
  8. The node transition never fires — the flow is stuck

Minimal Reproduction

import asyncio
from pipecat.frames.frames import (
    FunctionCallFromLLM,
    FunctionCallsStartedFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import LLMAssistantAggregator

async def reproduce():
    context = LLMContext()
    aggregator = LLMAssistantAggregator(context)

    # Turn 1: FunctionCallsStartedFrame fires, then user interrupts
    call_1 = FunctionCallFromLLM(
        context=context,
        tool_call_id="call_1",
        function_name="save_notes",
        arguments={},
    )
    # Simulate: FunctionCallsStartedFrame adds orphan entry
    aggregator._function_calls_in_progress[call_1.tool_call_id] = None
    
    # Simulate: InterruptionFrame arrives — no FunctionCallCancelFrame emitted
    # aggregator._function_calls_in_progress still has {"call_1": None}
    
    # Turn 2: LLM retries with a new call ID
    call_2 = FunctionCallFromLLM(
        context=context,
        tool_call_id="call_2",
        function_name="save_notes",
        arguments={},
    )
    aggregator._function_calls_in_progress[call_2.tool_call_id] = None
    
    # call_2 completes normally
    del aggregator._function_calls_in_progress[call_2.tool_call_id]
    
    # BUG: call_1 orphan is still there
    print(f"has_function_calls_in_progress: {aggregator.has_function_calls_in_progress}")
    # Prints: True — pipecat-flows transition is blocked forever

asyncio.run(reproduce())

Expected Behavior

Either:

  1. pipecat should emit FunctionCallCancelFrame for function calls that have FunctionCallsStartedFrame but no FunctionCallInProgressFrame when an interruption occurs
  2. Or _handle_function_calls_started / _check_and_execute_transition should handle orphan entries (value=None) from previous turns

Actual Behavior

The orphan entry persists forever, causing has_function_calls_in_progress to return True and blocking all pipecat-flows transitions.

Impact

In production, this causes the bot to get stuck after completing a booking — the node transition to the confirmation/goodbye node never fires, so the end_call tool is never available. The bot keeps chatting with the user until the pipeline times out.

Related

Workaround

We work around this in our custom LLMAssistantAggregator subclass by clearing orphan entries (value=None) in _handle_function_calls_started before calling super():

async def _handle_function_calls_started(self, frame):
    new_ids = {fc.tool_call_id for fc in frame.function_calls}
    orphan_ids = [
        tid for tid, val in self._function_calls_in_progress.items()
        if val is None and tid not in new_ids
    ]
    for tid in orphan_ids:
        del self._function_calls_in_progress[tid]
    await super()._handle_function_calls_started(frame)

Environment

  • pipecat version: 0.0.107
  • pipecat-flows version: 0.0.23
  • LLM: OpenAI GPT
  • Transport: Twilio/WebSocket

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions