2026-04-25 — main.py event handlers: sync wrapper + asyncio.create_task (livekit-agents 1.5+)
Description
Three event-handler decorators in voice/agent/main.py registered async callbacks directly on session.on(...) / ctx.room.on(...). In livekit-agents 1.5+, .on() rejects async callbacks with ValueError: Cannot register an async callback with .on(). Use asyncio.create_task within your synchronous callback instead. Result: voice-agent crashed at job-entrypoint startup right after tts_init, before any user audio could be processed.
This was the bug PR-I (#692, FasterWhisperSTT abstract-method fix) unmasked — the STT crash happened earlier in the entrypoint, so this one was hidden. With STT now working, the next bug surfaced.
What changed
voice/agent/main.py:
- Added
import asyncio. - Three handlers refactored from
async defdecorated with@.on(...)to a paired sync-wrapper + async-helper:agent_speech_created(line 173 in main, error site): runs the output guardrail on LLM output before TTS.participant_connected(line 186 in main): broadcasts current phase state on (re)connect.disconnected(line 207 in main): logs session summary + closes guardrail.
Each handler is now:
async def _on_<name>_async(...):
"""The original async body."""
...
@session.on("...") / @ctx.room.on("...")
def on_<name>(...):
asyncio.create_task(_on_<name>_async(...))The fourth handler (user_input_transcribed, line 161) was already sync and untouched.
asyncio.create_task schedules on the currently-running loop — the same loop the agent entrypoint runs under, so the work executes correctly in the agent's async context.
Why now
Surfaced during R-10 #670 E2E re-probe immediately after PR-I (#692, FasterWhisperSTT delegate pattern) deployed. With STT instantiation fixed, the agent progressed through:
Loading faster-whisper model: /models/faster-whisper-large-v3 (device=cpu, compute_type=int8) ✓
pipeline stt_init status=ok latency_ms=26031.3 ✓
pipeline llm_init status=ok latency_ms=406.0 ✓
pipeline tts_init status=ok latency_ms=59.1 ✓
ValueError: Cannot register an async callback with .on() ← new failureSo the pipeline initializes correctly across all three model paths, and only the event-handler registration step blocks final readiness.
Cross-check (read-only)
Pattern verified against the framework's error message text — it explicitly recommends asyncio.create_task within your synchronous callback. This is exactly what we do.
The decorator usage is valid in 1.5+ (the event_emitter.on(name, callback) syntax is unchanged); only the callback's signature requirement changed (sync only). So decorating a sync function still works.
Test plan
- Image rebuild on EC2 succeeds (
docker compose build voice-agent). - After
--force-recreate voice-agent: re-run/tmp/probe2_e2e.py. NoValueErrorat line 173. Logs showKnotencheck agent ready — awaiting user interaction+session_startstage success. - Agent's TTS response is published back into the room.
AGENT_AUDIO_RECEIVEDline in probe output (replacingAGENT_AUDIO_NONE). - Output guardrail fires correctly on synthesized response (visible in pipeline log as
output_guardstage).
Rollout / reversibility
Reversible via revert. After merge, voice-agent image must rebuild on EC2 (docker compose build voice-agent && docker compose up -d --force-recreate voice-agent). ~10 s rebuild because the pip install + turn-detector layer is cached, only the COPY layer regenerates.
Out of scope
- The
turn_detection=parameter onAgentSession(...)(line 157) is deprecated per a separate warning in the same logs (turn_detection is deprecated and will be removed in v2.0. Use turn_handling=TurnHandlingOptions(...)). Not fatal — leave for a follow-up. The 1.5 code path still respectsturn_detection=for backwards compatibility. - Streaming STT, custom Julia voice, production wiring — all out of bring-up scope as documented previously.