-
Notifications
You must be signed in to change notification settings - Fork 363
fix(interruption): bind inference responses to the overlap they were requested for #2306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mastermanas805
wants to merge
2
commits into
livekit:main
Choose a base branch
from
mastermanas805:fix/interruption-overlap-binding
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+270
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents': patch | ||
| --- | ||
|
|
||
| Bind adaptive-interruption inference responses to the overlap they were requested for, so a late response can no longer be reported as an interruption of a later overlap. |
225 changes: 225 additions & 0 deletions
225
agents/src/inference/interruption/interruption_overlap_binding.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // Regression tests for https://github.com/livekit/agents-js/issues/2119. | ||
| // | ||
| // A `bargein_detected` / `inference_done` response is matched to whichever overlap happens to be | ||
| // open when it lands, not to the overlap its request was cut for. A response that arrives after | ||
| // its own overlap ended, while a *later* overlap is open, used to be accepted and attributed to | ||
| // that later overlap — emitting `isInterruption: true` for user audio the model never scored. | ||
| // | ||
| // Plain response latency is enough to hit this: the gap between two overlaps inside one agent turn | ||
| // is often only a few hundred ms. | ||
| import { AudioFrame } from '@livekit/rtc-node'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { initializeLogger } from '../../log.js'; | ||
| import { MockWebSocket } from './_mock_ws.js'; | ||
| import { AdaptiveInterruptionDetector } from './interruption_detector.js'; | ||
| import { InterruptionStreamBase, InterruptionStreamSentinel } from './interruption_stream.js'; | ||
| import type { OverlappingSpeechEvent } from './types.js'; | ||
|
|
||
| vi.mock('ws', async () => { | ||
| const { MockWebSocket } = await import('./_mock_ws.js'); | ||
| return { default: MockWebSocket, WebSocket: MockWebSocket }; | ||
| }); | ||
|
|
||
| initializeLogger({ pretty: false, level: 'silent' }); | ||
|
|
||
| const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); | ||
|
|
||
| async function waitForInstance(timeoutMs = 2000): Promise<MockWebSocket> { | ||
| const start = performance.now(); | ||
| while (MockWebSocket.instances.length === 0) { | ||
| if (performance.now() - start > timeoutMs) throw new Error('WebSocket was never constructed'); | ||
| await sleep(5); | ||
| } | ||
| return MockWebSocket.instances[MockWebSocket.instances.length - 1]!; | ||
| } | ||
|
|
||
| function makeAudioFrame(numSamples = 1600, sampleRate = 16000): AudioFrame { | ||
| return new AudioFrame(new Int16Array(numSamples), sampleRate, 1, numSamples); | ||
| } | ||
|
|
||
| function createDetector(): AdaptiveInterruptionDetector { | ||
| return new AdaptiveInterruptionDetector({ | ||
| baseUrl: 'http://localhost:9999', | ||
| apiKey: 'test-key', | ||
| apiSecret: 'test-secret', | ||
| }); | ||
| } | ||
|
|
||
| /** Drain the event side into an array without blocking the test. */ | ||
| function collectEvents(stream: InterruptionStreamBase): OverlappingSpeechEvent[] { | ||
| const events: OverlappingSpeechEvent[] = []; | ||
| void (async () => { | ||
| const reader = stream.stream().getReader(); | ||
| try { | ||
| for (;;) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) return; | ||
| if (value) events.push(value); | ||
| } | ||
| } catch { | ||
| // stream errored or was torn down; the test asserts on what arrived before that | ||
| } | ||
| })(); | ||
| return events; | ||
| } | ||
|
|
||
| /** The 8-byte little-endian `created_at` header the transport prefixes to each audio request. */ | ||
| function sentRequestIds(ws: MockWebSocket): number[] { | ||
| return ws.sent | ||
| .filter((f): f is Uint8Array => f instanceof Uint8Array) | ||
| .map((frame) => { | ||
| const view = new DataView(frame.buffer, frame.byteOffset, 8); | ||
| return view.getUint32(0, true) + view.getUint32(4, true) * 0x100000000; | ||
| }); | ||
| } | ||
|
|
||
| /** Bring a stream up to an open socket with a server-provided threshold. */ | ||
| async function openStream(): Promise<{ stream: InterruptionStreamBase; ws: MockWebSocket }> { | ||
| const stream = new InterruptionStreamBase(createDetector(), {}); | ||
| const ws = await waitForInstance(); | ||
| ws.simulateOpen(); | ||
| await sleep(5); | ||
| ws.simulateMessage({ type: 'session.created', default_threshold: 0.5 }); | ||
| await sleep(5); | ||
| return { stream, ws }; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| MockWebSocket.instances.length = 0; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('interruption overlap binding (#2119)', () => { | ||
| it('does not attribute a late bargein from a closed overlap to a later overlap', async () => { | ||
| const { stream, ws } = await openStream(); | ||
| const events = collectEvents(stream); | ||
|
|
||
| // Overlap A: send one inference request, then let the overlap close unanswered. | ||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted()); | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await stream.pushFrame(makeAudioFrame()); | ||
| await sleep(20); | ||
|
|
||
| const requestIds = sentRequestIds(ws); | ||
| expect(requestIds.length).toBeGreaterThan(0); | ||
| const overlapARequestId = requestIds[requestIds.length - 1]!; | ||
|
|
||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechEnded(Date.now())); | ||
| await sleep(20); | ||
|
|
||
| // Overlap B opens a few hundred ms later, inside the same agent turn. | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await sleep(20); | ||
| const beforeLateResponse = events.length; | ||
|
|
||
| // Overlap A's response finally lands, while overlap B is open. | ||
| ws.simulateMessage({ | ||
| type: 'bargein_detected', | ||
| created_at: overlapARequestId, | ||
| probabilities: [0.99, 0.99, 0.99, 0.99], | ||
| prediction_duration: 0.05, | ||
| }); | ||
| await sleep(30); | ||
|
|
||
| const late = events.slice(beforeLateResponse); | ||
| expect(late.filter((e) => e.isInterruption)).toEqual([]); | ||
|
|
||
| await stream.close(); | ||
| }); | ||
|
|
||
| it('does not attribute a bargein from a previous agent turn to the current one', async () => { | ||
| // Overlap identity has to outlive the agent turn. A per-turn counter would restart at the | ||
| // same value next turn, and dropping the request's record at the boundary would let it | ||
| // through as "current" — either way the verdict lands on unrelated later speech. | ||
| const { stream, ws } = await openStream(); | ||
| const events = collectEvents(stream); | ||
|
|
||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted()); | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await stream.pushFrame(makeAudioFrame()); | ||
| await sleep(20); | ||
|
|
||
| const requestIds = sentRequestIds(ws); | ||
| expect(requestIds.length).toBeGreaterThan(0); | ||
| const previousTurnRequestId = requestIds[requestIds.length - 1]!; | ||
|
|
||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechEnded()); | ||
| await sleep(20); | ||
|
|
||
| // Next agent turn, first overlap — the same position the previous request was cut from. | ||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted()); | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await sleep(20); | ||
| const beforeLateResponse = events.length; | ||
|
|
||
| ws.simulateMessage({ | ||
| type: 'bargein_detected', | ||
| created_at: previousTurnRequestId, | ||
| probabilities: [0.99, 0.99, 0.99, 0.99], | ||
| prediction_duration: 0.05, | ||
| }); | ||
| await sleep(30); | ||
|
|
||
| expect(events.slice(beforeLateResponse).filter((e) => e.isInterruption)).toEqual([]); | ||
|
|
||
| await stream.close(); | ||
| }); | ||
|
|
||
| it('still reports an interruption when the request generation is no longer on record', async () => { | ||
| // The generation ledger is bounded, so a very long overlap can evict a live request. Losing | ||
| // that bookkeeping must fail open — dropping the response instead would suppress a genuine | ||
| // interruption, which is worse than the misattribution this guard exists to prevent. | ||
| const { stream, ws } = await openStream(); | ||
| const events = collectEvents(stream); | ||
|
|
||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted()); | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await stream.pushFrame(makeAudioFrame()); | ||
| await sleep(20); | ||
|
|
||
| // A `created_at` the transport never recorded, standing in for an evicted ledger entry. | ||
| ws.simulateMessage({ | ||
| type: 'bargein_detected', | ||
| created_at: Math.floor(performance.now()), | ||
| probabilities: [0.99, 0.99, 0.99, 0.99], | ||
| prediction_duration: 0.05, | ||
| }); | ||
| await sleep(30); | ||
|
|
||
| expect(events.filter((e) => e.isInterruption).length).toBe(1); | ||
|
|
||
| await stream.close(); | ||
| }); | ||
|
|
||
| it('still reports an interruption for a response belonging to the open overlap', async () => { | ||
| const { stream, ws } = await openStream(); | ||
| const events = collectEvents(stream); | ||
|
|
||
| await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted()); | ||
| await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now())); | ||
| await stream.pushFrame(makeAudioFrame()); | ||
| await sleep(20); | ||
|
|
||
| const requestIds = sentRequestIds(ws); | ||
| expect(requestIds.length).toBeGreaterThan(0); | ||
|
|
||
| ws.simulateMessage({ | ||
| type: 'bargein_detected', | ||
| created_at: requestIds[requestIds.length - 1]!, | ||
| probabilities: [0.99, 0.99, 0.99, 0.99], | ||
| prediction_duration: 0.05, | ||
| }); | ||
| await sleep(30); | ||
|
|
||
| expect(events.filter((e) => e.isInterruption).length).toBe(1); | ||
|
|
||
| await stream.close(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we've fixed this with a simpler implementation: livekit/agents#6957, can we use
cache.updateValue()and ignore missing entries in both handlers, then remove the generation ledger and fail-open test?For an interruption verdict that, for some reason, failed to interrupt the agent speech, we are going to fall back to VAD interruption path in another PR, because running inference with the same/similar audio context will have the same prediction anyway.