Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
4e31b27
Add turn config interfaces and defaults (#975)
lukasIO Jan 16, 2026
f3c7430
Merge branch 'main' into feat/barge-in
Toubat Jan 21, 2026
07c5d71
Add AdaptiveInterruptionDetector (#980)
lukasIO Jan 27, 2026
f1a2114
Merge branch 'main' into feat/barge-in
lukasIO Jan 27, 2026
c861f50
Add agent activity interruption detector integration (#991)
lukasIO Jan 29, 2026
1862dc3
remove aic
lukasIO Jan 29, 2026
705ed33
reuse
lukasIO Jan 29, 2026
8f53889
remove tests for legacy stream approach
lukasIO Jan 29, 2026
7d24bf0
fix util migration tests
lukasIO Jan 29, 2026
c78cf58
comment out example tests
lukasIO Jan 29, 2026
d5b271c
Rename files to underscore cases (#1007)
toubatbrian Jan 30, 2026
b020180
update date
lukasIO Jan 30, 2026
dbad1e4
update date
lukasIO Jan 30, 2026
d882012
update defaults
lukasIO Jan 30, 2026
67e8f6c
deprecate legacy options and update tests
lukasIO Jan 30, 2026
96d6b57
fix internal types
lukasIO Jan 30, 2026
2ee2748
rabbit comments
lukasIO Jan 30, 2026
62cd448
remove unused stuff
lukasIO Jan 30, 2026
ec6d9bd
more rabbit fixes
lukasIO Jan 30, 2026
016e3a4
better cleanup
lukasIO Jan 30, 2026
9a4939c
ensure inputStartedAt is set
lukasIO Jan 30, 2026
e28b1b1
Fix Inference URL parity (#1011)
toubatbrian Feb 2, 2026
4310baa
Preserve turnDetection after cloning
toubatbrian Feb 2, 2026
175e57b
respect LIVEKIT_REMOTE_EOT_URL environment variable
toubatbrian Feb 2, 2026
0682f25
refine timeout computation
toubatbrian Feb 3, 2026
a820521
Merge branch 'main' into feat/barge-in
lukasIO Feb 4, 2026
e175e7d
migrate turnhandling options on agent level
lukasIO Feb 4, 2026
9cb0a29
Create silly-donkeys-shop.md
lukasIO Feb 4, 2026
5f088b9
add explicit logging for sample rate error
lukasIO Feb 4, 2026
6fbc417
conditionally create interruption stream channel only when detection …
lukasIO Feb 4, 2026
9f2932d
propagate updated turn detection options
lukasIO Feb 4, 2026
b4a82ad
fix comment
lukasIO Feb 4, 2026
f2ac83a
Merge branch 'feat/barge-in' of github.com:livekit/agents-js into fea…
lukasIO Feb 4, 2026
ec26bb1
fix tests
lukasIO Feb 4, 2026
b5c541f
migrate allowInterruptions
lukasIO Feb 4, 2026
76bd4e8
fix inputStartedAt assignment
lukasIO Feb 4, 2026
245bc66
Session Usage Collection (#1014)
toubatbrian Feb 5, 2026
ea27278
resolve comments
toubatbrian Feb 5, 2026
63eccca
Merge branch 'main' into feat/barge-in
toubatbrian Feb 5, 2026
5bc7108
Merge branch 'main' into feat/barge-in
toubatbrian Feb 9, 2026
171fb98
Update audio_recognition.ts
toubatbrian Feb 9, 2026
bb93420
change config unit to ms
toubatbrian Feb 10, 2026
717e908
Add Metrics to OTEL Chat History (#1037)
toubatbrian Feb 10, 2026
b935b0d
keep deprecated field for session report
toubatbrian Feb 10, 2026
74e42fa
Merge branch 'feat/barge-in' of https://github.com/livekit/agents-js …
toubatbrian Feb 10, 2026
eaafc14
Merge branch 'main' into feat/barge-in
lukasIO Feb 12, 2026
8d14806
Port barge in fixes from python implementation (#1047)
lukasIO Feb 17, 2026
cfe0362
fix typo
lukasIO Feb 17, 2026
ff277d5
Align agents-js with Python PR #4834 refactor and follow-up fixes (#1…
toubatbrian Feb 21, 2026
b0dbbf5
Remote Session Events (#1073)
toubatbrian Feb 26, 2026
1caed4f
Bargein Model Metrics Usages (#1079)
toubatbrian Feb 27, 2026
7138bc9
Merge branch 'main' into feat/barge-in
toubatbrian Feb 27, 2026
dad12f8
Merge branch 'feat/barge-in' of https://github.com/livekit/agents-js …
toubatbrian Feb 27, 2026
9ae1e9a
resolve conflicts
toubatbrian Feb 27, 2026
f58454b
format code
toubatbrian Feb 27, 2026
dbff0f3
Make test working
toubatbrian Mar 2, 2026
44684ac
fix lint and tests
toubatbrian Mar 2, 2026
66a6b85
Merge branch 'main' into feat/barge-in
toubatbrian Mar 2, 2026
7ef1ecf
Lint
toubatbrian Mar 2, 2026
0956efc
Merge branch 'main' into feat/barge-in
toubatbrian Mar 4, 2026
db163f3
Update interruption_detector.ts
toubatbrian Mar 5, 2026
1cf1aa4
Attach user_speaking span (#1113)
toubatbrian Mar 9, 2026
7cd3e9d
Merge branch 'main' into feat/barge-in
toubatbrian Mar 12, 2026
6450210
replace JSON messages with protobuf messages (#1139)
chenghao-mou Mar 18, 2026
85068ac
Flatten session options (#1140)
lukasIO Mar 18, 2026
e67caa8
Update basic_agent.ts
toubatbrian Mar 18, 2026
f9739dd
port over Python fallback fixes (#1147)
chenghao-mou Mar 19, 2026
c98313e
Merge branch 'main' into feat/barge-in
toubatbrian Mar 19, 2026
4458b81
Merge branch 'feat/barge-in' of https://github.com/livekit/agents-js …
toubatbrian Mar 19, 2026
fe1a82f
Create metal-teeth-buy.md
toubatbrian Mar 19, 2026
b8cad33
keep only 1 changeset
toubatbrian Mar 19, 2026
5371a6b
Update agent.test.ts
toubatbrian Mar 19, 2026
65391bc
fix linting
toubatbrian Mar 19, 2026
ee5b957
Update metal-teeth-buy.md
lukasIO Mar 19, 2026
ac6d18b
Update changeset config
lukasIO Mar 19, 2026
7a963de
update changesets patch
lukasIO Mar 19, 2026
696e89a
revert config changes
lukasIO Mar 19, 2026
da585e0
change timeout to unretryable for bargein and add isHosted check (#1150)
chenghao-mou Mar 19, 2026
98298c5
prettier
lukasIO Mar 19, 2026
d2dab3d
Add error message for interruption error
lukasIO Mar 19, 2026
aff175c
lint
lukasIO Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@
],
"commit": false,
"ignore": ["livekit-agents-examples"],
"fixed": [
[
"@livekit/agents",
"@livekit/agents-plugin-*",
"@livekit/agents-plugins-test"
]
],
"fixed": [["@livekit/agents", "@livekit/agents-plugin-*", "@livekit/agents-plugins-test"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
Expand Down
6 changes: 6 additions & 0 deletions .changeset/metal-teeth-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@livekit/agents': minor
---

- Add adaptive interruption handling
- Add remote session event handler
10 changes: 5 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ jobs:
- name: Test agents
if: steps.filter.outputs.agents-or-tests == 'true' || github.event_name == 'push'
run: pnpm test agents
- name: Test examples
if: (steps.filter.outputs.examples == 'true' || github.event_name == 'push') && secrets.OPENAI_API_KEY != ''
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: pnpm test:examples
# - name: Test examples
# if: (steps.filter.outputs.examples == 'true' || github.event_name == 'push')
# env:
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# run: pnpm test:examples
# TODO (AJS-83) Re-enable once plugins are refactored with abort controllers
# - name: Test all plugins
# if: steps.filter.outputs.agents-or-tests == 'true' || github.event_name != 'pull_request'
Expand Down
4 changes: 3 additions & 1 deletion agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@
"zod": "^3.25.76"
},
"dependencies": {
"@bufbuild/protobuf": "^1.10.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@livekit/mutex": "^1.1.1",
"@livekit/protocol": "^1.43.0",
"@livekit/protocol": "^1.45.1",
"@livekit/typed-emitter": "^3.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.54.0",
Expand All @@ -69,6 +70,7 @@
"heap-js": "^2.6.0",
"json-schema": "^0.4.0",
"livekit-server-sdk": "^2.14.1",
"ofetch": "^1.5.1",
"openai": "^6.8.1",
"pidusage": "^4.0.1",
"pino": "^8.19.0",
Expand Down
2 changes: 2 additions & 0 deletions agents/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const runApp = (opts: ServerOptions) => {
opts.apiSecret = globalOptions.apiSecret || opts.apiSecret;
opts.logLevel = commandOptions.logLevel;
opts.workerToken = globalOptions.workerToken || opts.workerToken;
process.env.LIVEKIT_DEV_MODE = '1';
runServer({
opts,
production: false,
Expand All @@ -169,6 +170,7 @@ export const runApp = (opts: ServerOptions) => {
opts.apiSecret = globalOptions.apiSecret || opts.apiSecret;
opts.logLevel = commandOptions.logLevel;
opts.workerToken = globalOptions.workerToken || opts.workerToken;
process.env.LIVEKIT_DEV_MODE = '1';
runServer({
opts,
production: false,
Expand Down
14 changes: 14 additions & 0 deletions agents/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ export const TOPIC_TRANSCRIPTION = 'lk.transcription';
export const ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID = 'lk.segment_id';
export const ATTRIBUTE_PUBLISH_ON_BEHALF = 'lk.publish_on_behalf';
export const TOPIC_CHAT = 'lk.chat';

export const ATTRIBUTE_AGENT_STATE = 'lk.agent.state';
export const ATTRIBUTE_AGENT_NAME = 'lk.agent.name';

// TODO(eval): export const ATTRIBUTE_SIMULATOR = 'lk.simulator';

export const TOPIC_CLIENT_EVENTS = 'lk.agent.events';
export const RPC_GET_SESSION_STATE = 'lk.agent.get_session_state';
export const RPC_GET_CHAT_HISTORY = 'lk.agent.get_chat_history';
export const RPC_GET_AGENT_INFO = 'lk.agent.get_agent_info';
export const RPC_SEND_MESSAGE = 'lk.agent.send_message';
export const TOPIC_AGENT_REQUEST = 'lk.agent.request';
export const TOPIC_AGENT_RESPONSE = 'lk.agent.response';
export const TOPIC_SESSION_MESSAGES = 'lk.agent.session';
51 changes: 51 additions & 0 deletions agents/src/inference/interruption/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
Comment thread
toubatbrian marked this conversation as resolved.
import type { ApiConnectOptions } from './interruption_stream.js';
import type { InterruptionOptions } from './types.js';

export const MIN_INTERRUPTION_DURATION_IN_S = 0.025 * 2; // 25ms per frame, 2 consecutive frames
export const THRESHOLD = 0.5;
export const MAX_AUDIO_DURATION_IN_S = 3.0;
export const AUDIO_PREFIX_DURATION_IN_S = 0.5;
export const DETECTION_INTERVAL_IN_S = 0.1;
export const REMOTE_INFERENCE_TIMEOUT_IN_S = 0.7;
export const SAMPLE_RATE = 16000;
export const FRAMES_PER_SECOND = 40;
export const FRAME_DURATION_IN_S = 0.025; // 25ms per frame

export const apiConnectDefaults: ApiConnectOptions = {
maxRetries: 3,
retryInterval: 2_000,
timeout: 10_000,
} as const;

/**
* Calculate the retry interval using exponential backoff with jitter.
* Matches the Python implementation's _interval_for_retry behavior.
*/
export function intervalForRetry(
attempt: number,
baseInterval: number = apiConnectDefaults.retryInterval,
): number {
// Exponential backoff: baseInterval * 2^attempt with some jitter
const exponentialDelay = baseInterval * Math.pow(2, attempt);
// Add jitter (0-25% of the delay)
const jitter = exponentialDelay * Math.random() * 0.25;
return exponentialDelay + jitter;
}

// baseUrl and useProxy are resolved dynamically in the constructor
// to respect LIVEKIT_REMOTE_EOT_URL environment variable
export const interruptionOptionDefaults: Omit<InterruptionOptions, 'baseUrl' | 'useProxy'> = {
sampleRate: SAMPLE_RATE,
threshold: THRESHOLD,
minFrames: Math.ceil(MIN_INTERRUPTION_DURATION_IN_S * FRAMES_PER_SECOND),
maxAudioDurationInS: MAX_AUDIO_DURATION_IN_S,
audioPrefixDurationInS: AUDIO_PREFIX_DURATION_IN_S,
detectionIntervalInS: DETECTION_INTERVAL_IN_S,
inferenceTimeout: REMOTE_INFERENCE_TIMEOUT_IN_S * 1_000,
apiKey: process.env.LIVEKIT_API_KEY || '',
apiSecret: process.env.LIVEKIT_API_SECRET || '',
Comment on lines +48 to +49
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 LIVEKIT_INFERENCE_API_KEY env var is unreachable due to module-level default capturing

In agents/src/inference/interruption/defaults.ts:48, interruptionOptionDefaults.apiKey is set to process.env.LIVEKIT_API_KEY || '' at module load time. Because || '' coerces a missing env var to an empty string (never null/undefined), the ?? fallback chain in the AdaptiveInterruptionDetector constructor (agents/src/inference/interruption/interruption_detector.ts:57-58) never falls through to check LIVEKIT_INFERENCE_API_KEY:

lkApiKey = apiKey ?? process.env.LIVEKIT_INFERENCE_API_KEY ?? process.env.LIVEKIT_API_KEY ?? '';

Since apiKey is always a string (from the defaults spread at line 42), apiKey ?? short-circuits and the inference-specific env var is skipped. This differs from the other inference services (e.g., agents/src/inference/llm.ts:128) which use || for proper falsy-value fallthrough and read env vars fresh at construction time rather than from module-level defaults.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

minInterruptionDurationInS: MIN_INTERRUPTION_DURATION_IN_S,
Comment thread
toubatbrian marked this conversation as resolved.
} as const;
25 changes: 25 additions & 0 deletions agents/src/inference/interruption/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
/**
* Error thrown during interruption detection.
*/
export class InterruptionDetectionError extends Error {
readonly type = 'interruption_detection_error' as const;

readonly timestamp: number;
readonly label: string;
readonly recoverable: boolean;

constructor(message: string, timestamp: number, label: string, recoverable: boolean) {
super(message);
this.name = 'InterruptionDetectionError';
this.timestamp = timestamp;
this.label = label;
this.recoverable = recoverable;
}

toString(): string {
return `${this.name}: ${this.message} (label=${this.label}, timestamp=${this.timestamp}, recoverable=${this.recoverable})`;
}
}
206 changes: 206 additions & 0 deletions agents/src/inference/interruption/http_transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { FetchError, ofetch } from 'ofetch';
import { TransformStream } from 'stream/web';
import { z } from 'zod';
import { APIConnectionError, APIError, APIStatusError, isAPIError } from '../../_exceptions.js';
import { log } from '../../log.js';
import { createAccessToken } from '../utils.js';
import { InterruptionCacheEntry } from './interruption_cache_entry.js';
import type { OverlappingSpeechEvent } from './types.js';
import type { BoundedCache } from './utils.js';

export interface PostOptions {
baseUrl: string;
token: string;
signal?: AbortSignal;
timeout?: number;
maxRetries?: number;
}

export interface PredictOptions {
threshold: number;
minFrames: number;
}

export const predictEndpointResponseSchema = z.object({
created_at: z.number(),
is_bargein: z.boolean(),
probabilities: z.array(z.number()),
});

export type PredictEndpointResponse = z.infer<typeof predictEndpointResponseSchema>;

export interface PredictResponse {
createdAt: number;
isBargein: boolean;
probabilities: number[];
predictionDurationInS: number;
}

export async function predictHTTP(
data: Int16Array,
predictOptions: PredictOptions,
options: PostOptions,
): Promise<PredictResponse> {
const createdAt = performance.now();
const url = new URL(`/bargein`, options.baseUrl);
url.searchParams.append('threshold', predictOptions.threshold.toString());
url.searchParams.append('min_frames', predictOptions.minFrames.toFixed());
url.searchParams.append('created_at', createdAt.toFixed());

try {
const response = await ofetch(url.toString(), {
retry: 0,
headers: {
'Content-Type': 'application/octet-stream',
Authorization: `Bearer ${options.token}`,
},
signal: options.signal,
timeout: options.timeout,
method: 'POST',
body: data,
});
const { created_at, is_bargein, probabilities } = predictEndpointResponseSchema.parse(response);

return {
createdAt: created_at,
isBargein: is_bargein,
probabilities,
predictionDurationInS: (performance.now() - createdAt) / 1000,
};
} catch (err) {
if (isAPIError(err)) throw err;
if (err instanceof FetchError) {
if (err.statusCode) {
throw new APIStatusError({
message: `error during interruption prediction: ${err.message}`,
options: { statusCode: err.statusCode, body: err.data },
});
}
if (
err.cause instanceof Error &&
(err.cause.name === 'TimeoutError' || err.cause.name === 'AbortError')
) {
throw new APIStatusError({
message: `interruption inference timeout: ${err.message}`,
options: { statusCode: 408, retryable: false },
});
}
throw new APIConnectionError({
message: `interruption inference connection error: ${err.message}`,
});
}
throw new APIError(`error during interruption prediction: ${err}`);
}
}

export interface HttpTransportOptions {
baseUrl: string;
apiKey: string;
apiSecret: string;
threshold: number;
minFrames: number;
timeout: number;
maxRetries?: number;
}

export interface HttpTransportState {
overlapSpeechStarted: boolean;
overlapSpeechStartedAt: number | undefined;
cache: BoundedCache<number, InterruptionCacheEntry>;
}

/**
* Creates an HTTP transport TransformStream for interruption detection.
*
* This transport receives Int16Array audio slices and outputs InterruptionEvents.
* Each audio slice triggers an HTTP POST request.
*
* @param options - Transport options object. This is read on each request, so mutations
* to threshold/minFrames will be picked up dynamically.
*/
export function createHttpTransport(
options: HttpTransportOptions,
getState: () => HttpTransportState,
setState: (partial: Partial<HttpTransportState>) => void,
updateUserSpeakingSpan?: (entry: InterruptionCacheEntry) => void,
getAndResetNumRequests?: () => number,
): TransformStream<Int16Array | OverlappingSpeechEvent, OverlappingSpeechEvent> {
const logger = log();

return new TransformStream<Int16Array | OverlappingSpeechEvent, OverlappingSpeechEvent>(
{
async transform(chunk, controller) {
if (!(chunk instanceof Int16Array)) {
controller.enqueue(chunk);
return;
}

const state = getState();
const overlapSpeechStartedAt = state.overlapSpeechStartedAt;
if (overlapSpeechStartedAt === undefined || !state.overlapSpeechStarted) return;

try {
const resp = await predictHTTP(
chunk,
{ threshold: options.threshold, minFrames: options.minFrames },
{
baseUrl: options.baseUrl,
timeout: options.timeout,
maxRetries: options.maxRetries,
token: await createAccessToken(options.apiKey, options.apiSecret),
},
);

const { createdAt, isBargein, probabilities, predictionDurationInS } = resp;
const entry = state.cache.setOrUpdate(
createdAt,
() => new InterruptionCacheEntry({ createdAt }),
{
probabilities,
isInterruption: isBargein,
speechInput: chunk,
totalDurationInS: (performance.now() - createdAt) / 1000,
detectionDelayInS: (Date.now() - overlapSpeechStartedAt) / 1000,
predictionDurationInS,
},
);

if (state.overlapSpeechStarted && entry.isInterruption) {
if (updateUserSpeakingSpan) {
updateUserSpeakingSpan(entry);
}
const event: OverlappingSpeechEvent = {
type: 'overlapping_speech',
detectedAt: Date.now(),
overlapStartedAt: overlapSpeechStartedAt,
isInterruption: entry.isInterruption,
speechInput: entry.speechInput,
probabilities: entry.probabilities,
totalDurationInS: entry.totalDurationInS,
predictionDurationInS: entry.predictionDurationInS,
detectionDelayInS: entry.detectionDelayInS,
probability: entry.probability,
numRequests: getAndResetNumRequests?.() ?? 0,
};
logger.debug(
{
detectionDelayInS: entry.detectionDelayInS,
totalDurationInS: entry.totalDurationInS,
},
'interruption detected',
);
setState({ overlapSpeechStarted: false });
controller.enqueue(event);
}
} catch (err) {
controller.error(err);
}
},
},
{ highWaterMark: 2 },
{ highWaterMark: 2 },
);
}
Loading
Loading