마지막 업데이트 2026-10-01
Half Cascade(HC) 모드에서 2.5초 안에 OpenAI provider final을 확보하지 못하면, vendor AgentActivity가 턴을 폐기하고 아무 응답 없이 끝났다(이현우 18회기 AI 무응답). 이 PR은 폐기 지점에서 새 세션 이벤트를 emit하고, agent.py가 이를 받아 VP 모드와 같은 "(판독 불가)" 기록 + LLM 되묻기로 이어지게 한다. 리뷰에서 가장 먼저 볼 지점은 두 가지다. ① vendor emit 게이트(agent_activity.py:3774) ② 로컬 commit 전에 도착한 실패를 보류했다가 commit 시 평가하는 경합 처리(agent.py:3499, :3619).
VP 모드(completion_contract="local_stt")에는 STT final이 오지 않을 때 쓰는 판독 불가 복구가 이미 있었다. HC(vendor_exact)에서는 다음 세 곳에서 막혀 복구가 동작하지 않았다.
_arm_stt_no_final_watchdog가 local_stt 조건에서 바로 return한다.user_transcription_timeout은 별도 STT 파이프라인이 있을 때만 발생하는데, HC에는 별도 STT가 없어 발생하지 않는다.이 PR은 ③에 신호를 추가하고, 복구 판정(_stt_no_final_turn_is_eligible)과 복구 실행(_deliver_unreadable_recovery)은 VP 것을 그대로 재사용한다.
_REALTIME_INPUT_TRANSCRIPTION_TIMEOUT)은 변경하지 않았다.pending_soniox_blank_turns)을 그대로 따랐다.vendor: 2.5초 안에 provider final 미확보 → 턴 폐기
vendor/…/voice/agent_activity.py:3771
입력: _EndOfTurnInfo(input_turn_id, skip_reply), commit 예약. 출력: 경고 로그 + 새 이벤트
vendor: user_input_transcription_failed emit
agent_session.py:2258 events.py:351
페이로드: {input_turn_id, reason="provider_final_not_admitted", created_at}
agent.py: vendor 토큰을 로컬 턴으로 매핑하고 보류
agent.py:4179 input_gate.py:155
입력: input_turn_id. 출력: 세션로그 user_stt_provider_final_failed(mapped/superseded), 보류 집합에 turn_id 추가
agent.py: 로컬 commit 확인 후 복구 대상인지 판정하고 선점
agent.py:3499 :3619 :3177 :3209
commit 전이면 대기. commit 시점에 다시 평가한다. 복구 대상이 아니면 보류를 폐기하고 끝낸다.
공용 복구: "(판독 불가)" 로그 + 되묻기 응답 생성
agent.py:3363 LiveKit data → 브라우저 SessionLog
출력: user_stt_provider_final_fallback, user_stt_segment "(판독 불가)", clear_user_turn → interrupt(force) → generate_reply(되묻기 지시문)
reservation.invalidated는 쓰지 않았다. 정상 실패·clear·종료가 모두 이 플래그를 True로 만들어 구분용으로 쓸 수 없기 때문이다. vendor에서는 세션 종료 flush·종료 중·세션 교체만 거르고, 수동 인터럽트처럼 여기서 구분할 수 없는 경우는 agent.py의 eligibility 검사가 막는다.
리뷰 1라운드 P1을 고친 부분이다. 아동이 말을 멈췄다가 바로 이어 말하면 같은 epoch에 input-1·input-2가 붙는다. 이때 input-1의 실패가 input-2의 정상 발화를 지울 수 있었다. 지금은 최신 토큰이 아니면 superseded로 기록만 한다. _resume_endpointing_turn(:3117)도 보류해 둔 실패를 버린다.
commit 실패나 빈 final은 대기 없이 즉시 실패하므로, 실패 이벤트가 agent.py의 endpointing commit보다 먼저 올 수 있다. 그래서 보류해 두었다가 commit 시점에 다시 평가한다.
VP no-final·Soniox blank 경로에 있던 같은 내용의 선점 블록 두 개를 헬퍼 하나로 묶었다. 사이에 await가 없어서 순서가 바뀌어도 결과는 같다. dict 이름 변경은 이벤트 계약 변경이 아니다. Soniox blank와 HC 복구 task를 같은 저장소에 넣어서, 중복 방지(:3450·:3516)와 새 발화·종료 시 일괄 취소(:3580)를 두 경로에 함께 적용하기 위한 것이다.
| 레이어 | 파일 (위치) | 핵심 변경 |
|---|---|---|
| vendor 계약 | vendor/…/voice/events.py:351 | UserInputTranscriptionFailedEvent 추가, EventTypes·AgentEvent 등록 |
| vendor 계약 | vendor/…/voice/__init__.py | 이벤트 export |
| vendor 세션 | vendor/…/voice/agent_session.py:2258 | emit 헬퍼 _user_input_transcription_failed |
| vendor 런타임 | vendor/…/voice/agent_activity.py:3774 | 폐기 지점 emit + 게이트 (+12) |
| 입력 게이트 | input_gate.py:155, agent.py:2132 | is_latest_vendor_turn + 래퍼 |
| agent 상태 | agent.py:2577, :2579 | unreadable_recovery_tasks(이름 변경), pending_provider_final_failure_turn_ids(신규) |
| agent 수신 | agent.py:4179 | 실패 이벤트 핸들러: 매핑·superseded 판정·보류 |
| agent 스케줄 | agent.py:3485, :3499, :3619 | HC 복구 스케줄러·실행 task, commit 시점 연결 |
| agent 공용 | agent.py:3209 | _claim_unreadable_recovery_turn 추출 (no-final·Soniox·HC 공유) |
| agent 정리 | agent.py:3071, :3117, :3573 | 턴 정리·resume·새 발화/종료 시 보류 실패 폐기 |
| 테스트 | tests/test_realtime_livekit_vad_identity.py | 폐기 시 이벤트 1회, 세션 교체 시 0회 (기존 2개 테스트 보강) |
| 테스트 | tests/test_turn_terminalization.py | HC 복구 1회(중복 포함), commit 전 도착, 억제 3종, resume 전·후 superseded (+155) |
빈 provider final도 즉시 되묻기: deadline 만료·빈 transcript·admission 실패가 모두 같은 provider_final_not_admitted로 나간다. 그래서 기침·잡음에도 AI가 다시 말해 달라고 할 수 있다. VP의 Soniox blank는 soniox_blank_policy로 켜고 끌 수 있다. 사유를 timeout/blank로 나누고 blank를 정책값으로 켜고 끌지 결정이 필요하다.
vendor 수정: LiveKit agents를 업그레이드할 때 이 패치를 다시 적용하고 검증해야 한다. 로컬 venv는 vendor를 non-editable로 설치하므로, vendor를 수정하면 uv sync --frozen --reinstall-package livekit-agents를 해야 테스트에 반영된다.
응답 지연: 되묻기 문장을 Realtime LLM이 만들므로, 폐기 시점(EOT + 2.5초) 뒤에 응답 한 번을 더 기다린다. 실기기에서 체감 지연과, 세션로그에 failed → fallback → "(판독 불가)" 순서가 남는지 확인이 필요하다.
다음 경우를 검증하는 테스트가 없다.
skip_reply/_closing 가드_closing은 close가 시작된 뒤에야 True가 된다. _is_closing()을 쓰면 더 일찍 막을 수 있다.테스트: 전체 491개가 통과했다. 새 테스트는 구현 전 또는 수정 전에 실패하는 것을 확인했다.
리뷰: guardian-gate 2라운드에서 P0는 없었고, P1 1건은 수정 후 해소됐다.
영향 없음: 정상 HC 턴, VP 경로, 웹 클라이언트. 웹은 세션로그에 이벤트를 표시만 한다.