Skip to content

feat: automatic machine detection#4906

Open
chenghao-mou wants to merge 15 commits intomainfrom
feat/amd
Open

feat: automatic machine detection#4906
chenghao-mou wants to merge 15 commits intomainfrom
feat/amd

Conversation

@chenghao-mou
Copy link
Member

@chenghao-mou chenghao-mou commented Feb 20, 2026

  • AMDResult with categories: human, machine-dtmf, machine-vm, machine-nvm, and uncertain (reserved for time out)
  • AgentSession.amd_result() API for agents to await detection results (it cancels preemptive generation if it is detected as machine)
  • Example in examples/dtmf/amd.py

Session Interface

    session = AgentSession(
        ....
        amd="openai/gpt-5-mini",
    )

Usage

    async def on_enter(self):
        result = await self.session.amd_result()
        if result.is_human:
            logger.info("human answered the call, proceeding with normal conversation")
            return

        # disable preemptive generation to avoid concurrent responses
        async with self.session.disable_preemptive_generation():
            match result.category:
                case "machine-dtmf":
                    logger.info("dtmf menu detected, starting IVR detection")
                    await self.session.start_ivr_detection(transcript=result.transcript)
                    return
                case "machine-vm":
                    logger.info("voicemail detected, leaving a message")
                    speech_handle = self.session.generate_reply(
                        instructions=(
                            "You've reached voicemail. Leave a brief message asking "
                            "the customer to call back."
                        ),
                    )
                    await speech_handle.wait_for_playout()
                case _:
                    logger.info("mailbox unavailable, ending call")
            # shutdown the session if we don't need to proceed with the conversation
            self.session.shutdown()

@chenghao-mou chenghao-mou marked this pull request as ready for review March 3, 2026 17:37
@chenghao-mou chenghao-mou requested a review from a team March 3, 2026 17:37
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@chenghao-mou chenghao-mou marked this pull request as draft March 9, 2026 22:18
chenghao-mou and others added 4 commits March 15, 2026 16:41
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@chenghao-mou chenghao-mou marked this pull request as ready for review March 15, 2026 20:02
@chenghao-mou chenghao-mou requested a review from a team March 15, 2026 20:02
Copy link
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 13 additional findings in Devin Review.

Open in Devin Review

Comment on lines +313 to +329
def __repr__(self) -> str:
verdict = self._verdict.result() if self._verdict.done() else None
return (
f"AMD(llm={self._llm.label}, model={self._llm.model}, "
f"started={self._started}, closed={self._closed}, "
f"speech_duration={self.speech_duration:.2f}, verdict={verdict!r})"
)

def __str__(self) -> str:
if self._verdict.done():
result = self._verdict.result()
return f"AMD({result.category}, reason={result.reason})"
if self._closed:
return "AMD(closed)"
if not self._started:
return "AMD(pending)"
return "AMD(listening)"
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 __repr__ and __str__ raise CancelledError after close() cancels the verdict future

AMD.close() at livekit-agents/livekit/agents/voice/amd/base.py:276-277 cancels the _verdict future when it's not done. After cancellation, future.done() returns True but future.result() raises CancelledError. Both __repr__ (line 314) and __str__ (line 323) check self._verdict.done() and then call self._verdict.result(), which will crash with CancelledError. In __str__, the if self._closed branch (line 325) that would return "AMD(closed)" is never reached because the _verdict.done() check comes first and raises before getting there.

Suggested change
def __repr__(self) -> str:
verdict = self._verdict.result() if self._verdict.done() else None
return (
f"AMD(llm={self._llm.label}, model={self._llm.model}, "
f"started={self._started}, closed={self._closed}, "
f"speech_duration={self.speech_duration:.2f}, verdict={verdict!r})"
)
def __str__(self) -> str:
if self._verdict.done():
result = self._verdict.result()
return f"AMD({result.category}, reason={result.reason})"
if self._closed:
return "AMD(closed)"
if not self._started:
return "AMD(pending)"
return "AMD(listening)"
def __repr__(self) -> str:
verdict = None
if self._verdict.done() and not self._verdict.cancelled():
verdict = self._verdict.result()
return (
f"AMD(llm={self._llm.label}, model={self._llm.model}, "
f"started={self._started}, closed={self._closed}, "
f"speech_duration={self.speech_duration:.2f}, verdict={verdict!r})"
)
def __str__(self) -> str:
if self._verdict.done() and not self._verdict.cancelled():
result = self._verdict.result()
return f"AMD({result.category}, reason={result.reason})"
if self._closed:
return "AMD(closed)"
if not self._started:
return "AMD(pending)"
return "AMD(listening)"
Open in Devin Review

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

Comment on lines +167 to +177
if speech_duration > self._human_speech_threshold:
if self._classify_task is None:
self._classify_task = asyncio.create_task(self._classify_user_speech())

if self._silence_timer is not None:
self._silence_timer.cancel()
self._silence_timer = None
self._silence_timer = asyncio.get_running_loop().call_later(
max(0, self._machine_silence_threshold - silence_duration),
partial(self._silence_timer_callback, speech_duration=speech_duration),
)
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 _silence_timer_callback for machine-silence case can leave AMD hanging without emitting a result if _input_ch has no text

When on_user_speech_ended fires with speech_duration > HUMAN_SPEECH_THRESHOLD at livekit-agents/livekit/agents/voice/amd/base.py:167-177, the _classify_task is created (which reads from _input_ch) and a silence timer is scheduled without category/reason. If no transcript text is ever pushed to _input_ch (e.g., STT is slow or unavailable), the _classify_task blocks forever on the empty channel, and the silence timer callback at line 199 sets _machine_silence_reached = True but cannot emit because _verdict is not done. The only resolution is the 20-second _amd_timeout_timer. During those 20 seconds, authorization is paused (_authorization_allowed is cleared), making the agent completely unresponsive — it cannot play any speech. Consider closing the _input_ch or setting a verdict with a fallback category (like "uncertain") in the machine-silence timer callback when the LLM hasn't classified yet, rather than relying solely on the 20s global timeout.

Prompt for agents
In livekit-agents/livekit/agents/voice/amd/base.py, the _silence_timer_callback at line 174-177 for the machine-silence case (long speech) schedules a callback with no category/reason. When the LLM classification hasn't completed by the time this timer fires, the AMD enters a limbo state: _machine_silence_reached is True, but no verdict is set and no event is emitted. The agent's authorization remains paused for up to 20s (AMD_TIMEOUT). Fix this by having the machine-silence timer callback set a fallback verdict (e.g., category='uncertain', reason='machine_silence_no_classification') when the LLM hasn't provided a result yet. This ensures the AMD always emits promptly when the silence threshold is reached, rather than blocking the agent for the full timeout.
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.

2 participants