Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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': 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.
55 changes: 55 additions & 0 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,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 <sound value="laugh"/>';
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();
Expand Down
25 changes: 22 additions & 3 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 @@ -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 `<expr/>` 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));
Expand Down Expand Up @@ -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 {
Expand All @@ -934,6 +946,7 @@ export class ChatContext {
excludeTimestamp = true,
excludeFunctionCall = false,
excludeConfigUpdate = false,
stripMarkup = false,
} = options;

const items: ChatItem[] = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions agents/src/llm/provider_format/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions agents/src/llm/provider_format/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
19 changes: 19 additions & 0 deletions agents/src/llm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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]> = [];
Expand Down
18 changes: 18 additions & 0 deletions agents/src/tts/provider_format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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);

return stripped;
}
6 changes: 3 additions & 3 deletions agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion plugins/anthropic/src/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
14 changes: 7 additions & 7 deletions plugins/phonic/src/realtime/realtime_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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}</${item.role}>`;
}
Expand Down
Loading