From 8e76b8655592861a052fa384d677b0067c4c5dd3 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 11 Aug 2026 20:13:31 -0400 Subject: [PATCH 1/2] Let a started capture outlast the reply window it began in docs/BRIDGE_AI_HANDOFF.md records this as an open source-level blocker: the host's capture commitment is shorter than the firmware's endpoint ceiling and can reject a valid long utterance. The reply window bounds how long we wait for someone to *start* speaking. Once they have started, the device owns the ending: firmware's dedicated capture ceiling is 12 s, and the terminal plus the final PCM chunks still have to reach the host after that. The commitment that protects an in-progress capture was sized as `now + reply_window_ms`, so with the shipped 10 s window a capture that ran anywhere near the firmware ceiling could be closed out from under itself. The two numbers were never related; one just happened to be in the same units. The commitment is now its own value, defaulting to 13.5 s, validated to sit above the 12 s firmware ceiling and at or below the host's 14.5 s absolute capture lease. That keeps it long enough for the longest capture firmware can take while ensuring it can never become the control that keeps an abandoned capture alive -- the absolute lease still bounds that. Coverage: a capture running to the firmware ceiling now commits; a capture that never delivers still closes on timeout; the commitment no longer tracks a short reply window; and the bounds reject a value at or below the ceiling or above the lease. python -m unittest discover -s bridge -p "test_conversation_session.py": 19/19. Full bridge suite: 583 tests, with only the four pre-existing numpy/opencv environment gaps. Not qualified on hardware. This changes live conversation timing, and the 2026-08-11 physical evidence was collected under the previous value, so it needs a supervised run with a deliberately long utterance before promotion. Co-Authored-By: Claude Opus 5 --- bridge/conversation_session.py | 17 ++++++++++++- bridge/test_conversation_session.py | 38 ++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/bridge/conversation_session.py b/bridge/conversation_session.py index 0c4be3dc..fde2e141 100644 --- a/bridge/conversation_session.py +++ b/bridge/conversation_session.py @@ -22,6 +22,15 @@ class ConversationConfig: reply_window_ms: int = 10_000 reply_window_min_ms: int = 1_000 reply_window_step_ms: int = 0 + # How long a capture already in progress may outlive the reply window. + # + # The reply window bounds how long we wait for someone to *start* speaking. + # Once they have started, the device owns the ending: firmware's dedicated + # capture ceiling is 12 s and the terminal plus final chunks still have to + # reach us afterwards. Sizing this from reply_window_ms tied an unrelated + # number to that ceiling and left a long but perfectly valid utterance able + # to be closed out from under itself. + capture_commit_ms: int = 13_500 acoustic_tail_ms: int = 250 cooldown_ms: int = 300 max_turns: int = 24 @@ -46,6 +55,12 @@ def __post_init__(self) -> None: raise ValueError("reply_window_min_ms must be between 1000 and reply_window_ms") if self.reply_window_step_ms < 0: raise ValueError("reply_window_step_ms cannot be negative") + # Must outlast the firmware's 12 s dedicated capture ceiling so a capture + # that runs to that ceiling can still deliver its terminal, and must stay + # within the host's 14.5 s absolute capture lease so this can never be the + # control that keeps an abandoned capture alive. + if not 12_000 < self.capture_commit_ms <= 14_500: + raise ValueError("capture_commit_ms must be above 12000 and at most 14500") if not 0 <= self.acoustic_tail_ms <= 2_000: raise ValueError("acoustic_tail_ms must be between 0 and 2000") if self.cooldown_ms < 0: @@ -237,7 +252,7 @@ def utterance_started(self, now_ms: int) -> ConversationTransition: self.capture_in_progress = True # The device starts its bounded capture inside the reply window, but the # final audio chunks can arrive after that listening lease expires. - self.capture_commit_until_ms = now + self.config.reply_window_ms + self.capture_commit_until_ms = now + self.config.capture_commit_ms return self._transition("utterance_accepted", reason="listening") def utterance_committed(self, now_ms: int, text: str) -> ConversationTransition: diff --git a/bridge/test_conversation_session.py b/bridge/test_conversation_session.py index ea707dda..078d97c7 100644 --- a/bridge/test_conversation_session.py +++ b/bridge/test_conversation_session.py @@ -309,12 +309,48 @@ def test_started_capture_gets_bounded_time_to_finish_after_short_window(self) -> self.assertEqual("capture_in_progress", session.tick(1_060).reason) snapshot = session.snapshot(1_100) self.assertEqual(0, snapshot["conversation_reply_window_remaining_ms"]) - self.assertEqual(1_800, snapshot["conversation_capture_commit_remaining_ms"]) + # The commitment is sized from the firmware capture ceiling, not from the + # reply window, so a two-second window does not cap how long a capture + # that already started is allowed to finish. + self.assertEqual(13_300, snapshot["conversation_capture_commit_remaining_ms"]) committed = session.utterance_committed(2_000, "third") self.assertEqual(("close_capture", "begin_generation"), committed.actions) self.assertEqual(ConversationPhase.THINKING, session.phase) + def test_capture_running_to_the_firmware_ceiling_still_commits(self) -> None: + # Firmware's dedicated capture ceiling is 12 s and its terminal plus final + # chunks arrive after that. With the commitment sized from reply_window_ms + # the session closed the capture out from under a valid long utterance. + session = ConversationSession(ConversationConfig(acoustic_tail_ms=0)) + session.wake(0) + session.utterance_started(0) + + # Reply window is long gone; the capture is still running. + self.assertEqual("capture_in_progress", session.tick(10_500).reason) + self.assertEqual("capture_in_progress", session.tick(12_400).reason) + + committed = session.utterance_committed(12_600, "a genuinely long question") + self.assertEqual(("close_capture", "begin_generation"), committed.actions) + self.assertEqual(ConversationPhase.THINKING, session.phase) + + def test_capture_commitment_still_expires_so_it_cannot_hold_a_session_open(self) -> None: + session = ConversationSession(ConversationConfig(acoustic_tail_ms=0)) + session.wake(0) + session.utterance_started(0) + + # Past the commitment with nothing delivered, the session must close + # rather than wait on a capture that is never going to finish. + self.assertEqual("reply_timeout", session.tick(13_600).reason) + self.assertEqual(ConversationPhase.COOLDOWN, session.phase) + + def test_capture_commitment_must_outlast_the_firmware_capture_ceiling(self) -> None: + with self.assertRaises(ValueError): + ConversationConfig(capture_commit_ms=12_000) + with self.assertRaises(ValueError): + ConversationConfig(capture_commit_ms=14_501) + self.assertEqual(13_500, ConversationConfig().capture_commit_ms) + def test_invalid_config_is_rejected(self) -> None: with self.assertRaises(ValueError): ConversationConfig(reply_window_ms=0) From 7d5f0ad8a4bf1fdb9d18f9a63a2987061c65f868 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 11 Aug 2026 20:33:02 -0400 Subject: [PATCH 2/2] Update the work list for the capture commitment fix The handoff still described the shorter-than-ceiling capture commitment as an open source-level blocker. Record what changed, the reasoning that the reply window bounds waiting for speech to start while the device owns the ending, and that this alters live conversation timing and therefore still needs a supervised long-utterance run before promotion. Co-Authored-By: Claude Opus 5 --- docs/BRIDGE_AI_HANDOFF.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index 469c6ce2..7dca7482 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -63,8 +63,12 @@ when behaviour looks wrong. `CharacterMode` values are `0 Boot, 1 Idle, 2 Attend Completed turns no longer make the listener progressively less patient. The unchanged main firmware rejects out-of-range values rather than silently clamping them. The feature remains explicit and still needs exact-image hardware qualification before promotion. The host's - 10-second capture commitment is currently shorter than the firmware's 12-second endpoint ceiling - and can reject a valid long utterance; this is an open source-level blocker. + capture commitment is no longer derived from the reply window: it is its own `capture_commit_ms`, + defaulting to 13.5 s and validated to sit above the firmware's 12-second endpoint ceiling and at or + below the host's 14.5 s absolute capture lease. The reply window bounds how long to wait for + someone to start speaking; once they have started, the device owns the ending. This closes the + source-level blocker but changes live conversation timing, so it needs a supervised run with a + deliberately long utterance before promotion. - `bridge/initiative_policy.py` implements the ten-minute hard floor, intended fresh-person requirement, circadian suppression, busy/safety gates, curiosity decay, and two-ignored-opener backoff. Initiative generation uses the normal Character Lock and TTS path but never opens a microphone