fragJulia
Changelog

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 def decorated 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 failure

So 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. No ValueError at line 173. Logs show Knotencheck agent ready — awaiting user interaction + session_start stage success.
  • Agent's TTS response is published back into the room. AGENT_AUDIO_RECEIVED line in probe output (replacing AGENT_AUDIO_NONE).
  • Output guardrail fires correctly on synthesized response (visible in pipeline log as output_guard stage).

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 on AgentSession(...) (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 respects turn_detection= for backwards compatibility.
  • Streaming STT, custom Julia voice, production wiring — all out of bring-up scope as documented previously.

On this page