use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use crate::{PromptContribution, PromptTemplate};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PromptFingerprint(u64);
impl PromptFingerprint {
fn from_hashable(value: impl Hash) -> Self {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
Self(hasher.finish())
}
fn write(self, state: &mut impl Hasher) {
self.0.hash(state);
}
}
#[derive(Clone, Debug)]
pub struct PromptContributionSet {
contributions: Arc<Vec<PromptContribution>>,
fingerprint: PromptFingerprint,
}
impl PromptContributionSet {
pub fn new(contributions: Vec<PromptContribution>) -> Self {
let contributions = Arc::new(merge_prompt_contributions(contributions));
let fingerprint = fingerprint_contributions(&contributions);
Self {
contributions,
fingerprint,
}
}
pub fn empty() -> Self {
Self::new(Vec::new())
}
pub fn as_arc(&self) -> Arc<Vec<PromptContribution>> {
Arc::clone(&self.contributions)
}
pub fn as_slice(&self) -> &[PromptContribution] {
&self.contributions
}
pub fn fingerprint(&self) -> PromptFingerprint {
self.fingerprint
}
}
impl Default for PromptContributionSet {
fn default() -> Self {
Self::empty()
}
}
#[derive(Clone, Debug)]
pub struct PromptBuildInput {
pub template: PromptTemplate,
pub template_fingerprint: PromptFingerprint,
pub execution_prompt: Arc<str>,
pub execution_prompt_fingerprint: PromptFingerprint,
pub tool_names: Arc<Vec<String>>,
pub tool_names_fingerprint: PromptFingerprint,
pub omitted_tool_count: usize,
pub contributions: PromptContributionSet,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct PromptContext {
#[serde(default)]
pub execution_prompt: Arc<str>,
pub tool_names: Arc<Vec<String>>,
pub omitted_tool_count: usize,
pub contributions: Arc<Vec<PromptContribution>>,
}
impl PromptContext {
pub fn has_tool(&self, tool_name: &str) -> bool {
self.tool_names.iter().any(|name| name == tool_name)
}
}
#[derive(Clone, Debug)]
pub struct PreparedPrompt {
pub context: PromptContext,
pub system_prompt: Arc<str>,
}
#[derive(Default)]
pub struct PromptCache {
inner: Mutex<Option<(u64, Arc<str>)>>,
}
impl PromptCache {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&self) {
if let Ok(mut guard) = self.inner.lock() {
*guard = None;
}
}
}
impl std::fmt::Debug for PromptCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromptCache").finish_non_exhaustive()
}
}
pub fn build_prompt(input: PromptBuildInput) -> PreparedPrompt {
build_prompt_cached(input, None)
}
pub fn build_prompt_cached(input: PromptBuildInput, cache: Option<&PromptCache>) -> PreparedPrompt {
let context = PromptContext {
execution_prompt: Arc::clone(&input.execution_prompt),
tool_names: Arc::clone(&input.tool_names),
omitted_tool_count: input.omitted_tool_count,
contributions: input.contributions.as_arc(),
};
let key = cache.map(|_| hash_prompt_inputs(&input, &context));
if let (Some(cache), Some(key)) = (cache, key)
&& let Some(cached) = cache.inner.lock().ok().and_then(|guard| {
guard
.as_ref()
.filter(|(k, _)| *k == key)
.map(|(_, v)| Arc::clone(v))
})
{
return PreparedPrompt {
context,
system_prompt: cached,
};
}
let system_prompt: Arc<str> = Arc::from(input.template.render(&context));
if let (Some(cache), Some(key)) = (cache, key)
&& let Ok(mut guard) = cache.inner.lock()
{
*guard = Some((key, Arc::clone(&system_prompt)));
}
PreparedPrompt {
context,
system_prompt,
}
}
pub fn prompt_template_fingerprint(template: &PromptTemplate) -> PromptFingerprint {
PromptFingerprint::from_hashable(template)
}
pub fn prompt_text_fingerprint(text: &str) -> PromptFingerprint {
PromptFingerprint::from_hashable(text)
}
pub fn prompt_tool_names_fingerprint(tool_names: &[String]) -> PromptFingerprint {
PromptFingerprint::from_hashable(tool_names)
}
fn hash_prompt_inputs(input: &PromptBuildInput, context: &PromptContext) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
input.template_fingerprint.write(&mut hasher);
input.execution_prompt_fingerprint.write(&mut hasher);
input.tool_names_fingerprint.write(&mut hasher);
context.omitted_tool_count.hash(&mut hasher);
input.contributions.fingerprint().write(&mut hasher);
hasher.finish()
}
fn fingerprint_contributions(contributions: &[PromptContribution]) -> PromptFingerprint {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for contribution in contributions {
contribution.slot.hash(&mut hasher);
contribution.priority.hash(&mut hasher);
contribution.title.hash(&mut hasher);
contribution.content.hash(&mut hasher);
}
PromptFingerprint(hasher.finish())
}
fn merge_prompt_contributions(contributions: Vec<PromptContribution>) -> Vec<PromptContribution> {
let mut merged = contributions
.into_iter()
.filter_map(normalize_contribution)
.collect::<Vec<_>>();
merged.sort_by(|left, right| {
slot_order(left.slot)
.cmp(&slot_order(right.slot))
.then(left.priority.cmp(&right.priority))
.then_with(|| left.title.cmp(&right.title))
.then_with(|| left.content.cmp(&right.content))
});
merged.dedup_by(|a, b| {
slot_order(a.slot) == slot_order(b.slot)
&& a.priority == b.priority
&& a.title.as_deref() == b.title.as_deref()
&& a.content == b.content
});
merged
}
fn normalize_contribution(mut contribution: PromptContribution) -> Option<PromptContribution> {
contribution.content = Arc::from(contribution.content.trim());
if contribution.content.is_empty() {
return None;
}
contribution.title = contribution
.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
.map(Arc::from);
Some(contribution)
}
fn slot_order(slot: crate::PromptSlot) -> usize {
match slot {
crate::PromptSlot::Intro => 0,
crate::PromptSlot::Execution => 1,
crate::PromptSlot::Guidance => 2,
crate::PromptSlot::ProjectInstructions => 3,
crate::PromptSlot::RuntimeContext => 4,
crate::PromptSlot::Environment => 5,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
PromptBuiltin, PromptContribution, PromptLayer, PromptSlot, PromptTemplate,
PromptTemplateEntry, PromptTemplateSection, default_prompt_template, resolve_prompt_layers,
};
fn input(
template: PromptTemplate,
execution_prompt: &str,
tool_names: Vec<String>,
omitted_tool_count: usize,
contributions: Vec<PromptContribution>,
) -> PromptBuildInput {
let execution_prompt: Arc<str> = Arc::from(execution_prompt);
let tool_names = Arc::new(tool_names);
PromptBuildInput {
template_fingerprint: prompt_template_fingerprint(&template),
template,
execution_prompt_fingerprint: prompt_text_fingerprint(&execution_prompt),
execution_prompt,
tool_names_fingerprint: prompt_tool_names_fingerprint(&tool_names),
tool_names,
omitted_tool_count,
contributions: PromptContributionSet::new(contributions),
}
}
#[test]
fn build_prompt_renders_template_from_merged_context() {
let prepared = build_prompt(input(
default_prompt_template(),
"Use tools.",
vec!["read_file".to_string()],
0,
vec![
PromptContribution::guidance("Repo", "Follow repo rules."),
PromptContribution::guidance("Repo", "Follow repo rules."),
PromptContribution::project_instructions("Be careful."),
],
));
assert!(prepared.system_prompt.contains("Use tools."));
assert!(prepared.system_prompt.contains("Follow repo rules."));
assert!(prepared.system_prompt.contains("Be careful."));
assert_eq!(prepared.context.contributions.len(), 2);
}
#[test]
fn build_prompt_cached_reuses_arc_on_identical_inputs() {
let cache = PromptCache::new();
let inputs = || {
input(
default_prompt_template(),
"Use tools.",
vec!["read_file".to_string()],
0,
vec![PromptContribution::guidance("Repo", "Follow repo rules.")],
)
};
let first = build_prompt_cached(inputs(), Some(&cache));
let second = build_prompt_cached(inputs(), Some(&cache));
assert!(Arc::ptr_eq(&first.system_prompt, &second.system_prompt));
}
#[test]
fn build_prompt_cached_renders_again_when_inputs_change() {
let cache = PromptCache::new();
let first = build_prompt_cached(
input(
default_prompt_template(),
"Use tools.",
vec!["read_file".to_string()],
0,
vec![],
),
Some(&cache),
);
let second = build_prompt_cached(
input(
default_prompt_template(),
"Use other tools.",
vec!["read_file".to_string()],
0,
vec![],
),
Some(&cache),
);
assert!(!Arc::ptr_eq(&first.system_prompt, &second.system_prompt));
assert_ne!(first.system_prompt, second.system_prompt);
}
fn template_with_text(text: &str) -> PromptTemplate {
PromptTemplate::new(vec![PromptTemplateSection::untitled(vec![
PromptTemplateEntry::text(text),
PromptTemplateEntry::builtin(PromptBuiltin::ExecutionInstructions),
])])
}
fn content(contributions: &[PromptContribution]) -> Vec<&str> {
contributions
.iter()
.map(|contribution| contribution.content.as_ref())
.collect()
}
#[test]
fn prompt_layers_use_later_template() {
let core = PromptLayer::with_template(template_with_text("core"));
let session = PromptLayer::with_template(template_with_text("session"));
let resolved = resolve_prompt_layers([&core, &session]);
let rendered = resolved.template.render(&PromptContext {
execution_prompt: Arc::from("execute"),
..PromptContext::default()
});
assert!(rendered.contains("session"));
assert!(!rendered.contains("core"));
}
#[test]
fn prompt_layers_append_inherited_slot_content() {
let core =
PromptLayer::new().with_contribution(PromptContribution::guidance("Core", "core"));
let session = PromptLayer::new()
.with_contribution(PromptContribution::guidance("Session", "session"));
let resolved = resolve_prompt_layers([&core, &session]);
assert_eq!(content(&resolved.contributions), vec!["core", "session"]);
}
#[test]
fn prompt_layers_clear_one_slot_without_touching_others() {
let core = PromptLayer::new()
.with_contribution(PromptContribution::guidance("Guide", "guide"))
.with_contribution(PromptContribution::project_instructions("project"));
let session = PromptLayer::new().with_cleared_slot(PromptSlot::Guidance);
let resolved = resolve_prompt_layers([&core, &session]);
assert_eq!(content(&resolved.contributions), vec!["project"]);
}
#[test]
fn prompt_layers_replace_slot_and_normalize_contribution_slot() {
let core =
PromptLayer::new().with_contribution(PromptContribution::guidance("Guide", "old"));
let session = PromptLayer::new().with_replaced_slot(
PromptSlot::Guidance,
[PromptContribution::project_instructions("new")],
);
let resolved = resolve_prompt_layers([&core, &session]);
assert_eq!(content(&resolved.contributions), vec!["new"]);
assert_eq!(resolved.contributions[0].slot, PromptSlot::Guidance);
}
#[test]
fn prompt_layers_allow_later_append_after_replace() {
let core =
PromptLayer::new().with_contribution(PromptContribution::guidance("Guide", "old"));
let session = PromptLayer::new().with_replaced_slot(
PromptSlot::Guidance,
[PromptContribution::guidance("New", "new")],
);
let turn =
PromptLayer::new().with_contribution(PromptContribution::guidance("Turn", "turn"));
let resolved = resolve_prompt_layers([&core, &session, &turn]);
assert_eq!(content(&resolved.contributions), vec!["new", "turn"]);
}
}