From 73862eeadbbf0742f3b6aa385b8adf80b3caf9ab Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:19:02 +0000 Subject: [PATCH] fix(core): flip text content fields --- .changeset/fuzzy-crabs-express.md | 7 +++ agents/src/llm/chat_context.test.ts | 55 +++++++++++++++++++ agents/src/llm/chat_context.ts | 25 ++++++++- agents/src/llm/provider_format/google.ts | 4 +- agents/src/llm/provider_format/utils.ts | 4 +- agents/src/llm/utils.ts | 19 +++++++ agents/src/tts/provider_format.ts | 18 ++++++ agents/src/voice/agent_activity.ts | 6 +- plugins/anthropic/src/llm.ts | 2 +- plugins/phonic/src/realtime/realtime_model.ts | 14 ++--- 10 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 .changeset/fuzzy-crabs-express.md create mode 100644 agents/src/tts/provider_format.ts diff --git a/.changeset/fuzzy-crabs-express.md b/.changeset/fuzzy-crabs-express.md new file mode 100644 index 000000000..1783e902d --- /dev/null +++ b/.changeset/fuzzy-crabs-express.md @@ -0,0 +1,7 @@ +--- +'@livekit/agents': minor +'@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 101a9fe46..8de9e825e 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,6 +316,50 @@ describe('ChatContext.toJSON', () => { }); }); +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 expr tags from assistant textContent only', () => { + const msg = ChatMessage.create({ role: 'assistant', content: [mixedMarkup] }); + expect(msg.textContent).toBe(mixedMarkupClean); + expect(msg.rawTextContent).toBe(mixedMarkup); + }); + + for (const role of ['user', 'system', 'developer'] as const) { + it(`keeps ${role} textContent raw`, () => { + const msg = ChatMessage.create({ role, content: [mixedMarkup] }); + expect(msg.textContent).toBe(mixedMarkup); + expect(msg.rawTextContent).toBe(mixedMarkup); + }); + } + + it('returns undefined without text', () => { + const msg = ChatMessage.create({ role: 'assistant', content: [] }); + expect(msg.textContent).toBeUndefined(); + expect(msg.rawTextContent).toBeUndefined(); + }); + + it('toJSON stripMarkup strips expr tags from assistant messages only', () => { + const chatCtx = new ChatContext(); + chatCtx.addMessage({ role: 'user', content: [mixedMarkup] }); + chatCtx.addMessage({ role: 'assistant', content: [mixedMarkup] }); + + const strippedItems = chatCtx.toJSON({ stripMarkup: true }).items; + expect(strippedItems[0]).toMatchObject({ content: [mixedMarkup] }); + expect(strippedItems[1]).toMatchObject({ content: [mixedMarkupClean] }); + + const rawItems = chatCtx.toJSON().items; + expect(rawItems[1]).toMatchObject({ content: [mixedMarkup] }); + }); +}); + describe('ChatContext._summarize', () => { it('includes function calls in the summarization source and keeps chronological order', async () => { const ctx = new ChatContext(); diff --git a/agents/src/llm/chat_context.ts b/agents/src/llm/chat_context.ts index 34cf39200..9cae0f53c 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'; @@ -357,10 +358,20 @@ export class ChatMessage { } /** - * Returns a single string with all text parts of the message joined by new - * lines. If no string content is present, returns `null`. + * Returns text content with LiveKit expressive `` tags removed from assistant messages. + * Use {@link rawTextContent} for the exact model-facing content. */ get textContent(): string | undefined { + const raw = this.rawTextContent; + if (raw === undefined || this.role !== 'assistant') { + return raw; + } + + return stripExprMarkup(raw); + } + + /** Returns the exact text content as generated, joined by new lines. */ + get rawTextContent(): string | undefined { const parts = this.content .filter((c): c is string | Instructions => typeof c === 'string' || isInstructions(c)) .map((c) => (typeof c === 'string' ? c : c.value)); @@ -925,6 +936,7 @@ export class ChatContext { excludeTimestamp?: boolean; excludeFunctionCall?: boolean; excludeConfigUpdate?: boolean; + stripMarkup?: boolean; } = {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): JSONObject { @@ -934,6 +946,7 @@ export class ChatContext { excludeTimestamp = true, excludeFunctionCall = false, excludeConfigUpdate = false, + stripMarkup = false, } = options; const items: ChatItem[] = []; @@ -973,6 +986,12 @@ export class ChatContext { return !(typeof c === 'object' && c.type === 'audio_content'); }); } + + if (stripMarkup && processedItem.role === 'assistant') { + processedItem.content = processedItem.content.map((c) => + typeof c === 'string' ? stripExprMarkup(c) : c, + ); + } } items.push(processedItem); @@ -1190,7 +1209,7 @@ export class ChatContext { return toXml(item.role, (item.textContent ?? '').trim()); } - return functionCallItemToMessage(item).textContent ?? ''; + return functionCallItemToMessage(item).rawTextContent ?? ''; }) .join('\n') .trim(); diff --git a/agents/src/llm/provider_format/google.ts b/agents/src/llm/provider_format/google.ts index 2ef717f28..d7d2197d2 100644 --- a/agents/src/llm/provider_format/google.ts +++ b/agents/src/llm/provider_format/google.ts @@ -31,8 +31,8 @@ export async function toChatCtx( for (const msg of flattenedItems) { // Handle system messages separately - if (msg.type === 'message' && msg.role === 'system' && msg.textContent) { - systemMessages.push(msg.textContent); + if (msg.type === 'message' && msg.role === 'system' && msg.rawTextContent) { + systemMessages.push(msg.rawTextContent); continue; } diff --git a/agents/src/llm/provider_format/utils.ts b/agents/src/llm/provider_format/utils.ts index a0fcdbe13..d8a497b5c 100644 --- a/agents/src/llm/provider_format/utils.ts +++ b/agents/src/llm/provider_format/utils.ts @@ -117,12 +117,12 @@ export function convertMidConversationInstructions( item.type === 'message' && (item.role === 'system' || item.role === 'developer') && firstSystemSeen && - item.textContent + item.rawTextContent ) { items.push( ChatMessage.create({ role, - content: template.replace('{instructions}', item.textContent), + content: template.replace('{instructions}', item.rawTextContent), id: item.id, createdAt: item.createdAt, }), diff --git a/agents/src/llm/utils.ts b/agents/src/llm/utils.ts index 0db2bb802..0b138f687 100644 --- a/agents/src/llm/utils.ts +++ b/agents/src/llm/utils.ts @@ -706,6 +706,18 @@ interface DiffOps { toCreate: Array<[string | null, string]>; // (previous_item_id, id), if previous_item_id is null, add to the root } +function isUnchangedChatItem(oldItem: ChatItem | undefined, newItem: ChatItem): boolean { + if (!oldItem || oldItem.type !== newItem.type) { + return false; + } + + if (oldItem.type === 'message' && newItem.type === 'message') { + return oldItem.rawTextContent === newItem.rawTextContent; + } + + return true; +} + /** * Compute the minimal list of create/remove operations to transform oldCtx into newCtx. * @@ -717,6 +729,13 @@ export function computeChatCtxDiff(oldCtx: ChatContext, newCtx: ChatContext): Di const oldIds = oldCtx.items.map((item: ChatItem) => item.id); const newIds = newCtx.items.map((item: ChatItem) => item.id); const lcsIds = new Set(computeLCS(oldIds, newIds)); + const oldCtxById = new Map(oldCtx.items.map((item) => [item.id, item])); + + for (const newItem of newCtx.items) { + if (lcsIds.has(newItem.id) && !isUnchangedChatItem(oldCtxById.get(newItem.id), newItem)) { + lcsIds.delete(newItem.id); + } + } const toRemove = oldCtx.items.filter((msg) => !lcsIds.has(msg.id)).map((msg) => msg.id); const toCreate: Array<[string | null, string]> = []; diff --git a/agents/src/tts/provider_format.ts b/agents/src/tts/provider_format.ts new file mode 100644 index 000000000..9f84d1f2d --- /dev/null +++ b/agents/src/tts/provider_format.ts @@ -0,0 +1,18 @@ +// 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); + + return stripped; +} diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index 9fd906783..0839f4d93 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -2117,7 +2117,7 @@ export class AgentActivity implements RecognitionHooks { this.realtimeReplyTask({ speechHandle: handle, // TODO(brian): support llm.ChatMessage for the realtime model - userInput: userMessage?.textContent, + userInput: userMessage?.rawTextContent, instructions, modelSettings: { // isGiven(toolChoice) = toolChoice !== undefined @@ -2391,7 +2391,7 @@ export class AgentActivity implements RecognitionHooks { // make sure the onUserTurnCompleted didn't change some request parameters // otherwise invalidate the preemptive generation if ( - preemptive.info.newTranscript === userMessage?.textContent && + preemptive.info.newTranscript === userMessage?.rawTextContent && preemptive.chatCtx.isEquivalent(chatCtx) && preemptive.tools.equals(this.tools) && isSameToolChoice(preemptive.toolChoice, this.toolChoice) @@ -2649,7 +2649,7 @@ export class AgentActivity implements RecognitionHooks { span.setAttribute(traceTypes.ATTR_INSTRUCTIONS, renderInstructions(instructions)); } if (newMessage) { - span.setAttribute(traceTypes.ATTR_USER_INPUT, newMessage.textContent || ''); + span.setAttribute(traceTypes.ATTR_USER_INPUT, newMessage.rawTextContent || ''); } const localParticipant = this.agentSession._roomIO?.localParticipant; diff --git a/plugins/anthropic/src/llm.ts b/plugins/anthropic/src/llm.ts index 7f9fe9056..0c85bbf90 100644 --- a/plugins/anthropic/src/llm.ts +++ b/plugins/anthropic/src/llm.ts @@ -113,7 +113,7 @@ export class LLM extends llm.LLM { for (const msg of chatCtx.items) { if (msg.type === 'message') { - const textContent = msg.textContent || ''; + const textContent = msg.rawTextContent || ''; if (msg.role === 'system' || msg.role === 'developer') { system.push({ type: 'text', text: textContent }); } else if (msg.role === 'user' || msg.role === 'assistant') { diff --git a/plugins/phonic/src/realtime/realtime_model.ts b/plugins/phonic/src/realtime/realtime_model.ts index 6ccfb2102..d2a92cfd4 100644 --- a/plugins/phonic/src/realtime/realtime_model.ts +++ b/plugins/phonic/src/realtime/realtime_model.ts @@ -369,19 +369,19 @@ export class RealtimeSession extends llm.RealtimeSession { } } if (item?.type === 'message') { - if ((item.role === 'system' || item.role === 'developer') && item.textContent) { - this.#logger.debug(`Sending add system message: ${item.textContent}`); + if ((item.role === 'system' || item.role === 'developer') && item.rawTextContent) { + this.#logger.debug(`Sending add system message: ${item.rawTextContent}`); this.socket?.sendAddSystemMessage({ type: 'add_system_message', - system_message: item.textContent, + system_message: item.rawTextContent, }); sentAddSystemMessage = true; } // Only treat a user message as text input when it's appended at the tail of the context. - if (item.role === 'user' && itemId === lastItemId && item.textContent) { - this.#logger.debug(`Received user text input: ${item.textContent}`); - this.pendingUserText = item.textContent; + if (item.role === 'user' && itemId === lastItemId && item.rawTextContent) { + this.#logger.debug(`Received user text input: ${item.rawTextContent}`); + this.pendingUserText = item.rawTextContent; bufferedUserText = true; } } @@ -1011,7 +1011,7 @@ export class RealtimeSession extends llm.RealtimeSession { function chatItemToText(item: llm.ChatItem): string | undefined { if (item.type === 'message') { - const text = item.textContent?.trim(); + const text = item.rawTextContent?.trim(); if (!text) return undefined; return `<${item.role}>${text}`; }