ADR-031: Tagged Prompt Snippet Library
- Status
-
Accepted
- Date
-
2026-06-10
- Authors
-
Netresearch DTT GmbH
Context
Consuming extensions — first nr_repurpose — assemble prompts from
recurring building blocks: a persona, a tone of voice, a target
audience, an image style, a layout instruction. Editors want to manage
these fragments centrally, once, instead of re-typing them into every
extension's own configuration.
The existing Prompt entity does not fit this need. It is
a heavyweight complete prompt: it binds a feature, carries model
parameters (temperature, max tokens, top-p), supports versioning with
parent/variant relations, and tracks usage performance. A persona like
"You are Nova, a friendly expert." has none of these concerns — it is a
fragment that only becomes a prompt when a consumer composes it with
its own instructions. Forcing fragments into Prompt
would either bloat every fragment record with irrelevant model fields
or fork the template semantics depending on a "fragment" flag.
A second question is how consumers select fragments. A fixed category
enum (like Task categories) would require an nr-llm release
every time a consuming extension introduces a new fragment kind, which
contradicts the goal of nr-llm being a shared foundation that consumers
extend without touching it.
Decision
Introduce a separate, lightweight Prompt entity
(table tx_nrllm_promptsnippet) next to — not on top of —
Prompt:
- Fragments, not templates. A snippet is identifier + name +
description + fragment text. No model parameters, no versioning, no
performance tracking.
Promptstays untouched.Template - Free-form CSV tags instead of a category enum. Snippets carry a
comma-separated
tagsfield. Consumers queryPrompt, which matches tags as exact, case-insensitive tokens —Snippet Repository:: find Active By Tag () stylenever matcheslifestyle. The tag vocabulary is a convention between editors and consumers (established so far:audience,tone_of_voice,persona,layout,style), documented in the TCA field description and the administration guide. New fragment kinds need no nr-llm release. - JSON metadata side-channel. An optional
metadataJSON object carries consumer-specific settings (e.g.{"voice": "nova"}on persona snippets so speech features can pick a matching TTS voice).getreturnsMetadata Array () []for empty or invalid JSON — bad editor input must never break a consumer. - Composition stays in nr-llm.
Promptrenders an ordered label-to-snippet map into labeled prompt blocks (Snippet Composer LABEL:+ fragment text, blank-line separated), so all consumers produce uniformly structured prompt sections. - Editing via FormEngine. The backend module gets a "Snippets" list following the established Providers/Models/Tasks pattern; create/edit links into FormEngine, no custom forms.
Amendment (2026-08-09): a configuration selects snippets by tag
Until this amendment the library had no reader in a production
prompt. The only consumers were Run
and the codec that rehydrates it, and Run is
constructed nowhere but the tool playground — so outside the playground
the whole snippet system was inert, and the "consuming extension"
of point 2 above was the only way a snippet ever reached a model.
ADR-139 names a tagged snippet as the supported way to
attach editorial context to a request. That needs a selection an
operator can make. tx_nrllm_configuration.snippet_tags is it: a CSV
of tags whose items lists the tags the snippet records
actually carry, so the vocabulary stays consumer-owned and a new
fragment kind still needs no nr_llm release.
The selection is composed into the effective system prompt, not into
extra system messages. Configuration appends the
labeled blocks to the system_prompt that
Configuration has merged, behind the
configuration's own prompt. One insertion point reaches chat,
completion, streaming and the agent loop, because every
configuration-driven entry point on Llm builds its
options there.
The alternative — rendering each snippet as its own leading system
message, the shape the playground uses — was rejected. It only works in
the playground because the loop bakes the configuration's system prompt
ahead of the snippet messages first. Anywhere else a snippet system
message would be the first system message in the list, which is exactly
the condition under which Message
leaves the list alone: the configuration's own system prompt would be
dropped from the run, silently and with no error. The characterisation
tests added with ADR-139 pin that behaviour.
These details follow from the code:
- Dedup is by snippet identifier.
Promptloads all active snippets and filters in PHP, so a snippet carrying two selected tags comes back from both lookups and would otherwise be composed twice.Snippet Repository:: find Active By Tag () - An unknown tag is empty, not an error — the free-tag model has no referential integrity by design, so a typo degrades to "no snippets".
- The tool playground reads the same composed value. Its bake site
in
Toolgoes through the same resolver, so a previewed transcript is the transcript a live run sends. A forced snippet the configuration already selects by tag is composed once, not twice: the resolver's identifier dedup never sees the forced list, so the bake site skips a forced block its composed prompt already contains.Loop Service:: assemble () - Hiding a snippet takes it out of every configuration. The
repository ignores enable fields on purpose — the backend module lists
hidden records — so
Configurationis where a hidden record is dropped.Snippet Resolver is_activeremains the operational switch;hiddenis the editorial one, and both now keep a snippet out of a production prompt. This is the one place in the extension wherehiddendecides a runtime outcome, because it is the one place where an editor's list-module action would otherwise keep shipping text to a provider. - The composed block counts against the context window. The prompt
is prepended after
Contexthas run, so both callers (Window Manager Interface:: fit () ToolandLoop Service Conversation) hand the composed prompt toService fit()instead of letting it re-readLlm— otherwise the budget of ADR-107 is short by exactly the snippet block.Configuration:: get System Prompt ()
What this does not change: a caller-supplied system message still suppresses the configuration's system prompt — and with it the snippet block — because per-call precedence is decided before this composition is read. That is the pre-existing rule, not a new one.
Consequences
- Editors manage personas, tones, audiences, styles, and layouts once, centrally; every consuming extension reads the same library.
- The free-tag model keeps nr-llm release-independent from consumer vocabulary — at the cost of no referential integrity: a typo in a tag silently yields an empty query result. The documented convention and the tag badges in the list view mitigate this.
- Token matching is implemented over the CSV field in PHP, not SQL
LIKE, guaranteeing exact-token semantics on every database platform. The snippet library is small (tens of records), so loading active snippets for tag filtering is not a performance concern. - Two prompt-related entities now coexist. The split is intentional (template = complete prompt, snippet = fragment) and documented here, in the administration guide, and in both entities' PHPDoc.
- Since the 2026-08-09 amendment an operator can attach snippets to a configuration without writing any code, and every request made with that configuration carries them — including requests from consuming extensions that know nothing about snippets.