Skip to main content

turnframe_core/
prompt.rs

1//! Where prompt text comes from, and which prompt text produced a turn.
2//!
3//! A replayed turn can already say which workflow versions were in force, which
4//! schema the plan was validated against and which provider attempt answered.
5//! What it could not say is which instructions the model was given, because the
6//! prompt reached the provider layer as an opaque string.
7//!
8//! This module closes that gap with two things and nothing else:
9//!
10//! * a [`PromptRef`] — a name, a version and the digest of the exact text —
11//!   which [`ReplayRecord`](crate::replay::ReplayRecord) and
12//!   [`ProviderAttemptRecord`](crate::replay::ProviderAttemptRecord) carry, so
13//!   the record can be falsified rather than merely believed;
14//! * a [`PromptSource`], the trait an application implements or configures when
15//!   it wants the runtime to fetch prompt text instead of using the
16//!   instructions compiled into the library.
17//!
18//! # Why the contract is here and the implementations are not
19//!
20//! The trait lives in the contract crate so the runtime can accept an
21//! `Arc<dyn PromptSource>` without depending on any particular way of getting
22//! prompts. Concrete sources — prompts compiled in from the adopter's
23//! repository, a bounded cache, an optional registry adapter — live in
24//! `turnframe-prompt`, which nothing in this crate and nothing in the runtime
25//! depends on.
26//!
27//! That split is the point rather than a tidiness preference. **Prompt
28//! management must never become a framework dependency.** An application that
29//! configures no source pulls no source code, reaches no network, and runs on
30//! the instructions in its own binary; the dependency graph says so, not the
31//! documentation.
32//!
33//! ```
34//! use turnframe_core::prompt::PromptRef;
35//!
36//! let reference = PromptRef::of_text("interpret.system", "v3", "Answer with the plan only.");
37//! assert!(reference.matches("Answer with the plan only."));
38//! assert!(!reference.matches("Answer with anything you like."));
39//! ```
40
41use std::fmt;
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46use crate::hash::Digest;
47use crate::ids::string_id;
48
49string_id! {
50    /// Name of a prompt, as the application asks for it.
51    ///
52    /// An opaque label owned by the adopter — `"interpret.system"`,
53    /// `"narrate.transition"` — never free user text, so it is safe in a log
54    /// line and in a metric label.
55    PromptName
56}
57
58string_id! {
59    /// Version of a prompt, as its source names it.
60    ///
61    /// Deliberately a string and not a number, because the two sources that
62    /// matter spell it differently: a registry hands out ordinal versions
63    /// (`"3"`), and a repository-served prompt derives its version from the
64    /// content of the file, so nobody has to maintain a counter. Comparing two
65    /// versions for equality is meaningful; ordering them is not, which is why
66    /// this type is not asked to be an integer.
67    PromptVersion
68}
69
70/// Which prompt text produced a turn.
71///
72/// Small and cheap to clone: three owned strings, no borrowed lifetimes and no
73/// content. It is safe to log and to store, because a name, a version and a
74/// digest are identifiers and never the prompt body.
75///
76/// # The obligation
77///
78/// A reference is only worth what its hash is worth. Whatever produces one owes
79/// the reader this: **[`hash`](Self::hash) is the digest of the text that was
80/// actually used**, computed with [`Digest::of_bytes`] over the UTF-8 bytes.
81/// Build one with [`PromptRef::of_text`] and the obligation is met by
82/// construction; build one field by field and [`PromptRef::matches`] is how a
83/// test proves it.
84#[derive(
85    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
86)]
87pub struct PromptRef {
88    /// The name the application asked for.
89    pub name: PromptName,
90    /// The version its source returned.
91    pub version: PromptVersion,
92    /// BLAKE3 digest of the UTF-8 bytes of the prompt text.
93    pub hash: Digest,
94}
95
96impl PromptRef {
97    /// A reference to `text`, with its hash computed here so it cannot be wrong.
98    #[must_use]
99    pub fn of_text(
100        name: impl Into<PromptName>,
101        version: impl Into<PromptVersion>,
102        text: &str,
103    ) -> Self {
104        Self {
105            name: name.into(),
106            version: version.into(),
107            hash: Digest::of_bytes(text.as_bytes()),
108        }
109    }
110
111    /// A reference assembled from parts, for a source that already holds a
112    /// digest it trusts.
113    ///
114    /// Prefer [`PromptRef::of_text`]; this constructor exists for the
115    /// deserialization-shaped cases, and it is the caller who then owes the
116    /// obligation described on the type.
117    #[must_use]
118    pub fn new(
119        name: impl Into<PromptName>,
120        version: impl Into<PromptVersion>,
121        hash: Digest,
122    ) -> Self {
123        Self {
124            name: name.into(),
125            version: version.into(),
126            hash,
127        }
128    }
129
130    /// Returns `true` when `text` is the text this reference stands for.
131    #[must_use]
132    pub fn matches(&self, text: &str) -> bool {
133        self.hash == Digest::of_bytes(text.as_bytes())
134    }
135
136    /// A short, stable label for a metric or a span attribute: `name@version`.
137    ///
138    /// Without the digest, which is 64 characters and belongs in the record
139    /// rather than in a dashboard legend.
140    #[must_use]
141    pub fn label(&self) -> String {
142        format!("{}@{}", self.name, self.version)
143    }
144}
145
146impl fmt::Display for PromptRef {
147    /// `name@version#12345678` — the label plus the first eight hex characters
148    /// of the digest, which is enough to tell two texts apart in a log line.
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        let short: String = self.hash.as_str().chars().take(8).collect();
151        write!(f, "{}@{}#{short}", self.name, self.version)
152    }
153}
154
155/// Which version of a prompt is wanted.
156///
157/// The three arms are three different postures towards a moving registry, and
158/// they are worth choosing between deliberately:
159///
160/// * [`Latest`](Self::Latest) takes whatever the source considers current. Fine
161///   for a compiled-in source, where "current" means "what is in this binary".
162/// * [`Label`](Self::Label) takes the version somebody has marked — `production`
163///   being the usual one. Convenient, and it means an edit in the registry
164///   changes a running system without a deploy, which is the trade being made.
165/// * [`Version`](Self::Version) pins. A registry edit then cannot change what
166///   the system does; only a deploy can, and the pinned version is what the
167///   replay record cites. This is the arm a deployment that cares about
168///   reproducibility uses.
169///
170/// Deliberately **not** `#[non_exhaustive]`, unlike most growable enums in this
171/// crate. A [`PromptSource`] has to decide what every selector means for it,
172/// and a wildcard arm decides by accident: it would serve *something* for a
173/// selector the source has never heard of, which is the one behaviour a pin
174/// exists to rule out. Adding an arm here should break every source and make
175/// each of them say what it does.
176#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
177pub enum PromptSelector {
178    /// Whatever the source considers current.
179    #[default]
180    Latest,
181    /// The version carrying this deployment label.
182    Label(String),
183    /// This exact version, and no other.
184    Version(PromptVersion),
185}
186
187impl PromptSelector {
188    /// A label selector.
189    #[must_use]
190    pub fn label(label: impl Into<String>) -> Self {
191        Self::Label(label.into())
192    }
193
194    /// A pinned-version selector.
195    #[must_use]
196    pub fn version(version: impl Into<PromptVersion>) -> Self {
197        Self::Version(version.into())
198    }
199
200    /// A short stable rendering, used as part of a cache key and safe to log.
201    #[must_use]
202    pub fn as_key(&self) -> String {
203        match self {
204            Self::Latest => "latest".to_owned(),
205            Self::Label(label) => format!("label:{label}"),
206            Self::Version(version) => format!("version:{version}"),
207        }
208    }
209
210    /// Returns `true` when this selector names one immutable version, so what
211    /// comes back cannot change under a running system.
212    #[must_use]
213    pub const fn is_pinned(&self) -> bool {
214        matches!(self, Self::Version(_))
215    }
216}
217
218impl fmt::Display for PromptSelector {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.write_str(&self.as_key())
221    }
222}
223
224/// A prompt, and the reference that names it.
225///
226/// The two fields are private and the only constructors compute or verify the
227/// hash, so a value of this type carries the [`PromptSource`] promise by
228/// construction: `reference().hash` *is* the digest of `text()`.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct LoadedPrompt {
231    reference: PromptRef,
232    text: String,
233}
234
235impl LoadedPrompt {
236    /// Builds a prompt and derives its reference from the text.
237    ///
238    /// This is the constructor a source should reach for: there is no way to
239    /// get the hash wrong.
240    #[must_use]
241    pub fn new(
242        name: impl Into<PromptName>,
243        version: impl Into<PromptVersion>,
244        text: impl Into<String>,
245    ) -> Self {
246        let text = text.into();
247        let reference = PromptRef::of_text(name, version, &text);
248        Self { reference, text }
249    }
250
251    /// Builds a prompt from a reference somebody else produced, checking that
252    /// the reference is telling the truth.
253    ///
254    /// # Errors
255    ///
256    /// [`PromptError::InconsistentReference`] when `reference.hash` is not the
257    /// digest of `text`.
258    pub fn from_parts(reference: PromptRef, text: impl Into<String>) -> Result<Self, PromptError> {
259        let text = text.into();
260        if !reference.matches(&text) {
261            return Err(PromptError::InconsistentReference {
262                name: reference.name,
263            });
264        }
265        Ok(Self { reference, text })
266    }
267
268    /// The prompt text.
269    #[must_use]
270    pub fn text(&self) -> &str {
271        &self.text
272    }
273
274    /// The reference that names this text.
275    #[must_use]
276    pub fn reference(&self) -> &PromptRef {
277        &self.reference
278    }
279
280    /// The name it was loaded under.
281    #[must_use]
282    pub fn name(&self) -> &PromptName {
283        &self.reference.name
284    }
285
286    /// The version it came back at.
287    #[must_use]
288    pub fn version(&self) -> &PromptVersion {
289        &self.reference.version
290    }
291
292    /// Consumes the value and returns the text alone.
293    #[must_use]
294    pub fn into_text(self) -> String {
295        self.text
296    }
297
298    /// Consumes the value and returns both halves.
299    #[must_use]
300    pub fn into_parts(self) -> (PromptRef, String) {
301        (self.reference, self.text)
302    }
303}
304
305/// A prompt could not be loaded.
306///
307/// Two rules shape this family, and they are the two the provider layer
308/// follows.
309///
310/// **Nothing here can print a secret.** No variant has a field a credential, a
311/// response body or a prompt text could be put into: every field is a name, a
312/// label, a version or a short stable code. `Display` is therefore safe in a
313/// log line.
314///
315/// **Every failure says whether it is worth trying again**, through
316/// [`is_transient`](Self::is_transient), because that is the difference between
317/// a cache that serves a held version for a moment and one that serves it
318/// forever.
319///
320/// The family grows as sources learn to distinguish more, so a downstream match
321/// needs a wildcard arm.
322#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
323#[non_exhaustive]
324pub enum PromptError {
325    /// The source has no prompt by that name.
326    #[error("no prompt named {name}")]
327    NotFound {
328        /// The name that was asked for.
329        name: PromptName,
330    },
331    /// The prompt exists, but not at the version that was pinned.
332    ///
333    /// A deployment that pins a version wants this to be loud: quietly serving
334    /// a different version is exactly the failure pinning exists to prevent.
335    #[error("prompt {name} has no version {version}")]
336    VersionNotFound {
337        /// The name.
338        name: PromptName,
339        /// The version that was pinned and is not there.
340        version: PromptVersion,
341    },
342    /// The prompt exists, but no version carries that label.
343    #[error("prompt {name} has no version labelled {label}")]
344    LabelNotFound {
345        /// The name.
346        name: PromptName,
347        /// The label. A deployment label such as `production`, never user text.
348        label: String,
349    },
350    /// The credentials were rejected.
351    ///
352    /// The credential itself is deliberately absent from every field, so this
353    /// value cannot leak it however it is rendered.
354    #[error("the prompt source rejected the credentials")]
355    Unauthorized,
356    /// The credentials are valid but not entitled to this project or prompt.
357    #[error("the credentials are not entitled to prompt {name}")]
358    Forbidden {
359        /// The name that was asked for.
360        name: PromptName,
361    },
362    /// The source asked for the request to be made again later.
363    #[error("the prompt source rate-limited the request")]
364    RateLimited,
365    /// The request never got a complete answer, or the answer was a server
366    /// error.
367    #[error("the prompt source could not be reached: {code}")]
368    Transport {
369        /// A short stable code — `"timeout"`, `"connect"`, `"status_503"` —
370        /// never a URL, a body or a header.
371        code: &'static str,
372    },
373    /// The answer arrived and was not the shape the source expects.
374    ///
375    /// Never carries the payload: a malformed answer from a registry is exactly
376    /// the kind of thing that turns out to contain a token.
377    #[error("the prompt source returned an answer this adapter cannot read: {code}")]
378    Malformed {
379        /// A short stable code naming what was wrong — `"not_json"`,
380        /// `"missing_field"`, `"version_not_a_number"`.
381        code: &'static str,
382    },
383    /// The source can reach the prompt but this adapter will not serve it.
384    ///
385    /// Used where guessing would be worse than refusing: a chat-shaped prompt
386    /// that would have to be flattened into one string to fit this trait, or an
387    /// endpoint whose current API shape an adapter cannot verify.
388    #[error("the prompt source cannot serve this prompt here: {reason}")]
389    Unsupported {
390        /// A short stable code naming what is unsupported.
391        reason: &'static str,
392    },
393    /// The source returned a reference whose hash is not the hash of the text
394    /// it returned, which breaks the obligation [`PromptSource`] states.
395    #[error("prompt {name} came back with a reference that does not match its text")]
396    InconsistentReference {
397        /// The name that was asked for.
398        name: PromptName,
399    },
400}
401
402impl PromptError {
403    /// A stable snake-case label, for a metric or a span attribute.
404    #[must_use]
405    pub const fn code(&self) -> &'static str {
406        match self {
407            Self::NotFound { .. } => "not_found",
408            Self::VersionNotFound { .. } => "version_not_found",
409            Self::LabelNotFound { .. } => "label_not_found",
410            Self::Unauthorized => "unauthorized",
411            Self::Forbidden { .. } => "forbidden",
412            Self::RateLimited => "rate_limited",
413            Self::Transport { .. } => "transport",
414            Self::Malformed { .. } => "malformed",
415            Self::Unsupported { .. } => "unsupported",
416            Self::InconsistentReference { .. } => "inconsistent_reference",
417        }
418    }
419
420    /// Returns `true` when the same request could plausibly succeed later
421    /// without anybody changing anything.
422    ///
423    /// A transport fault and a rate limit are transient. A missing name, a
424    /// pinned version that does not exist, a rejected credential and a
425    /// malformed answer are not: they need an edit somewhere before the answer
426    /// changes.
427    #[must_use]
428    pub const fn is_transient(&self) -> bool {
429        matches!(self, Self::RateLimited | Self::Transport { .. })
430    }
431}
432
433/// Where prompt text comes from.
434///
435/// # The obligation
436///
437/// An implementation owes exactly one thing beyond returning text:
438///
439/// > **The text it returns must be the text whose hash the reference carries.**
440///
441/// Everything downstream rests on that. The replay record stores the reference
442/// and not the text, so an auditor answering "which prompt produced this turn"
443/// is trusting the digest; a source that returns one text and a reference to
444/// another turns the whole record into decoration. Returning
445/// [`LoadedPrompt::new`] discharges the obligation by construction, and
446/// [`LoadedPrompt::from_parts`] checks it when a reference arrives from
447/// somewhere else.
448///
449/// Two smaller expectations follow from it:
450///
451/// * A source must not rewrite, trim or template-expand the text after hashing
452///   it. Normalize first, hash second.
453/// * A pinned [`PromptSelector::Version`] that the source cannot honour is
454///   [`PromptError::VersionNotFound`], never a quiet substitution.
455///
456/// # Object safety
457///
458/// The trait is dyn-compatible through [`async_trait`], because the runtime
459/// holds one as `Arc<dyn PromptSource>` and because a cache decorates an
460/// arbitrary one. Implementations are `Send + Sync` for the same reason.
461///
462/// ```
463/// use std::sync::Arc;
464/// use turnframe_core::prompt::{
465///     LoadedPrompt, PromptError, PromptName, PromptSelector, PromptSource,
466/// };
467///
468/// #[derive(Debug)]
469/// struct OnePrompt(&'static str);
470///
471/// #[async_trait::async_trait]
472/// impl PromptSource for OnePrompt {
473///     async fn load(
474///         &self,
475///         name: &PromptName,
476///         _selector: &PromptSelector,
477///     ) -> Result<LoadedPrompt, PromptError> {
478///         Ok(LoadedPrompt::new(name.clone(), "v1", self.0))
479///     }
480///
481///     fn describe(&self) -> &'static str {
482///         "one-prompt"
483///     }
484/// }
485///
486/// // The coercion is the assertion: a source is usable behind a shared
487/// // pointer, which is how the runtime holds one.
488/// let shared: Arc<dyn PromptSource> = Arc::new(OnePrompt("Say hello."));
489/// assert_eq!(shared.describe(), "one-prompt");
490///
491/// let name = PromptName::from("greeting");
492/// let loading = shared.load(&name, &PromptSelector::Latest);
493/// // Awaiting it needs an executor; that the future exists at all is what
494/// // dyn-compatibility buys.
495/// drop(loading);
496/// ```
497#[async_trait::async_trait]
498pub trait PromptSource: Send + Sync + fmt::Debug {
499    /// Loads one prompt.
500    ///
501    /// # Errors
502    ///
503    /// See [`PromptError`]. A source that reaches a network reports a failure
504    /// to reach it as [`PromptError::Transport`], so a caller can tell "this
505    /// prompt does not exist" from "this registry is down".
506    async fn load(
507        &self,
508        name: &PromptName,
509        selector: &PromptSelector,
510    ) -> Result<LoadedPrompt, PromptError>;
511
512    /// A short stable label naming the kind of source, for logs and metrics.
513    ///
514    /// `"file"`, `"cache"`, `"langfuse"`. Never a URL and never a credential.
515    fn describe(&self) -> &'static str;
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn a_reference_built_from_text_matches_that_text_and_no_other() {
524        let reference = PromptRef::of_text("interpret.system", "v1", "one");
525        assert!(reference.matches("one"));
526        assert!(!reference.matches("two"));
527        assert!(!reference.matches("one "));
528    }
529
530    #[test]
531    fn the_same_text_under_two_names_hashes_the_same_but_is_a_different_reference() {
532        let a = PromptRef::of_text("a", "v1", "shared");
533        let b = PromptRef::of_text("b", "v1", "shared");
534        assert_eq!(a.hash, b.hash);
535        assert_ne!(a, b);
536    }
537
538    #[test]
539    fn rendering_carries_identifiers_and_never_the_text() {
540        let reference = PromptRef::of_text("interpret.system", "v7", "SECRET INSTRUCTIONS");
541        let rendered = reference.to_string();
542        assert!(rendered.starts_with("interpret.system@v7#"));
543        assert!(!rendered.contains("SECRET"));
544        assert_eq!(reference.label(), "interpret.system@v7");
545        assert!(!format!("{reference:?}").contains("SECRET"));
546    }
547
548    #[test]
549    fn a_reference_round_trips() {
550        let reference = PromptRef::of_text("n", "v", "text");
551        let json = serde_json::to_string(&reference).unwrap();
552        assert_eq!(serde_json::from_str::<PromptRef>(&json).unwrap(), reference);
553    }
554
555    #[test]
556    fn a_loaded_prompt_carries_the_hash_of_its_own_text() {
557        let loaded = LoadedPrompt::new("interpret.system", "v1", "Answer with the plan only.");
558        assert!(loaded.reference().matches(loaded.text()));
559        assert_eq!(loaded.name().as_str(), "interpret.system");
560        assert_eq!(loaded.version().as_str(), "v1");
561        assert_eq!(loaded.clone().into_text(), "Answer with the plan only.");
562        let (reference, text) = LoadedPrompt::new("n", "v", "t").into_parts();
563        assert!(reference.matches(&text));
564    }
565
566    #[test]
567    fn a_reference_that_names_another_text_is_refused() {
568        let honest = PromptRef::of_text("n", "v", "the real text");
569        assert!(LoadedPrompt::from_parts(honest.clone(), "the real text").is_ok());
570        let error = LoadedPrompt::from_parts(honest, "a different text").unwrap_err();
571        assert_eq!(error.code(), "inconsistent_reference");
572    }
573
574    #[test]
575    fn selector_keys_are_distinct_stable_and_say_whether_they_pin() {
576        assert_eq!(PromptSelector::Latest.as_key(), "latest");
577        assert_eq!(
578            PromptSelector::label("production").as_key(),
579            "label:production"
580        );
581        assert_eq!(PromptSelector::version("7").as_key(), "version:7");
582        assert_ne!(
583            PromptSelector::label("7").as_key(),
584            PromptSelector::version("7").as_key()
585        );
586        assert_eq!(PromptSelector::default(), PromptSelector::Latest);
587        assert_eq!(PromptSelector::Latest.to_string(), "latest");
588        assert!(PromptSelector::version("7").is_pinned());
589        assert!(!PromptSelector::label("production").is_pinned());
590        assert!(!PromptSelector::Latest.is_pinned());
591    }
592
593    #[test]
594    fn every_error_variant_renders_identifiers_and_codes_only() {
595        let planted = "sk-live-0123456789abcdef";
596        let errors = [
597            PromptError::NotFound {
598                name: PromptName::from("interpret.system"),
599            },
600            PromptError::VersionNotFound {
601                name: PromptName::from("interpret.system"),
602                version: PromptVersion::from("7"),
603            },
604            PromptError::LabelNotFound {
605                name: PromptName::from("interpret.system"),
606                label: "production".to_owned(),
607            },
608            PromptError::Unauthorized,
609            PromptError::Forbidden {
610                name: PromptName::from("interpret.system"),
611            },
612            PromptError::RateLimited,
613            PromptError::Transport { code: "timeout" },
614            PromptError::Malformed { code: "not_json" },
615            PromptError::Unsupported {
616                reason: "chat_prompt",
617            },
618            PromptError::InconsistentReference {
619                name: PromptName::from("interpret.system"),
620            },
621        ];
622        for error in errors {
623            let rendered = format!("{error} {error:?}");
624            assert!(!rendered.contains(planted), "{rendered}");
625            assert!(!error.code().is_empty());
626        }
627    }
628
629    #[test]
630    fn only_a_transport_fault_and_a_rate_limit_are_worth_repeating() {
631        assert!(PromptError::RateLimited.is_transient());
632        assert!(PromptError::Transport { code: "connect" }.is_transient());
633        assert!(!PromptError::Unauthorized.is_transient());
634        assert!(
635            !PromptError::NotFound {
636                name: PromptName::from("x"),
637            }
638            .is_transient()
639        );
640    }
641}