Skip to content

fix: allow agent handoff after ctx.update() in AsyncToolset (fixes #5936)#6254

Open
C1-BA-B1-F3 wants to merge 2 commits into
livekit:mainfrom
C1-BA-B1-F3:fix/async-toolset-agent-handoff-after-update
Open

fix: allow agent handoff after ctx.update() in AsyncToolset (fixes #5936)#6254
C1-BA-B1-F3 wants to merge 2 commits into
livekit:mainfrom
C1-BA-B1-F3:fix/async-toolset-agent-handoff-after-update

Conversation

@C1-BA-B1-F3

Copy link
Copy Markdown

What does this PR do?

Fixes #5936 β€” allows agent handoff to work after calling ctx.update() in an AsyncToolset tool.

Problem

When a tool in an AsyncToolset calls ctx.update() and then returns an Agent for handoff, the Agent was being silently dropped with an error log:

tool `get_weather` returned an Agent after ctx.update(); agent handoff after a progress update is not supported

This happened because ctx.update() resolves first_update_fut early, causing execute() to return before the tool completes. The Agent return value had no surface to carry it back to the caller.

Solution

  1. Store the Agent for deferred handoff: When _execute_tool() detects that the tool returned an Agent after ctx.update() has already been called, it stores the Agent in run_ctx._deferred_agent_handoff instead of logging an error.

  2. Trigger handoff on task completion: In the _on_done callback (which fires when the tool task completes), check if a deferred Agent exists and call session.update_agent() to perform the handoff.

Changes

  • livekit-agents/livekit/agents/voice/events.py: Add _deferred_agent_handoff field to RunContext
  • livekit-agents/livekit/agents/voice/tool_executor.py: Store Agent and trigger handoff
  • tests/test_tools.py: Add tests for deferred agent handoff

Testing

Added TestDeferredAgentHandoff class with two tests:

  1. test_agent_return_after_update_stored_for_handoff β€” verifies Agent is stored when returned after ctx.update()
  2. test_agent_return_without_update_works_normally β€” verifies existing behavior (Agent returned directly) still works

All 87 tests in test_tools.py pass.

Example

This now works:

class ToolsetExample(AsyncToolset):
    @function_tool
    async def get_weather(self, ctx: AsyncRunContext):
        await ctx.update("Please hold on a minute")
        await long_running_task()
        return NewAgent()  # handoff works!

C1-BA-B1-F3 and others added 2 commits June 26, 2026 22:31
Nova Sonic 2 API rejects sessionStart when endpointingSensitivity is
placed at the top level. It must be nested under an 'endpointing' key.

Fixes livekit#6200

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vekit#5936)

When a tool in an AsyncToolset calls ctx.update() and then returns an Agent
for handoff, the Agent was being silently dropped with an error log. This
happened because ctx.update() resolves first_update_fut early, causing
execute() to return before the tool completes, and the Agent return had no
surface to carry it back.

Fix:
- Store the Agent in run_ctx._deferred_agent_handoff when returned after ctx.update()
- In the _on_done callback, trigger handoff via session.update_agent() if a deferred Agent exists

This allows tools to send progress updates via ctx.update() and still perform
agent handoff when they complete.
@C1-BA-B1-F3 C1-BA-B1-F3 requested a review from a team as a code owner June 26, 2026 18:39

@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

Comment on lines +362 to +363
if run_ctx._deferred_agent_handoff is not None:
session.update_agent(run_ctx._deferred_agent_handoff)

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.

🚩 Deferred handoff bypasses FunctionToolsExecutedEvent emission

The normal agent handoff path (in livekit-agents/livekit/agents/voice/agent_activity.py:3109-3116) emits a FunctionToolsExecutedEvent with _handoff_required=True and also creates an AgentHandoff history item (via _update_activity_task at agent_session.py:1477). The new deferred path calls session.update_agent() directly from the _on_done callback, which still creates the AgentHandoff item (inside _update_activity_task), but no FunctionToolsExecutedEvent is emitted for the handoff itself. If any listener relies on seeing _handoff_required=True on that event to know a handoff occurred from this specific tool execution, it won't be notified. This may be intentional since the tool's initial output already triggered a function_tools_executed event (with the progress message), but downstream consumers may miss that a handoff was ultimately triggered by this tool.

Open in Devin Review

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

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.

Can't make agent handoff on AsyncToolset after calling ctx.update

1 participant