Skip to content

feat(tts): Expressiveness Mode#1982

Open
toubatbrian wants to merge 12 commits into
mainfrom
brian/expressive-mode
Open

feat(tts): Expressiveness Mode#1982
toubatbrian wants to merge 12 commits into
mainfrom
brian/expressive-mode

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Port of livekit/agents#6116 (expressive mode) to agents-js.

Summary

  • Markup pipeline (agents/src/tts/markup_utils.ts, _provider_format.ts): provider-keyed dialects for LLM tag instructions, transcript stripping, tag normalization, and conversion to native TTS syntax. TTS exposes a markup accessor; plugins override _markupProviderKey() (Cartesia, Inworld; xAI dialect built in).
  • Instructions redesign (agents/src/llm/chat_context.ts): unified common text with optional audio/text additions and a render(modality, data) method, replacing modality variants (asModality/concatInstructions removed). safeRender utility fills template placeholders.
  • XML-aware tokenization (agents/src/tokenize/): sentence tokenizer treats markup tags as atomic units so tags never split across TTS chunks (xmlAware, maxTokenLength, minTokenLength).
  • Voice pipeline wiring (agents/src/voice/): ExpressiveOptions + presets (presets.ts), expressive instruction injection in AgentActivity, markup stripping in room transcription output and the transcript synchronizer, lk.expression attribute forwarding.
  • STT SpeakerContext: context models can contribute LLM instructions via sttContext in audio recognition.
  • Inference TTS (agents/src/inference/tts.ts): sentence-level normalize/convert of markup before sending to the gateway, expressive chunking.

Also fixes an off-by-one in splitSentences (final sentence end offset) that caused the XML-aware tokenizer to split the last character into a phantom sentence, producing mid-word TTS chunks (regression test added in xml_markup.test.ts).

The XML tag regex in token_stream.ts uses a single [^<>]* body (self-closing detection on the captured text), keeping matching linear-time on untrusted LLM output (CodeQL js/polynomial-redos). Note: the Python source (livekit/agents#6116) still carries the backtracking-prone form.

Review follow-ups

Fixed (JS-only issue):

  • voice.presets namespace is now exported (presets.CUSTOMER_SERVICE / presets.CASUAL were unreachable).

Fixed — intentional deviations from Python (bugs verified against livekit/agents main; candidates for upstreaming):

  • instructionsEqual: an Instructions with audio/text additions no longer equals its bare common string (Python __eq__ returns True, which lets realtime session reuse skip instruction updates on handoff).
  • Instructions.resolveTemplate + render(modality): variants are full template renders; render now returns the variant instead of appending it to common (Python duplicates the whole template: render(audio)common + "\n\n" + audio).
  • Pipeline reply: base Instructions are re-rendered for the turn modality even when extra per-turn instructions are supplied (Python skips the re-render in that branch, leaving audio-rendered base rules in text turns).

Examples (ports of the #6116 example updates):

  • Updated: drive-thru, frontdesk, survey_agent.ts, instructions_per_modality.ts; added inference_agent.ts (playground agent with expressive CASUAL preset and RPC model controls).
  • Not ported — blocked: the healthcare example. It depends on beta workflow tasks that don't exist in JS yet (GetNameTask, GetDOBTask, GetPhoneNumberTask, GetCreditCardTask; JS has only TaskGroup, WarmTransferTask, EndCallTool). Should follow once the beta workflows are ported.

Kept as-is (matches Python by design):

  • Transcript sinks strip the union of all providers' markup, including [...] bracket tags, regardless of expressive mode — documented upstream design decision (tag shapes don't occur in spoken text).
  • expressive: true silently resolves to disabled for non-inference-gateway TTS — same silent guard as Python.
  • Instructions removed from ChatContent — intentional API change matching Python (instructions are resolved to strings before storage).

Test plan

  • Unit tests ported/added: xml_markup.test.ts, markup_utils.test.ts, Instructions tests, provider format snapshots
  • pnpm build, pnpm test, pnpm lint, pnpm api:update green (agents + touched plugins)
  • cue-cli e2e: basic agent regression (text) — PASS (getWeather tool call, non-expressive)
  • cue-cli e2e: expressive agent (voice) — PASS: markup present in chat context, stripped from user-visible transcripts; voice output verified via session recordings
  • cue-cli e2e: Cartesia emotion showcase (42 turns) — ~38 distinct emotions emitted correct leading <emotion value="..."/> tags; speed/volume/break/spell tags verified; no error events (one CLI-side 300s RPC timeout on a single giant run — driver limitation, not pipeline)

Port of livekit/agents#6116 (expressive mode) to agents-js.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cf15251

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 36 packages
Name Type
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

github-advanced-security[bot]

This comment was marked as resolved.

@toubatbrian toubatbrian requested a review from tinalenguyen July 7, 2026 14:36
@toubatbrian toubatbrian changed the title feat(tts): LLM-driven TTS prosody via provider-specific markup tags feat(tts): Expressiveness Mode Jul 7, 2026
@toubatbrian toubatbrian changed the title feat(tts): Expressiveness Mode [WIP] feat(tts): Expressiveness Mode Jul 7, 2026
toubatbrian and others added 2 commits July 8, 2026 11:02
CodeQL flagged js/polynomial-redos on XML_TAG_RE: the lazy [^>]*? and
trailing \s* both match whitespace, so unterminated input like
'<A' + '\t' * n backtracks polynomially (~18s at n=200k). Replace the
ambiguous body with a single [^>]* and detect self-closing tags on the
captured body instead. Matching behavior is unchanged.

Co-Authored-By: Oz <oz-agent@warp.dev>
@toubatbrian toubatbrian changed the title [WIP] feat(tts): Expressiveness Mode feat(tts): Expressiveness Mode Jul 8, 2026
@toubatbrian toubatbrian marked this pull request as ready for review July 8, 2026 03:28
@toubatbrian toubatbrian requested a review from a team as a code owner July 8, 2026 03:28
github-advanced-security[bot]

This comment was marked as resolved.

CodeQL still flagged the previous form: with [^>]* the body can absorb
'<', so unterminated input like '<A<A<A…' rescans to end-of-string from
every '<' (quadratic). [^<>] bounds each scan at the next '<'.

Co-Authored-By: Oz <oz-agent@warp.dev>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

The documented presets.CUSTOMER_SERVICE / presets.CASUAL API was defined
but never re-exported, so consumers couldn't reach it (the package only
exposes the root entrypoint). Export it as voice.presets and fix the doc
example accordingly.
Three fixes from PR review (each also present in the Python source; JS
deviates intentionally, verified against livekit/agents main):

- instructionsEqual: an Instructions with audio/text additions no longer
  compares equal to its bare common string. It renders differently, so
  treating them as equal made realtime session reuse skip instruction
  updates on handoff when the new agent added modality rules.

- Instructions.resolveTemplate: the produced audio/text variants are
  full template renders, not additions. render(modality) now returns
  the variant instead of appending it to common, which duplicated the
  entire template in the prompt.

- pipeline reply: base Instructions are re-rendered for the turn
  modality even when extra per-turn instructions are supplied, so a
  text turn no longer keeps audio-rendered base rules copied from the
  session chat context.
devin-ai-integration[bot]

This comment was marked as resolved.

u9g added 2 commits July 8, 2026 08:47
Port frontdesk example changes from livekit/agents#6116: expressive
CUSTOMER_SERVICE preset with inference gateway STT/LLM/TTS (inworld
tts-2), rewritten voice-first instructions, time-of-day onEnter
greeting via generateReply, and idle away-state nudge loop.
Relative .js import specifiers fail when running examples directly
under node type stripping (e.g. lk agent console). Switch to .ts
specifiers with rewriteRelativeImportExtensions so tsc still emits
.js in dist output.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +75 to +84
function cleanToOrig(cleanPos: number, tagSpans: [number, number][]): number {
let orig = cleanPos;
for (const [tagStart, tagEnd] of tagSpans) {
if (tagStart < orig) {
orig += tagEnd - tagStart;
} else {
break;
}
}
return orig;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 XML-aware tokenizer offset remapping assumes non-overlapping sorted tag spans

The cleanToOrig function at token_stream.ts:75-84 maps clean-text positions back to original positions by iterating through tag spans and accumulating their lengths. It assumes tag spans are sorted by position and non-overlapping (which is guaranteed by matchAll(XML_TAG_RE) on well-formed input). However, the function does a single forward pass: after adjusting orig for a tag, subsequent tags are compared against the adjusted orig. This means if a tag at position 10 (length 20) shifts orig from 15 to 35, a tag at position 25 would be skipped (25 < 35) even though it should contribute. This could cause incorrect offset mapping when multiple tags cluster near a sentence boundary. The tests cover the common cases (tags between sentences, wrapping tags), and the splitSentences off-by-one fix at sentence.ts:85-88 addresses a related boundary issue.

Open in Devin Review

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread agents/src/llm/index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 concatInstructions removed from public API — breaking change for external callers

The concatInstructions function was removed from the public exports at agents/src/llm/index.ts (previously line 49). This was a public API used to concatenate strings and Instructions while preserving both audio/text variants. The PR replaces it with the new Instructions.render() approach, but any external code calling concatInstructions will break at compile time. The concat method on Instructions and the asModality method were also removed. The changeset marks this as a patch version bump, but these are breaking API changes.

Open in Devin Review

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

u9g and others added 3 commits July 8, 2026 14:32
Port drive-thru example changes from livekit/agents#6116: expressive
CUSTOMER_SERVICE preset with inference gateway STT/LLM/TTS (inworld
tts-2), restructured COMMON_INSTRUCTIONS (outcome / voice / how-to-work
/ hard constraints), and idle away-state nudge loop.
Port survey example changes from livekit/agents#6116: expressive CASUAL
preset with inference gateway STT/LLM/TTS (inworld tts-2, multi-language
STT), and idle away-state nudge loop.
Port of livekit/agents examples/inference/agent.py (post-#6116): inference
gateway STT/LLM/TTS (inworld tts-2 CREATIVE), expressive CASUAL preset,
on_enter greeting, idle away-state nudge loop, and the playground RPC
controls (set_stt_model / set_llm_model / set_tts_model /
set_system_prompt / open_in_builder).

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +762 to +763
const pattern = new RegExp(`<(${tags.join('|')})\\b([^>]*[^/])\\s*>`, 'g');
return text.replace(pattern, '<$1$2/>');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Markup normalization silently skips bare self-closing tags without attributes, leaving them unfixed for the TTS

A tag written by the LLM without any attributes (e.g. <break>) is not converted to self-closing form (<break/>) by the normalization regex (normalizeMarkup at agents/src/tts/_provider_format.ts:762), so it passes through to the TTS provider as an opening tag with no closing tag.

Impact: If the LLM emits a bare <break> (or bare <sound>, <expression>, etc.) without attributes, the tag is sent unconverted to the TTS, which may misinterpret or reject it.

Regex requires at least one non-slash character in the tag body

The regex pattern is <(tag)\b([^>]*[^/])\s*>. The [^/] alternation requires at least one character that is not /. For a bare tag like <break>, the body between \b and > is empty — zero characters — so [^>]*[^/] cannot match (the [^/] needs one character). Tags with at least one attribute character (e.g. <break time="1s">) or even a trailing space (<break >) are matched correctly.

The fix is to make the body group optional or use a negative lookahead for the self-closing slash instead, e.g. <(tag)\b([^>]*?)(?<!/)\s*>.

Suggested change
const pattern = new RegExp(`<(${tags.join('|')})\\b([^>]*[^/])\\s*>`, 'g');
return text.replace(pattern, '<$1$2/>');
const pattern = new RegExp(`<(${tags.join('|')})\\b([^>]*?)(?<!/)\\s*>(?!.*<\\/\\1)`, 'g');
return text.replace(pattern, '<$1$2/>');
Open in Devin Review

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants