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}