Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/fuzzy-crabs-express.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@livekit/agents': patch
'@livekit/agents-plugin-anthropic': patch
'@livekit/agents-plugin-phonic': patch
---

Preserve raw expressive markup for provider-facing text while stripping LiveKit expr tags from assistant text content.
72 changes: 48 additions & 24 deletions agents/src/llm/chat_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { initializeLogger } from '../log.js';
import { stripExprMarkup } from '../tts/provider_format.js';
import { INSTRUCTIONS_MESSAGE_ID, applyInstructionsModality } from '../voice/generation.js';
import { FakeLLM } from '../voice/testing/fake_llm.js';
import {
Expand All @@ -26,6 +27,16 @@ initializeLogger({ pretty: false, level: 'error' });
const summaryXml = (summary: string) =>
['<chat_history_summary>', summary, '</chat_history_summary>'].join('\n');

const mixedMarkup =
'<expr type="expression" label="happy"/> Press [Enter] to see <b>bold</b>, ' +
'read [the docs](https://docs.livekit.io), then 1 < 2. <break time="1s"/> ' +
'<expr type="prosody" label="whisper">keep it secret</expr>';

const mixedMarkupClean =
' Press [Enter] to see <b>bold</b>, ' +
'read [the docs](https://docs.livekit.io), then 1 < 2. <break time="1s"/> ' +
'keep it secret';

class TrackingFakeLLM extends FakeLLM {
chatCalls = 0;

Expand Down Expand Up @@ -305,28 +316,41 @@ describe('ChatContext.toJSON', () => {
});
});

describe('ChatMessage text content', () => {
const mixed =
'<expr type="expression" label="happy"/> Press [Enter] to see <b>bold</b>, ' +
'read [the docs](https://docs.livekit.io), then 1 < 2. <break time="1s"/> ' +
'<expr type="prosody" label="whisper">keep it secret</expr>';
const mixedClean =
' Press [Enter] to see <b>bold</b>, ' +
'read [the docs](https://docs.livekit.io), then 1 < 2. <break time="1s"/> ' +
'keep it secret';

it('strips only expr markup from assistant textContent', () => {
const msg = ChatMessage.create({ role: 'assistant', content: [mixed] });

expect(msg.textContent).toBe(mixedClean);
expect(msg.rawTextContent).toBe(mixed);
describe('stripExprMarkup and ChatMessage text content', () => {
it('stripExprMarkup only touches expr tags', () => {
expect(stripExprMarkup(mixedMarkup)).toBe(mixedMarkupClean);
});

it('stripExprMarkup is a noop without expr tags', () => {
const text = 'plain text with [brackets] and <sound value="laugh"/>';
expect(stripExprMarkup(text)).toBe(text);
});

it('strips an unmatched opening expr marker', () => {
expect(stripExprMarkup('Alpha <expr type="prosody" label="whisper">bravo')).toBe('Alpha bravo');
});

it('strips an unmatched closing expr marker', () => {
expect(stripExprMarkup('Alpha </expr>bravo')).toBe('Alpha bravo');
});

it('strips a marker assembled from stream-split chunks', () => {
const chunks = ['Alpha <ex', 'pr type="break" label="1s"/> bravo'];
expect(stripExprMarkup(chunks.join(''))).toBe('Alpha bravo');
});

it('strips expr tags from assistant textContent only', () => {
const msg = ChatMessage.create({ role: 'assistant', content: [mixedMarkup] });

expect(msg.textContent).toBe(mixedMarkupClean);
expect(msg.rawTextContent).toBe(mixedMarkup);
});

it.each(['user', 'system', 'developer'] as const)('keeps %s textContent raw', (role) => {
const msg = ChatMessage.create({ role, content: [mixed] });
const msg = ChatMessage.create({ role, content: [mixedMarkup] });

expect(msg.textContent).toBe(mixed);
expect(msg.rawTextContent).toBe(mixed);
expect(msg.textContent).toBe(mixedMarkup);
expect(msg.rawTextContent).toBe(mixedMarkup);
});

it('returns undefined without text content', () => {
Expand All @@ -338,19 +362,19 @@ describe('ChatMessage text content', () => {

it('toJSON stripMarkup is expr-only and assistant-only', () => {
const chatCtx = new ChatContext();
chatCtx.addMessage({ role: 'user', content: [mixed] });
chatCtx.addMessage({ role: 'assistant', content: [mixed] });
chatCtx.addMessage({ role: 'user', content: [mixedMarkup] });
chatCtx.addMessage({ role: 'assistant', content: [mixedMarkup] });

const stripped = chatCtx.toJSON({ stripMarkup: true });
expect(stripped.items).toEqual([
expect.objectContaining({ content: [mixed], role: 'user' }),
expect.objectContaining({ content: [mixedClean], role: 'assistant' }),
expect.objectContaining({ content: [mixedMarkup], role: 'user' }),
expect.objectContaining({ content: [mixedMarkupClean], role: 'assistant' }),
]);

const raw = chatCtx.toJSON();
expect(raw.items).toEqual([
expect.objectContaining({ content: [mixed], role: 'user' }),
expect.objectContaining({ content: [mixed], role: 'assistant' }),
expect.objectContaining({ content: [mixedMarkup], role: 'user' }),
expect.objectContaining({ content: [mixedMarkup], role: 'assistant' }),
]);
});
});
Expand Down
5 changes: 1 addition & 4 deletions agents/src/llm/chat_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
import type { AudioFrame, VideoFrame } from '@livekit/rtc-node';
import { stripExprMarkup } from '../tts/provider_format.js';
import { createImmutableArray, shortuuid } from '../utils.js';
import type { LLM } from './llm.js';
import { type ProviderFormat, toChatCtx } from './provider_format/index.js';
Expand Down Expand Up @@ -228,10 +229,6 @@ export function concatInstructions(...parts: Array<string | Instructions>): stri

export type ChatContent = ImageContent | AudioContent | Instructions | string;

function stripExprMarkup(text: string): string {
return text.replace(/<expr\b[^>]*>/g, '').replace(/<\/expr\s*>/g, '');
}

export function createImageContent(params: {
image: string | VideoFrame;
id?: string;
Expand Down
20 changes: 20 additions & 0 deletions agents/src/tts/provider_format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0

/** Strip only LiveKit expressive `<expr/>` tags, leaving provider-native markup untouched. */
export function stripExprMarkup(text: string): string {
let stripped = text;
let previous: string;

do {
previous = stripped;
stripped = stripped
.replace(/<expr\b[^>]*\/>/gi, '')
.replace(/<expr\b[^>]*>([\s\S]*?)<\/expr>/gi, '$1');
} while (stripped !== previous);

stripped = stripped.replace(/<expr\b[^>]*\/?>/gi, '').replace(/<\/expr\s*>/gi, '');

return stripped;
}
Loading