Skip to content

System prompt assembly

web/src/lib/prompt.ts is the layer that turns a character + persona + history + new user text into the outbound messages[] array that goes to the LLM.

The entry point

assembleMessages({
  character,                // Tavern card (or null for direct-LLM chat)
  userName,                 // global display name
  persona,                  // optional Persona
  history,                  // previous messages: { role, content }[]
  newUserText,              // the user's freshly composed turn
}, samplerProfile)

Returns OutboundMessage[] ready to ship to /api/chat.

Two modes inside:

Mode Triggered when
Preset The active sampler profile carries prompts[] + prompt_order[] (a SillyTavern preset).
Fallback No preset, so synthesise a default frame from the card fields.

Preset mode

ST presets ship as a JSON describing:

  • prompts[] — every named prompt block (charDescription, scenario, dialogueExamples, jailbreak, personaDescription, plus user-defined ones).
  • prompt_order[] — the order to emit them, with chatHistory as a sentinel marker.

assembleViaPreset():

  1. Picks the active prompt_order (uses the default-character-id 100000 entry if present, else the first).
  2. Walks the order. For each entry that's enabled:
    • If it's the chatHistory marker, flush the system-buffer and switch to "after history" mode.
    • Else look up the prompt by id.
    • If the prompt is a marker (charDescription, scenario, …), resolve from the active card.
    • Else use the prompt's literal content.
  3. Concatenates the before-history blocks into a single role: system message, emits the history, concatenates the after-history blocks into a trailing role: system message, appends the new user turn.

Marker resolutions:

Marker Resolved to
charDescription card.data.description
charPersonality card.data.personality
scenario card.data.scenario
dialogueExamples card.data.mes_example
personaDescription "The user's name is <name>." [+ persona description]
worldInfoBefore / worldInfoAfter / enhanceDefinitions Empty (lorebooks not wired yet; tracked in smelt-vd8)

Fallback mode

No preset selected. assembleFallback() synthesises:

  1. Persona anchor (when a persona is attached): # The user / The user's name is X. [+ description].
  2. Legacy system prompt from the card: either the card's own system_prompt if set, or a synthesised composition of description + personality + scenario + example dialogue + a formatting hint.
  3. History, with {{user}} / {{char}} substituted per message.
  4. Post-history instructions if the card has them (as a trailing role: system).
  5. New user message, substituted.

The formatting hint added to legacy fallback:

# Formatting
Wrap spoken dialogue in straight double-quotes: "like this".
Wrap actions, gestures, thoughts, and narration in asterisks: *like this*.
Use plain text for everything else. Do not use code blocks unless quoting code.

This is the convention the chat-render layer (md.ts) recognises with its custom speech token.

{{user}} and {{char}} substitution

substitute(text, userName, charName) replaces both {{user}} / {{User}} and {{char}} / {{Char}} (case-preserving in the literal markup, not the substitution).

userName comes from resolveIdentity():

  • Persona attached → persona.name (or userName if empty)
  • No persona → userName

charName comes from card.data.name (or empty for direct-LLM chat).

Context-size trimming

When the active sampler profile sets context_size, trimToBudget() drops oldest-non-sticky messages until the total estimated tokens fit. Sticky messages:

  • Everything with role: system
  • The last message (the user's freshly composed turn)

Token estimate is len(content) / 4 + 4 per message — coarse but conservative for English. Underestimates CJK and code; that's acceptable because the trim is a safety net, not a precise allocation.

Mid-chat persona swaps

The persona anchor is in the system prompt, not the chat history. Switching the persona mid-chat changes what the next outbound messages[] carries; the history (which references old {{user}} substitutions in card prose) stays. Because the anchor is explicit ("The user's name is now Alice"), the model reliably switches form of address even when the prior history was full of "you".

This is the fix landed in prompt.ts 5cn — earlier behaviour only emitted the persona anchor when the persona carried a description, so name-only personas didn't anchor the form of address.