Compose multi-message prompts
Composition is an explicit, ordered sequence of (Prompt, vars, variant?) entries that
resolves to a Vec<Message> / list[Message] / Message[] in append order. It is the
few-shot / system+user sequence builder.
There is no registry — each entry holds a Prompt object directly. There is deliberately
no fluent .chain() API (it cannot cross the PyO3/napi FFI boundary and collides with
Iterator::chain in Rust / standard JS idiom).
An entry is (prompt, vars, variant?) everywhere; only its spelling differs by language idiom:
Rust and Python pass them as positional args to append (Python also accepts (prompt, vars[, variant]) tuples in from_messages), while TypeScript takes a single CompositionEntry object
({ prompt, schema?, data, variant? }) — matching how render is shaped in each language.
The validate-then-render guarantee
Section titled “The validate-then-render guarantee”Validation is eager — at append time in Rust/Python, at append/fromMessages time in
TypeScript. An invalid entry is rejected immediately and nothing is stored. resolve() therefore
only ever sees fully-validated entries. If any entry fails at resolve (unknown variant,
strict-undefined reference), the whole call fails and no partial result is returned.
Building a composition
Section titled “Building a composition”//! Build a Composition by appending (prompt, vars, variant?) entries, then//! resolve it to an ordered Vec<Message>. Standalone://! `cargo run --example guides_composition_build`.
use garde::Validate;use prompting_press::{Composition, Prompt, PromptDefinition};use serde::Serialize;
#[derive(Serialize, Validate)]struct SysVars { #[garde(length(min = 1))] instructions: String,}
#[derive(Serialize, Validate)]struct UserVars { #[garde(length(min = 1))] query: String,}
fn main() -> Result<(), Box<dyn std::error::Error>> { // Build the two prompts inline from their shape, so the content is explicit. let sys_def: PromptDefinition = serde_json::from_value(serde_json::json!({ "name": "system-preamble", "role": "system", "body": "{{ instructions }}", "variables": { "instructions": { "type": "string", "trusted": true } } }))?; let user_def: PromptDefinition = serde_json::from_value(serde_json::json!({ "name": "user-turn", "role": "user", "body": "{{ query }}", "variables": { "query": { "type": "string", "trusted": false } } }))?;
let sys = Prompt::new(sys_def)?; let user = Prompt::new(user_def)?;
let mut comp = Composition::new(); comp.append( &sys, &SysVars { instructions: "Be concise.".into(), }, None, // variant = None → default arm )?; comp.append( &user, &UserVars { query: "What is Rust?".into(), }, None, )?;
let messages = comp.resolve()?; for m in &messages { println!("{}: {}", m.role, m.text); // "system: Be concise." // "user: What is Rust?" }
assert_eq!( messages .iter() .map(|m| (m.role.as_str(), m.text.as_str())) .collect::<Vec<_>>(), vec![("system", "Be concise."), ("user", "What is Rust?")], ); Ok(())}Composition::append<V>(&mut self, prompt: &Prompt, vars: &V, variant: Option<&str>) returns
Result<(), ConsumerError>. On a garde failure the entry is NOT stored.
Composition::resolve(&self) returns Result<Vec<Message>, ConsumerError>. Each Message
carries role: String and text: String. Composition::len() and is_empty() report the entry
count.
Rust builds a composition with new() + append() only — there is no bulk from_messages
constructor (that is the Python/TypeScript idiom); push each entry explicitly, as above.
"""Build a Composition by appending (Prompt, vars) entries, then resolve it toan ordered list of role-tagged messages. Standalone."""
from prompting_press import Composition, Promptfrom pydantic import BaseModel
class SysVars(BaseModel): instructions: str
class UserVars(BaseModel): query: str
# Build the two prompts inline from their shape, so the content is explicit.sys_prompt = Prompt( { "name": "system-preamble", "role": "system", "body": "{{ instructions }}", "variables": {"instructions": {"type": "string", "trusted": True}}, })user_prompt = Prompt( { "name": "user-turn", "role": "user", "body": "{{ query }}", "variables": {"query": {"type": "string", "trusted": False}}, })
comp = Composition()comp.append(sys_prompt, SysVars(instructions="Be concise."))comp.append(user_prompt, UserVars(query="What is Rust?"))
messages = comp.resolve()for m in messages: print(m.role, m.text) # "system" "Be concise." # "user" "What is Rust?"
assert [(m.role, m.text) for m in messages] == [ ("system", "Be concise."), ("user", "What is Rust?"),]append accepts a Pydantic model instance (already-constructed). Validation happens eagerly;
a PromptValidationError is raised and nothing is stored on failure.
"""append validates eagerly: a passing model instance is stored, but an invalidone raises PromptValidationError and NOTHING is stored (no partial state).Standalone."""
from prompting_press import Composition, Prompt, PromptValidationErrorfrom pydantic import BaseModel, Field
class SysVars(BaseModel): instructions: str
sys_prompt = Prompt( { "name": "system-preamble", "role": "system", "body": "{{ instructions }}", "variables": {"instructions": {"type": "string", "trusted": True}}, })user_prompt = Prompt( { "name": "user-turn", "role": "user", "body": "{{ query }}", "variables": {"query": {"type": "string", "trusted": False}}, })
# Successful append — model instance passes Pydantic validation:comp = Composition()comp.append(sys_prompt, SysVars(instructions="Be concise."))
# Failed append — the vars fail validation; nothing is stored:class StrictVars(BaseModel): query: str = Field(min_length=1)
# model_construct() bypasses Pydantic so the invalid model reaches append,# where validation runs — this is what raises PromptValidationError.invalid = StrictVars.model_construct(query="")raised = Falsetry: comp.append(user_prompt, invalid) # raises before storageexcept PromptValidationError as exc: raised = True print(exc.errors[0].field) # "query" print(exc.errors[0].code) # "validation" # comp is unchanged — the failed entry was never stored assert exc.errors[0].field == "query" assert exc.errors[0].code == "validation"
assert raised, "the invalid append must raise PromptValidationError"assert len(comp) == 1, "the rejected append must store nothing (no partial state)"Build a composition from a list of (prompt, vars) or (prompt, vars, variant) tuples. The
optional third element selects a variant of that entry’s prompt (omit it for the default arm):
"""from_messages builds a Composition from a list of (prompt, vars) or(prompt, vars, variant) tuples. All validation runs before any state is created.Standalone."""
from prompting_press import Composition, Promptfrom pydantic import BaseModel
class SysVars(BaseModel): instructions: str
class UserVars(BaseModel): query: str
sys_prompt = Prompt( { "name": "system-preamble", "role": "system", "body": "{{ instructions }}", "variables": {"instructions": {"type": "string", "trusted": True}}, })user_prompt = Prompt( { "name": "user-turn", "role": "user", "body": "{{ query }}", "variables": {"query": {"type": "string", "trusted": False}}, })
comp = Composition.from_messages( [ (sys_prompt, SysVars(instructions="Be concise.")), (user_prompt, UserVars(query="What is Rust?")), ])messages = comp.resolve()for m in messages: print(m.role, m.text) # "system" "Be concise." # "user" "What is Rust?"
assert [(m.role, m.text) for m in messages] == [ ("system", "Be concise."), ("user", "What is Rust?"),]import assert from "node:assert/strict";import { test } from "node:test";import { z } from "zod";import { Composition, Prompt } from "prompting-press";
const SysVars = z.object({ instructions: z.string().min(1) });const UserVars = z.object({ query: z.string().min(1) });
// Build the two prompts inline from their shape, so the content is explicit.const sysPrompt = new Prompt({ name: "system-preamble", role: "system", body: "{{ instructions }}", variables: { instructions: { type: "string", trusted: true } },});const userPrompt = new Prompt({ name: "user-turn", role: "user", body: "{{ query }}", variables: { query: { type: "string", trusted: false } },});
test("compose with fromMessages", () => { // fromMessages: all validation runs before any Composition state is created. const comp = Composition.fromMessages([ { prompt: sysPrompt, schema: SysVars, data: { instructions: "Be concise." } }, { prompt: userPrompt, schema: UserVars, data: { query: "What is Rust?" } }, ]);
const messages = comp.resolve(); for (const m of messages) { console.log(m.role, m.text); // "system" "Be concise." // "user" "What is Rust?" }
assert.deepEqual( messages.map((m) => [m.role, m.text]), [ ["system", "Be concise."], ["user", "What is Rust?"], ], );});In TypeScript an entry is a CompositionEntry object (the analogue of Rust/Python’s positional
args):
{ prompt: Prompt; schema?: ZodLikeSchema; // optional — present → safeParse(data) runs at append data: unknown; variant?: string;}append(entry) → void (not chainable).
Composition.fromMessages(entries) → Composition (all validation runs before any state is
created; the first failure aborts).
Message shape
Section titled “Message shape”{ role: string, text: string }role is taken from the Prompt’s definition ("system" / "user" / "assistant").
text is the rendered body. Composition uses no guard expansion — the RenderResult.guard field
is not accessible from composition; use individual Prompt.render calls to access the guard.
docs current as of 0.2.0