Agent handoff fails when tool returning Agent runs...
# ask-ai
p
Agent handoff fails when tool returning Agent runs in parallel with reply-producing tool (pipeline path) When the LLM returns parallel tool calls in a single generation where: One tool returns an Agent (triggering a handoff), and Another tool returns a string result (reply_required=True) I see they both return responses correctly but the handoff never completes. The old agent stays active, on_enter() is never called on the new agent, and the conversation gets stuck in a loop. https://github.com/livekit/agents/issues/5150
t
Thanks for reaching out, Onur. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I see you're reporting that agent handoff fails when a single LLM generation returns parallel tool results where one tool returns an Agent (to trigger a handoff) and another returns a reply string (reply_required=True). You described the symptom clearly: both tools return values but the new agent never enters (on_enter() is not called) and the conversation gets stuck in a loop. I can help troubleshoot this. A few quick facts up front: • Returning an
Agent
from a tool should add an AgentHandoff to the chat context and trigger the new agent’s
on_enter()
after the handoff. • The system supports
parallel_tool_calls
so an LLM can invoke multiple tools in one generation. • There is no documented behavior for the mixed case where one parallel tool triggers a handoff while another produces a reply in the same generation. To narrow this down and give an actionable suggestion, I need a little more info. Could you share the following? 1. The minimal steps to reproduce (a short sequence you can run locally or a short transcript). 2. The agent and tool definitions (the code for the two tools involved — especially the tool that returns the Agent and the one that returns the reply). Redact any secrets. 3. The LLM and LiveKit agents/framework versions you’re using. 4. The value of
parallel_tool_calls
and any related runtime configuration. 5. Any logs around the time of the event (tool return values, the created AgentHandoff entry if visible, and whether an error or exception appears in logs). While you gather that, here are two practical mitigation steps you can try immediately: • Disable parallel tool calls for the affected flow (set
parallel_tool_calls
to false) so tools run sequentially. This ensures the handoff tool runs alone and the handoff completes before any subsequent reply is handled. • Alternatively, ensure the tool that returns the Agent does not set
reply_required=True
or returns the Agent as the only output in that generation; then have the reply-producing tool run in a separate generation after the new agent is active. If you prefer, once you paste the minimal repro or the two tool snippets I can: • Review them for any issues that could prevent
on_enter()
from being invoked. • Suggest a small code change to sequence the handoff and reply cleanly (for example, explicitly pushing the AgentHandoff and deferring replies until after the handoff). Thanks — once you share the repro details I’ll walk through it with you and suggest the next fix. Sources: Agents and handoffs | LiveKit Documentation | Function tool definition | LiveKit Documentation | Anthropic Claude LLM plugin guide | LiveKit Documentation | xAI LLM plugin guide | LiveKit Documentation | DeepSeek LLM | LiveKit Documentation, with interactive elements
p
### Bug Description When the LLM returns parallel tool calls in a single generation where: 1. One tool returns an Agent (triggering a handoff), and 2. Another tool returns a string result (reply_required=True) I see they both return responses correctly but the handoff never completes. The old agent stays active,
on_enter()
is never called on the new agent, and the conversation gets stuck in a loop. This is the same class of bug as #4691, but on the pipeline path (STT → LLM → TTS) rather than the Gemini Realtime path. ### Observed Trace Data Cycle 1 — agent_turn (162024.163 → 162025.031) | Span | Time | Key attributes | |------|------|---------------| |
llm_node
| 24.164–24.827 | model=
gemini-2.5-flash
, output_tokens=33, response.text=
""
, response.function_calls=2 calls:
validate_and_save_field(isPlanTerminated=false)
+
switch_assistant(get-info-assistant-product-coverage)
| |
function_tool
| 24.820–25.031 | name=
validate_and_save_field
, output=
"Field isPlanTerminated saved successfully with value False"
, is_error=false | |
function_tool
| 24.822–24.824 | name=
switch_assistant
, output=
""
, is_error=false | |
tts_node
| 24.184–24.834 | input_text=
""
(nothing to speak) | Cycle 2 — follow-up agent_turn (162025.033 → 162025.470) | Span | Time | Key attributes | |------|------|---------------| |
llm_node
| 25.033–25.446 | model=
gemini-2.5-flash
, output_tokens=0, response.text=
""
, response.function_calls=
[]
| |
tts_node
| 25.055–25.470 | input_text=
""
(nothing to speak) | → Agent is silent.
on_enter()
never fires for the new agent. User speaks again → Cycle 1 repeats identically. Full trace spans available on request. Did a brief root cause analysis, it might be related to how parallel tool calls are handled in the
agent_activity
### Root Cause In
agent_activity.py
around line 2232-2269, after parallel tool execution: 1. The SDK detects
agent_task
from the handoff tool and calls
self._session.update_agent(new_agent_task)
(line 2238-2239) — this creates an async
_update_activity_task
2. But
fnc_executed_ev._reply_required
is
True
(set by the other tool's string return value), so a
_pipeline_reply_task
is also created (line 2243-2269) that sends tool results back to the LLM on the old agent 3. The follow-up LLM call completes before the handoff task can drain the old agent — the LLM returns an empty response (0 output tokens) since the old agent has nothing meaningful to say 4. The old agent goes back to "listening". When the user speaks again, the old agent starts a new turn, making the same tool calls (handoff + save), creating the same race — an infinite loop 5.
on_enter()
is never called on the new agent The key issue is at line 2243: when
_handoff_required
is
True
, the follow-up
_pipeline_reply_task
should not be created, since the old agent is about to be replaced.
Copy code
python
# agent_activity.py lines 2237-2269 (simplified)
draining = self.scheduling_paused
if fnc_executed_ev._handoff_required and new_agent_task and not ignore_task_switch:
    self._session.update_agent(new_agent_task)  # async task
    draining = True

tool_messages = new_calls + new_fnc_outputs
if fnc_executed_ev._reply_required:  # ← True from the non-handoff tool's string result
    # This creates a follow-up LLM call on the OLD agent,
    # which races with and prevents the handoff from completing
    chat_ctx.items.extend(tool_messages)
    tool_response_task = self._create_speech_task(
        self._pipeline_reply_task(
            speech_handle=speech_handle,
            chat_ctx=chat_ctx,
            tools=tools,
            model_settings=ModelSettings(
                tool_choice="none" if draining or ... else "auto",
            ),
            ...
        ),
        ...
    )
### Expected Behavior When a tool returns an Agent (handoff), the handoff should complete regardless of what other parallel tools returned. on_exit() should be called on the old agent and on_enter() should be called on the new agent. ### Reproduction Steps
Copy code
bash
The bug triggers when the LLM returns parallel tool calls where one returns an Agent and another returns a string. This happens naturally in production when models batch related operations (e.g., "save this field AND switch to the next assistant"). It can be hard to reproduce deterministically since it depends on LLM behavior.


from livekit.agents import Agent, RunContext, function_tool, AgentSession


class PrimaryAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions=(
                "You are a helpful assistant. "
                "When the user says 'switch', you MUST call BOTH save_data and "
                "switch_to_secondary at the same time in the same response."
            ),
        )

    async def on_enter(self):
        print("PrimaryAgent on_enter called")
        await self.session.generate_reply()

    @function_tool()
    async def save_data(self, context: RunContext, value: str) -> str:
        """Save a piece of data. Always call this before switching agents."""
        print(f"save_data called with: {value}")
        return f"Saved: {value}"  # <-- returns string, sets reply_required=True

    @function_tool()
    async def switch_to_secondary(self, context: RunContext) -> Agent:
        """Switch to the secondary agent to continue the conversation."""
        print("switch_to_secondary called")
        return SecondaryAgent()  # <-- returns Agent, triggers handoff


class SecondaryAgent(Agent):
    def __init__(self):
        super().__init__(instructions="You are a secondary assistant. Say hello.")

    async def on_enter(self):
        # THIS NEVER FIRES when save_data runs in the same generation
        print("SecondaryAgent on_enter called")
        await self.session.generate_reply()

To reproduce:

* Start the agent with uv run python main.py console
* Say "switch" — the LLM should call both save_data and switch_to_secondary in the same generation
* Observe that SecondaryAgent on_enter called never prints
* The agent goes silent. Subsequent messages keep triggering the same two tool calls in a loop

**Key condition:** The bug only triggers when the LLM batches the handoff tool with another tool that returns a non-None value in the **same generation**. If switch_to_secondary is the only tool called (or all other tools return None), the handoff works correctly.
### Operating System Linux ### Models Used Deepgram Nova-3, Gemini 2.5 Flash (via vertex), Elevenlabs TTS ### Package Versions
Copy code
bash
livekit-agent: 1.4.3
t
I don't have the answer you're looking for. You could also try asking your question: • in one of the other Slack channels or • to https://deepwiki.com/livekit/livekit_composite which is trained on all LiveKit source code If you find the answer, please post it here to help others!