diff --git a/.changeset/fuzzy-crabs-express.md b/.changeset/fuzzy-crabs-express.md new file mode 100644 index 000000000..e6663882b --- /dev/null +++ b/.changeset/fuzzy-crabs-express.md @@ -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. diff --git a/agents/src/llm/chat_context.test.ts b/agents/src/llm/chat_context.test.ts index bfb51aa2e..9388dd546 100644 --- a/agents/src/llm/chat_context.test.ts +++ b/agents/src/llm/chat_context.test.ts @@ -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 { @@ -26,6 +27,16 @@ initializeLogger({ pretty: false, level: 'error' }); const summaryXml = (summary: string) => ['', summary, ''].join('\n'); +const mixedMarkup = + ' Press [Enter] to see bold, ' + + 'read [the docs](https://docs.livekit.io), then 1 < 2. ' + + 'keep it secret'; + +const mixedMarkupClean = + ' Press [Enter] to see bold, ' + + 'read [the docs](https://docs.livekit.io), then 1 < 2. ' + + 'keep it secret'; + class TrackingFakeLLM extends FakeLLM { chatCalls = 0; @@ -305,28 +316,41 @@ describe('ChatContext.toJSON', () => { }); }); -describe('ChatMessage text content', () => { - const mixed = - ' Press [Enter] to see bold, ' + - 'read [the docs](https://docs.livekit.io), then 1 < 2. ' + - 'keep it secret'; - const mixedClean = - ' Press [Enter] to see bold, ' + - 'read [the docs](https://docs.livekit.io), then 1 < 2. ' + - '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 '; + expect(stripExprMarkup(text)).toBe(text); + }); + + it('strips an unmatched opening expr marker', () => { + expect(stripExprMarkup('Alpha bravo')).toBe('Alpha bravo'); + }); + + it('strips an unmatched closing expr marker', () => { + expect(stripExprMarkup('Alpha bravo')).toBe('Alpha bravo'); + }); + + it('strips a marker assembled from stream-split chunks', () => { + const chunks = ['Alpha 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', () => { @@ -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' }), ]); }); }); diff --git a/agents/src/llm/chat_context.ts b/agents/src/llm/chat_context.ts index 65f4ff052..0803c4a3e 100644 --- a/agents/src/llm/chat_context.ts +++ b/agents/src/llm/chat_context.ts @@ -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'; @@ -228,10 +229,6 @@ export function concatInstructions(...parts: Array): stri export type ChatContent = ImageContent | AudioContent | Instructions | string; -function stripExprMarkup(text: string): string { - return text.replace(/]*>/g, '').replace(/<\/expr\s*>/g, ''); -} - export function createImageContent(params: { image: string | VideoFrame; id?: string; diff --git a/agents/src/tts/provider_format.ts b/agents/src/tts/provider_format.ts new file mode 100644 index 000000000..54790acca --- /dev/null +++ b/agents/src/tts/provider_format.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** Strip only LiveKit expressive `` tags, leaving provider-native markup untouched. */ +export function stripExprMarkup(text: string): string { + let stripped = text; + let previous: string; + + do { + previous = stripped; + stripped = stripped + .replace(/]*\/>/gi, '') + .replace(/]*>([\s\S]*?)<\/expr>/gi, '$1'); + } while (stripped !== previous); + + stripped = stripped.replace(/]*\/?>/gi, '').replace(/<\/expr\s*>/gi, ''); + + return stripped; +}