From 54a3220538df29d0ead3ef84402c407f8a5ff91c Mon Sep 17 00:00:00 2001 From: Eugenio Zuccarelli <11176606+jayzuccarelli@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:15:40 +0000 Subject: [PATCH] broker: never suppress the first utterance after wake The model sometimes classified the user's first post-wake utterance as background and called wait_for_user, stranding the session silent until hygiene timeout (live incident 2026-07-27 01:22 UTC). The wake word is proof that first speech is addressed. - instructions: turn 1 is always addressed, never wait_for_user it - server: if wait_for_user still fires on the first committed turn, answer instead of suppressing (instruction alone fixed only 1/5) - harness: new first_turn_addressed scenario, turn-1 twin of tv_line_after_answer (same TV line: answer on turn 1, ignore on turn 2) Bench (5 reps, fresh sessions): first_turn_addressed 0/5 on main -> 5/5 with fix; background_rejection 5/5 both; tv_line_after_answer 4/5 both (pre-known flake, no override fired). make check GREEN 2/2. Co-Authored-By: Claude Fable 5 --- broker/realtime_broker/agent.py | 6 +++++- broker/realtime_broker/server.py | 21 +++++++++++++++++++++ broker/tools/harness.py | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/broker/realtime_broker/agent.py b/broker/realtime_broker/agent.py index 93babd7..32d2dd7 100644 --- a/broker/realtime_broker/agent.py +++ b/broker/realtime_broker/agent.py @@ -121,7 +121,11 @@ async def _handle_context(self, context: LLMContext) -> None: "right after you answer, the next utterance is usually the same user " "following up. A follow-up question, reaction, or challenge to what you just " "said ('are you sure?', 'okay, and...', 'what about tomorrow?') is addressed " - "to you even when it does not name you — answer it. When torn between " + "to you even when it does not name you — answer it. A second exception, " + "absolute: the FIRST speech you hear after a session starts is always " + "addressed to you, because the user just said the wake word to open the " + "session. Never call wait_for_user on that first utterance; answer it even " + "if it sounds like background or does not name you. When torn between " "answering a plausible follow-up and staying silent, answer: a wrongly " "ignored user must repeat themselves, which is worse than a wrongly " "answered TV line." diff --git a/broker/realtime_broker/server.py b/broker/realtime_broker/server.py index f22f9da..f8a0177 100644 --- a/broker/realtime_broker/server.py +++ b/broker/realtime_broker/server.py @@ -361,6 +361,16 @@ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: await self.cancel_task(task) await self.push_frame(frame, direction) + def is_first_turn(self) -> bool: + """True while this connection's FIRST committed user turn is in flight. + + A wake word opened the connection, so that first utterance is addressed + to us by construction — the wait_for_user handler uses this to refuse + suppressing it. Independent of the hygiene knobs: `_turns` is counted + (and reset per connect) even when both bounds are disabled. + """ + return self._turns <= 1 + def on_device_connect(self) -> None: # Fresh wake = fresh budget and window. The connect handler runs for # the NEW client before a kicked stale socket's disconnect fires (and @@ -610,6 +620,17 @@ async def _wait_for_user(params): # noqa: ANN001 # Non-addressed speech (TV, side conversation, background). Acknowledge # the call but suppress the follow-up response (run_llm=False) so the bot # stays silent and keeps listening instead of replying to the room. + if hygiene.is_first_turn(): + # Belt-and-suspenders for the instruction above: the wake word that + # opened this connection is proof the first utterance was meant for + # us, but the model still wait_for_user's it sometimes and strands + # the session silent. Let the response run so it answers. + logger.warning("wait_for_user on turn 1 overridden; answering instead") + await params.result_callback( + "That speech was addressed to you: the user just said the wake " + "word. Answer it now." + ) + return await params.result_callback( "", properties=FunctionCallResultProperties(run_llm=False) ) diff --git a/broker/tools/harness.py b/broker/tools/harness.py index 89093f5..2237842 100644 --- a/broker/tools/harness.py +++ b/broker/tools/harness.py @@ -273,6 +273,22 @@ async def s_challenge_follow_up(url): return r2.got_audio, f"challenge reply={t!r}", r2.first_audio_ms +async def s_first_turn_addressed(url): + # Turn-1 twin of tv_line_after_answer, deliberately the SAME line: a wake + # word opened this connection, so whatever follows it is addressed to us + # even when it sounds exactly like the room. The model used to + # wait_for_user the first utterance away and strand the session silent + # (live logs); this line reproduced that 0/3 before the turn-1 override. + # The pair encodes the tradeoff — same words, answer on turn 1, ignore on + # turn 2. + async with session(url) as c: + tv = ("Oh man, it's good, huh? I told you this show gets better. " + "Hang on, I'm gonna grab another drink before it starts.") + r = await c.ask(tv, voice="echo") + t = r.transcript().lower() if r.got_audio else "" + return r.got_audio, f"first-turn reply={t!r}", r.first_audio_ms + + async def s_tv_line_after_answer(url): # Counter-metric to the follow-up bias in BACKGROUND_GUIDANCE: a # conversational TV line in a different voice right after the bot @@ -323,6 +339,7 @@ async def s_mid_speech_disconnect(url): "reconnect": s_reconnect, "background_rejection": s_background_rejection, "challenge_follow_up": s_challenge_follow_up, + "first_turn_addressed": s_first_turn_addressed, "tv_line_after_answer": s_tv_line_after_answer, "mid_speech_disconnect": s_mid_speech_disconnect, }