Skip to content

fix(voice): collapse duplicate same-target handoff tool calls#6280

Open
georgeglarson wants to merge 1 commit into
livekit:mainfrom
georgeglarson:fix-duplicate-handoff-calls
Open

fix(voice): collapse duplicate same-target handoff tool calls#6280
georgeglarson wants to merge 1 commit into
livekit:mainfrom
georgeglarson:fix-duplicate-handoff-calls

Conversation

@georgeglarson

@georgeglarson georgeglarson commented Jul 1, 2026

Copy link
Copy Markdown

What

When a @function_tool returns an Agent (a handoff), AgentActivity treats a second AgentTask produced in the same turn as an ambiguous multi-handoff and aborts the switch:

if new_agent_task is not None and sanitized_out.agent_task is not None:
    logger.error("expected to receive only one AgentTask from the tool executions")
    ignore_task_switch = True

Real LLMs can emit the same handoff tool call more than once in a single turn (parallel or duplicate tool calls). Each duplicate returns the same target agent, so this guard fires, ignore_task_switch = True, and the handoff is discarded: update_agent never runs, the target agent never starts, and the follow-up tool turn is stranded.

Fix

Refuse the switch only when the duplicate AgentTask targets a different agent (genuinely ambiguous). Identical same-target calls collapse to one handoff. This guard exists in both the pipeline and realtime tool-output loops, so the same change is applied to both. Each duplicate tool call still gets its own function_call_output, so the one-output-per-tool-call requirement holds.

Test

tests/test_handoff_duplicate_transfer_5990.py is a hermetic, no-keys unit test: a scripted FakeLLM emits two identical transfer calls in one turn, and the test asserts the handoff completes and the specialist's follow-up tool runs. It fails on main and passes with this change. It covers the pipeline path; the realtime loop takes the identical change, and there is no hermetic realtime harness in the suite to unit-test it separately without a live model. make check is clean (ruff + mypy strict) and the full --unit suite is green.

Scope

This surfaced while investigating #5990. It is an independent correctness fix and does not by itself close that issue. Keeping it small and self-contained so it can be reviewed on its own.

@georgeglarson georgeglarson requested a review from a team as a code owner July 1, 2026 01:10
@CLAassistant

CLAassistant commented Jul 1, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

When a tool call returns an Agent, AgentActivity treats a second AgentTask in the same turn as an ambiguous multi-handoff and aborts the switch (ignore_task_switch=True). Real LLMs routinely emit the same handoff tool call more than once in a single turn (parallel / duplicate tool calls); each returns the same target agent, so the guard discarded the handoff entirely -- the target agent never started and the follow-up tool turn was stranded.

Only refuse the switch when the duplicate AgentTask targets a *different* agent (genuinely ambiguous). Identical same-target calls collapse to a single handoff. The same fix is applied to both the pipeline and realtime tool-output loops.

Surfaced while investigating livekit#5990; this is an independent correctness fix, not the primary cause of that issue.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

# turn (parallel/duplicate tool calls, common with real LLMs). Collapse to
# a single handoff instead of discarding it (livekit/agents#5990).

new_agent_task = sanitized_out.agent_task

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Unconditional new_agent_task reassignment can lose a handoff if a later tool returns None

At livekit-agents/livekit/agents/voice/agent_activity.py:3150, new_agent_task = sanitized_out.agent_task runs unconditionally β€” even when sanitized_out.agent_task is None. If a handoff tool and a regular (non-handoff) tool execute in the same turn, the final value of new_agent_task depends on iteration order: a non-handoff tool processed after the handoff tool silently overwrites it to None, dropping the handoff. This is a pre-existing issue (the line is unchanged by this PR) and would only matter when mixing handoff and non-handoff tools in one parallel turn, but it's a latent footgun.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, and agreed it's real: if a non-handoff tool is processed after the handoff tool in the same turn, the unconditional reassignment nulls out the captured new_agent_task and drops the handoff. As you noted, that line is pre-existing (unchanged by this PR), and it's a separate failure mode from the duplicate same-target case this change targets, so I've kept it out to leave this reviewable on its own. Can send a separate PR that guards the reassignment (if sanitized_out.agent_task is not None) if maintainers want the pre-existing issue fixed alongside.

@georgeglarson georgeglarson marked this pull request as draft July 1, 2026 16:04
@georgeglarson georgeglarson marked this pull request as ready for review July 1, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants