# Unified Tavern loader contract [中文](LOADER_CONTRACT.md) The current contract targets Tavern **2.4.1** and DSH `0.1.7-alpha.2`. It covers the RP session overlay (`selection.rp` + `rp:policy`), delegated subagents freezing their parent selection, named official sections, and schema 4 Trace references. DSH V4 uses `system/message` as the system-body authority while `request/header` retains config/tools; see the [migration contract](DSH_0.1.7_MIGRATION_en.md). ## Goals and ownership The loader is the only layer allowed to decide how current resources enter a DSH request. Format modules only interpret files. Use-case modules only manage resources. They must not register `systemPrompt`, mutate the Agent, copy conversation history, or write the model request themselves. ```text PresetModel ─────────────┐ CharacterCardModel ──────┼─> TavernProfileLoader ─> ordered Tavern sections UserModel ────────────────┤ │ WorldBookModel + matches ┘ │ ├─> agent/request call config SessionSelectionStore ─────────────────┘ ``` The root registers its logical profile at order 10, then expands it into `pmp-dsh-tavern:part:*` sections at the same array position. Import context retains `pmp-dsh-tavern:profile`; optional `rp:policy` remains at order 45. Preset `replace` keeps these Tavern contributions, including character/lore text and RP policy. ## Session policy The supported runtime is DSH 0.1.7-alpha.2 only. The awaited serial `agent/created` listener freezes or restores selection, reconstructs pending input from public own events, then initializes RP and its read-only sandbox before the Agent is exposed for requests. Initialization failure propagates to registration; it is not downgraded to a warning. Forks, resumes, and delegated agents use the same initialization boundary. The durable file is `session-selections.json` under the plugin data directory: ```json { "schemaVersion": 2, "sessions": { "": { "selection": { "presetId": "... or null", "characterCardId": "... or null", "userId": "... or null", "worldBookIds": [], "character": {}, "rp": { "active": false, "source": null, "followSuppressed": false, "sandboxBefore": null } }, "updatedAt": "2026-08-15T00:00:00.000Z" } } } ``` - Old `PresetStore.state.selectedId` remains the compatibility default for a session that is not yet bound. - The UI splits resource browsing from session binding. Changing the dropdown, importing, or creating only changes the current edit target. Only the explicit bind/unbind buttons write session selection. After UI/API carry `sessionId`, preset selection edits that session only and does not pollute other parallel sessions. - Presets share the same runtime boundary as character cards/users: changing a binding is rejected while the agent is running. Switching a preset on a session that already has history prompts that only later requests are affected. - A fresh ordinary session freezes the then-current default selection the first time an Agent uses it. - An ordinary fork copies the parent selection from `Session.header.parentSession`. After that, parent and child do not stay linked. - A subagent with `delegationDepth > 0` likewise freezes the parent selection (the same projection as **New chat with current settings**, including `rp`). After that, parent and child do not stay linked. Whether the delegated task is narrowed is decided by the parent agent's spawn prompt, not encoded in `rp:policy` or an empty selection. - RP is a session overlay on selection, not a DSH agent preset. `selection.rp` records whether it is locked. Optional `rp:policy` text lives in `rp-policy.json`. The default only says high-risk operations are locked. - When a resource is deleted, loader policy provides `clearResource(kind, id)` to clear every dangling selection. - Session id is only a JSON key, but it still goes through length/charset checks to avoid prototype keys and abnormal input. - Schema v1 is migrated in place to v2 on read. Character options keep only the three loader-known greeting/system/PHI fields. Resource ids and per-session world-book counts are bounded. - Default cap is 2,048 sessions (implementation hard cap 4,096) and 4 MiB of durable state. Old files over 8 MiB never enter `JSON.parse`. Writes validate on a copy and land atomically. Failure does not pollute in-memory state. - Selections are user intent that must not be dropped silently, so a full capacity rejects new entries instead of copying Trace's LRU. `deleteSession(id)` is an explicit reclaim seam; the current Host has no authoritative session-delete event that can trigger it automatically. ### Running-agent mutation boundary The current runtime protection is “explicit session-binding write protection”, not a global transaction lock over every resource change. Preset, character-card, user, and standalone world-book selection APIs query the matching agent before writing `SessionSelectionStore`. State `running` returns HTTP 409 with `PRESET_AGENT_RUNNING`, `CHARACTER_AGENT_RUNNING`, `USER_AGENT_RUNNING`, or `WORLD_BOOK_AGENT_RUNNING`. That stops a user from switching those four selections on that session through the normal bind buttons while a turn is executing. These indirect mutation entries are outside the same protection, so the current contract does not guarantee that “a running Tavern configuration is fully immutable”: - session-template/configuration apply can overwrite a target session with a complete selection. The normal UI targets a newly created blank session, but the API itself does not yet reject an already-running existing target. - Deleting a referenced preset, character card, user, or standalone world book calls `clearResource()` and clears one or more session selections without checking each affected agent. - Editing the body of a currently bound resource does not change the resource ID, but it does change what a later assembly reads. - Editing a user, preset, or character card's standalone world-book relations may change the effective world-book set of one or more sessions. Today only the relation and resource caps are validated. Those sessions are not checked for running. A completed system assembly is a frozen snapshot. Resource changes do not write back into durable history and cannot be described as having entered an earlier request. Concurrent edits around assembly retain a timing boundary: body edits and indirect relation changes only guarantee that a later assembly reads the state visible at that time. The current running-state rejection guarantee applies only to the four explicit selection writes above. ### Clean-session/template policy **New chat with current settings** and configuration templates copy only the selection projection above. They do not call an ordinary fork and do not read or write Session events. DSH mode obtains a real blank session id through public `uiWorkspace.connectWorkspace(workspaceId)`. Mowan first obtains the configured character from the same preview, then calls the shared playthrough-create controller: existing v2 session/directory/timeline/catalog atomic APIs create or reuse that character's authoritative empty playthrough; v1 apply then commits the complete configuration with one `SessionSelectionStore.set(targetId, completeSelection)` and rechecks that the root session's character binding matches the playthrough character. Both modes call public `uiWorkspace.openSession(targetId)` only after success. A configuration with no character card can create only an ordinary DSH session, not a playthrough. Templates are not rewritten silently when a resource is deleted. Dangling ids for preset, character/greeting, user, or standalone world book are returned as structured diagnostics from preview/apply and block create. A DSH create failure happens before the selection write. An atomic write failure does not publish in-memory state and does not navigate. Templates must not contain durable history, Trace, Inbox, turn/step, runtime state, or resource bodies. RP state is copied with the selection projection. ## Profile safety budget `TavernProfileLoader` applies a default 512 KiB UTF-8 cap to the combined Tavern profile text it generates. `limits.maxProfileBytes` may tighten or loosen it, but the implementation hard cap is 2 MiB. The world-book parser/store share a streaming structure guard before normalize: at most 10,000 entries per resource, depth 32, 100,000 nodes, 1 MiB per string, 1,024 characters per object key. The adapter additionally applies a 10,000-entry hard cap to standalone plus embedded books for this request. A resource that cannot fit is skipped and diagnosed. The combined budget is first-come by a deterministic composition order: session-explicit standalone books, user-bound standalone books, preset-bound standalone books, character-bound standalone books (stable ID de-duplication), then the card's embedded book. Each resource is reserved as a whole; if it cannot fit completely it is not scanned. So when earlier standalone books fill 10,000 entries, the embedded book is skipped with `WORLD_BOOK_RUNTIME_TOTAL_LIMIT`. That is an intentional safety/determinism policy, not a random omission. After those guards, the assembler considers at most the top-ranked 4,096 lore candidates and limits raw lore bodies to twice the profile budget before composing section text. A world book's own `tokenBudget` and `ignoreBudget` only decide ST-compatible candidates. They cannot change any Host hard cap. When a character card edits an embedded `character_book`, the shared structure guard and parser run first. Raw JSON/PNG import only confirms at the character-format layer that `character_book` is an object, then losslessly keeps unknown fields, and does not run the same depth/node/entry guard before disk. The 32 MiB import cap limits total input. When the loader first consumes it, `parseCharacterBook()` fails closed with `EMBEDDED_WORLD_BOOK_INVALID`; an unrunnable embedded book can enter the library, but it cannot enter the match-amplification path. If all content exceeds the cap, the assembler keeps the highest-ranked lore-entry prefix that still fits in the original candidate order and reports `TAVERN_PROFILE_LORE_LIMITED`. If it is still over after removing all lore, it throws `TAVERN_PROFILE_TOO_LARGE`. Preset, character fields, or user description are not truncated in the middle. ### Resource-to-world-book relationships User resources stay strictly `{ id, name, description }`. “User-bound world books” are an independent relation in unified loader policy's `user-world-book-bindings.json`. World-book ids are not written into the description body, and the `user` adapter does not run the matcher itself. Preset and character standalone world-book relations live in `resource-world-book-bindings.json`, partitioned by owner kind `preset` / `character`. IDs are not added to the ST preset or character-card original. A card's embedded `character_book` stays in the card and exports with it. External relations are a parallel, not exclusive, source. On each assembly the loader reads the current session's explicit `worldBookIds`, current user relation, current preset relation, and current character relation, de-duplicates by ID, and hands each standalone book to the shared adapter once. The card's embedded book then enters the same adapter. Audit keeps the original `sessionSelection` and `worldBookSelection` explicit/user/preset/character/effective/duplicate IDs. Each world-book resource summary's `bindingSources` keeps every hit source. The active view's `selection.worldBookIds` is the actually effective set, so the launcher can show the real combination. Unbinding or switching any one resource removes only that source and does not rewrite the others. Deleting a user, preset, or character card clears owner relations and the matching session selections. Deleting a standalone world book clears every relation and session-explicit reference, but does not modify any card's embedded book. The relation store bounds owner count, books per owner, state bytes, and safe reads, and atomically replaces after validating a copy. ## Adapter boundary `TavernProfileLoader` exposes one singleton adapter slot each for character, user, and world book: ```js loader.registerCharacterAdapter({ resolve({ selection, sessionId, agent, conversationText, context }) { return { character, diagnostics } }, }) loader.registerUserAdapter({ resolve({ selection, sessionId, agent, conversationText, context }) { return { user, diagnostics } }, }) loader.registerWorldBookAdapter({ resolve({ selection, sessionId, agent, conversationText, character, context }) { return { loreEntries, resources, diagnostics } }, }) ``` Constraints: - Adapters return already-normalized models. They do not return raw ST files as runtime instructions. - Adapters may read `conversationText` for matching. They do not write the session. - Adapters do not assemble the DSH system prompt. Final placement, override, de-duplication, and degradation diagnostics are decided by `compileTavernProfile()`. - Only one adapter per kind. A duplicate registration fails immediately so load order cannot decide behavior. - A disposer revokes only its own instance and supports HMR. The character adapter's minimum return model matches the character branch `CharacterCardModel`. The loader currently consumes `id/name/updatedAt/data`. The user adapter returns only `{ id, name, description }`. The world-book adapter at least normalizes activated items to `{ id|uid, content, position: "before"|"after" }`. ### Activation input contract The loader Host layer's only `PendingInputProjection` rebuilds the queue and this claimed batch from public `agent/inbox/spliced`, then gives adapters a structured, read-only `activationContext`: ```js { messages, // bounded { id, role, text, source } de-duplicated by stable id text, // matcher-compatible input under message/character caps metadata, // counts, truncation, claim event seq; no body / body hash } ``` `conversationText` is a compatibility field derived from `activationContext.text`, not a second state. Adapters consume that value only and do not subscribe to DSH events. Pending queue, claim/cancel decisions, one-shot consume on first assembly, turn-end cleanup, and de-duplication are exclusive to the loader. Default scan is the latest 128 messages / 64 KiB characters; hard caps are 1,024 messages and 1 MiB. Queue retention has its own message/character hard caps. Trace does not persist ActivationContext messages/text. Schema 4 also stores no assembly or source-body copy; it records verifiable official-history references and metadata only. ## Composition semantics ### Preset-only compatibility With no character, user, or activated lore, the loader calls `compilePresetForDsh()` directly. It emits enabled non-marker prompt bodies in their original order and preserves sampler mapping and macro behavior. Tavern adds no preset names, IDs, or XML-style identification wrappers to model-visible text; identical tags authored in resource bodies remain literal. A selected resource with no body produces no placeholder header. Official waterfall sections contain only `name` and `text`; prompt identifiers, requested roles, and resource provenance are kept in Tavern Trace metadata. ### Marker ownership After a character is selected or lore is activated, the unified assembler consumes these ST markers: | Marker / prompt | Loader source | Behavior | | --- | --- | --- | | `main` | character `systemPrompt` | May override the preset; supports `{{original}}`. `forbid_overrides` keeps the preset | | `worldInfoBefore` | active before lore | Emitted at that marker; stable fallback if the marker is missing | | `charDescription` | character description | Emitted once; fallback if the marker is missing | | `charPersonality` | character personality | Emitted once; fallback if the marker is missing | | `scenario` | character scenario | Emitted once; fallback if the marker is missing | | `personaDescription` | user description | Emitted once. `{{persona}}` may be an explicit placement. Missing marker/macro is diagnosed with a stable fallback | | `worldInfoAfter` | active after lore | Emitted at that marker; fallback if the marker is missing | | `dialogueExamples` | character message example | Emitted as ordinary approximate system text; provenance is recorded only in Tavern Trace metadata | | `chatHistory` | DSH Session | Marker is consumed but not emitted. DSH durable history is always the only authority | | `jailbreak` | character PHI | May override the preset; supports `{{original}}`. Position approximation is reported explicitly | Each character field, user description, and lore position is consumed at most once. `{{user}}` uses the name of the user bound to the current session. The user description may also use existing name/character macros. A user resource does not change the DSH Agent persona or identity section. Creator notes never enter the profile. Turning off the character system/PHI switch truly suppresses the field. It is not moved to fallback and sent by accident. ### Honest degradation - Greeting contributes plain system text only on the first-round generation. After the first real assistant reply it is no longer injected, and it is never forged as assistant history. - PHI lives in the Tavern system profile and is not claimed to sit strictly after all history. - The character module preserves depth-prompt role/depth fields. The current loader can only place the body in an ordinary system fallback and reports that real depth/role semantics were not executed. - `user`/`assistant` preset prompt roles remain reviewable in Tavern Trace `sources[].role` metadata. Official waterfall sections themselves contain only `name` and `text`; every actual contribution is still a system section, not a real history-message role. - System assembly scans durable history plus this step's claimed batch, so the current input of a single-step session can hit on the first request. The implementation does not use a too-late `agent/pre-step` and does not read a private Inbox. - Trace must describe the actually frozen assembly. It must not rerun the matcher on current input after `agent/pre-step` or `request/header` and label that result as having entered this turn's system. Because there is no same-step reassembly seam, the claimed batch must enter the matcher via the `agent/inbox/spliced` projection before the first assembly. ## Preset parameter admission and fallback Preset sampling remains stored as authored. At `agent/request`, the Host merges supported preset overrides and uses public `llm.resolveCallConfig` for preflight. Unsupported preset reasoning effort is omitted so the adapter can supply its default; no alternative effort name is guessed. After an explicit invalid/unsupported parameter rejection, `agent/request-error` may request a native DSH retry only before any output and while not aborted. It omits only an active preset override among `temperature`, `maxTokens`, `reasoningEffort`, and `stop`, each at most once, with at most four runtime retries. Other plugins' parameters and unrelated authentication, quota, or network errors are not fallback candidates. The prepared request is not mutated and its middleware continuation is not replayed. Trace records requested/effective parameter maps and each omission; effective values come from the actual `llm/stream` boundary after DSH defaults. ## Audit boundary `TavernProfileLoader.compile()` returns: - `systemText`: the Tavern profile proposed by the loader and expanded into named official system sections; - `callConfig`: preset fields proposed by assembly; admission/fallback may adjust the final request, whose authority is the official header and observed Trace effective values; - `resources`: summaries of preset, character, user, and world book resolved this run; - `diagnostics`: missing resources and placement degradation; - `audit`: session selection, resources, activated lore IDs, and SHA-256 fingerprint. For DSH V4 sessions, official `system/message` and context `user/message` are authoritative for prompt bodies, while `request/header` stores final tools and call config. V4 producer sources use `system-prompt` for system messages and `runtime-context` with `form: "snapshot"` for context messages. Retained historical readers understand older references and the released plugin source wrapper; that does not enable an older Host. The explicit offline upgrade verifies V3 body/error references against both generations. Pre-V3 `request-header-system` Trace references refuse V4 upgrade; see the migration contract. Loader audit helps UI/API explain why this input was produced. It replaces none of those official DSH events and adds no private session event. ## Adapter integration invariants The character-card adapter must: 1. store/API/UI maintain only character documents and selection intent; 2. migrate or bridge session selection onto `SessionSelectionStore.characterCardId/character`; 3. provide the model through `registerCharacterAdapter()`; 4. register no separate Host system section/profile assembler; 5. verify with marker, replace, dual-session, fork, subagent, and request-header tests. The world-book adapter must: 1. keep parser/matcher as pure logic; 2. return activated entries and diagnostics through `registerWorldBookAdapter()`; 3. card-embedded `characterBook` and standalone WorldBookModel enter the same matcher; do not reimplement; 4. do not register a system context/section yourself; 5. give deterministic tests for scan window, regex, recursion, and budget. The user-resource adapter must: 1. store documents are strictly `id/name/description`; reject avatar and unknown fields; 2. `SessionSelectionStore.userId` is the only session-binding owner; 3. hand off through `registerUserAdapter()` to the unified loader; do not register a Host seam; 4. marker, macros, fallback, and description de-duplication are executed only by `compileTavernProfile()`; 5. verify dual session, live switch, restart, unbind, delete cleanup, and a single final profile output. ## Current acceptance - Preset-only output and model parameters do not regress. - Two sessions may choose different presets, or explicitly choose “no preset”. - Ordinary forks and delegated subagents both inherit a snapshot of the parent selection (including RP state) and then stay unlinked. - Marker fill, character override, `{{original}}`, lore before/after, and chatHistory-not-copied all have unit tests. - `replace` removes only host system sections and keeps tools, contexts, variables, and the complete Tavern profile. - The API active view exposes selection/resources/diagnostics/audit and does not expose the full `compiledPrompt`. - Character-card APIs use unified session policy. Old `character-state.json` bindings migrate one way and are then cleared so unbind + restart cannot resurrect them. - V1/V2/V3 JSON and PNG cards can enter the profile through the adapter. Creator notes are not sent. - A card's embedded `character_book` uses the shared world-book parser/matcher. Hits enter the same profile. - Standalone world books are provided by the `world-book-library` use-case layer as a document store, CRUD/export API, and management UI. Per-session zero/one/many bindings still write loader-owned `SessionSelectionStore.worldBookIds`. - Selected standalone books and the card's embedded `characterBook` are run by the same world-book adapter through the same parser, matcher, rank, probability, and budget contract, then merged into the profile. - Deleting a standalone book clears dangling ids from every session through `clearResource("world-book", id)`. It does not read, modify, or unbind a character card or its embedded book. - User CRUD/API/UI, per-session single binding, `{{user}}`/`{{persona}}`, the `personaDescription` marker, and diagnostic fallback are wired. - The user panel can save zero or more standalone world books per user. The loader composes with stable order “session explicit first, user relation next”, de-duplicating. The active view/launcher/Trace publish the actually effective set. - User relations are independently atomically persisted, with caps on user count, books per user, state bytes, and safe reads. Deleting a user or world book clears the matching relation without deleting other users or session-explicit selections. - The world-book panel can save zero or more standalone world books for the current preset and character. Relations do not pollute ST originals. The loader composes with stable de-duplication in order “session, user, preset, character, character embedded”. - A bound card with no `character_book` can create an empty embedded book in the world-book panel and save it through the existing card embedded-book API. Standalone relations and the embedded book each keep their export semantics. - No local third-party preset, character-card, or world-book fixture has been copied.