Skip to content

fix(core): move callbacks to the caller - #5039

Merged
chenghao-mou merged 2 commits into
mainfrom
chenghao/fix/missed-first-frame-fut
Mar 9, 2026
Merged

fix(core): move callbacks to the caller#5039
chenghao-mou merged 2 commits into
mainfrom
chenghao/fix/missed-first-frame-fut

Conversation

@chenghao-mou

Copy link
Copy Markdown
Member

Previously first_frame_fut is resolved by callbacks, but the callback might be unregistered when forwarding finishes before the playout starts.

This makes sure the callback stays alive until the speech is finished or interrupted and is self-cleaned with a done_callback. This closes #5038

@chenghao-mou
chenghao-mou requested a review from a team March 8, 2026 13:03

@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 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

for tool in tools:
info: RawFunctionToolInfo | FunctionToolInfo | None = None
if isinstance(tool, (llm.RawFunctionTool, llm.FunctionTool)):
if isinstance(tool, llm.RawFunctionTool | llm.FunctionTool):

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.

🟡 isinstance(x, A | B) union syntax breaks Python 3.9 compatibility

AGENTS.md states "Python 3.9+ compatibility required" under Code Style. The change from isinstance(tool, (llm.RawFunctionTool, llm.FunctionTool)) to isinstance(tool, llm.RawFunctionTool | llm.FunctionTool) uses the PEP 604 union type syntax in a runtime expression, which requires Python 3.10+. On Python 3.9, type.__or__ is not defined, so llm.RawFunctionTool | llm.FunctionTool raises TypeError: unsupported operand type(s) for |: 'type' and 'type'. The old code using a tuple worked on all Python versions. Note: this applies only if the project still genuinely targets 3.9; if pyproject.toml has since raised the minimum, this is moot.

Suggested change
if isinstance(tool, llm.RawFunctionTool | llm.FunctionTool):
if isinstance(tool, (llm.RawFunctionTool, llm.FunctionTool)):
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@@ -506,7 +509,7 @@ def _tool_completed(out: ToolExecutionOutput) -> None:
)
continue

@devin-ai-integration devin-ai-integration Bot Mar 8, 2026

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.

🟡 isinstance(x, A | B) union syntax breaks Python 3.9 compatibility (second instance)

Same issue as BUG-0001 but in generation.py. isinstance(function_tool, llm.FunctionTool | llm.RawFunctionTool) uses Python 3.10+ union syntax in a runtime isinstance call, violating the "Python 3.9+ compatibility required" rule in AGENTS.md. The old code used isinstance(function_tool, (llm.FunctionTool, llm.RawFunctionTool)).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@longcw longcw 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.

the fix looks good to me, just not sure about the resolve and cancel, maybe we can call the audio_out.first_frame_fut.cancel() directly, and resolve is also not needed to be exposed?

@chenghao-mou
chenghao-mou merged commit e2f0f3e into main Mar 9, 2026
17 of 18 checks passed
@chenghao-mou
chenghao-mou deleted the chenghao/fix/missed-first-frame-fut branch March 9, 2026 09:30

@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 new potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines 366 to +371
out = _AudioOutput(audio=[], first_frame_fut=asyncio.Future())
# out.first_frame_fut should be cancelled in the caller after the playout is finished or interrupted
audio_output.on("playback_started", out._resolve_first_frame_fut)
out.first_frame_fut.add_done_callback(
lambda _: audio_output.off("playback_started", out._resolve_first_frame_fut)
)

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.

🟡 Event listener cleanup on audio_output is no longer guaranteed, causing potential stale listener leak and spurious state updates

Previously, the playback_started listener and first_frame_fut.cancel() were in _audio_forwarding_task's finally block (generation.py:408-411 in old code), guaranteeing cleanup regardless of how the task ended (including cancellation). The new code removes this finally cleanup and instead relies on callers to cancel first_frame_fut at the end of their methods (agent_activity.py:1877, agent_activity.py:2189, agent_activity.py:2680). However, none of these cancel calls are in try/finally blocks. If the caller's coroutine is cancelled (e.g. via speech interruption calling cancel_and_wait on the speech task) or an unexpected exception occurs after perform_audio_forwarding but before the cancel line, first_frame_fut stays pending and the playback_started listener remains registered on the shared audio_output object. When a subsequent speech starts playing and fires playback_started, the stale listener resolves the old future, triggering the _on_first_frame callback which calls self._session._update_agent_state("speaking", ...) with the stale speech handle's context.

Prompt for agents
The first_frame_fut.cancel() calls added to the three callers in agent_activity.py (_tts_task_impl at line 1877, _pipeline_reply_task_impl at line 2189, _realtime_reply_task_impl at line 2680) are not protected by try/finally blocks. If the caller task is cancelled or an exception occurs before reaching these lines, the playback_started listener registered at generation.py:368 will leak on the shared audio_output object.

To fix this, each caller should wrap the section between perform_audio_forwarding and the cancel call in a try/finally. For example, in _tts_task_impl (agent_activity.py), the code from the perform_audio_forwarding call (around line 1793) through to line 1878 should have the cancel in a finally block. Similarly for _pipeline_reply_task_impl (around lines 2081-2190) and _realtime_reply_task_impl (the section in _read_messages where perform_audio_forwarding is called at line 2517 through to line 2681).

Alternatively, you could keep the cleanup in _audio_forwarding_task's finally block as a safety net in addition to the caller-side cancel, ensuring both approaches co-exist for robustness.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

iancarrasco-b10 pushed a commit to iancarrasco-b10/agents that referenced this pull request Mar 17, 2026
enriqueespaillat-gyde added a commit to enriqueespaillat-gyde/agents-js that referenced this pull request Jun 29, 2026
…#1909)

Use the bare livekit#1909 short form referenced once at the core fix points,
matching existing comments (e.g. livekit#1662, livekit#1430, livekit#1124), instead of the
verbose cross-repo 'livekit#1909 (port of livekit/agents#5039)'
form and the per-call-site repetition. The port context lives in the
commit/PR/changeset.
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.

Interrupted speech dropped from conversation history when audio paused before first_frame_fut resolves

2 participants