diff --git a/broker/realtime_broker/agent.py b/broker/realtime_broker/agent.py index de6df34..bf66235 100644 --- a/broker/realtime_broker/agent.py +++ b/broker/realtime_broker/agent.py @@ -131,7 +131,11 @@ async def _handle_evt_error(self, evt) -> 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 04cd24a..9e25a78 100644 --- a/broker/realtime_broker/server.py +++ b/broker/realtime_broker/server.py @@ -368,6 +368,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 @@ -618,6 +628,17 @@ async def _wait_for_user(params): # 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 1fea79d..02930ba 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, }