use crate::agent::prefix_fingerprint::hash_canonical;
use crate::types::ChannelVisibility;
use serde_json::json;
#[derive(Clone, Debug)]
pub(crate) struct CoreInputs {
pub base_template: String,
pub tool_roster: Vec<(String, String)>,
pub skills_catalog: Vec<(String, String, bool)>,
pub specialists: Vec<(String, String)>,
pub channel_rules: String,
pub persona: String,
pub core_profile: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ComponentHashes {
entries: [(&'static str, String); Self::COMPONENT_COUNT],
}
impl ComponentHashes {
const COMPONENT_COUNT: usize = 7;
pub(crate) fn aggregate(&self) -> String {
let concatenated: Vec<serde_json::Value> = self
.entries
.iter()
.map(|(_, hash)| serde_json::Value::String(hash.clone()))
.collect();
hash_canonical(&serde_json::Value::Array(concatenated))
}
pub(crate) fn diff(&self, other: &ComponentHashes) -> Vec<&'static str> {
self.entries
.iter()
.zip(other.entries.iter())
.filter_map(|((name, lhs), (_, rhs))| (lhs != rhs).then_some(*name))
.collect()
}
}
impl CoreInputs {
pub(crate) fn component_hashes(&self) -> ComponentHashes {
let mut tool_roster = self.tool_roster.clone();
tool_roster.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let mut skills_catalog = self.skills_catalog.clone();
skills_catalog.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let mut specialists = self.specialists.clone();
specialists.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
ComponentHashes {
entries: [
("base_template", hash_canonical(&json!(self.base_template))),
("tool_roster", hash_canonical(&json!(tool_roster))),
("skills_catalog", hash_canonical(&json!(skills_catalog))),
("specialists", hash_canonical(&json!(specialists))),
("channel_rules", hash_canonical(&json!(self.channel_rules))),
("persona", hash_canonical(&json!(self.persona))),
("core_profile", hash_canonical(&json!(self.core_profile))),
],
}
}
#[allow(dead_code)]
pub(crate) fn aggregate_hash(&self) -> String {
self.component_hashes().aggregate()
}
}
#[derive(Clone, Debug)]
pub(crate) struct CachedCore {
pub aggregate: String,
pub components: ComponentHashes,
pub bytes: String,
}
pub(crate) struct CoreCacheDecision {
pub bytes: String,
pub updated_entry: Option<CachedCore>,
pub changed: Vec<String>,
}
const INITIAL_COMPONENT: &str = "initial";
pub(crate) fn core_cache_decision(
prev: Option<&CachedCore>,
inputs: &CoreInputs,
) -> CoreCacheDecision {
let new_components = inputs.component_hashes();
let new_aggregate = new_components.aggregate();
if let Some(prev) = prev {
if prev.aggregate == new_aggregate {
return CoreCacheDecision {
bytes: prev.bytes.clone(),
updated_entry: None,
changed: Vec::new(),
};
}
}
let changed: Vec<String> = match prev {
Some(prev) => prev
.components
.diff(&new_components)
.into_iter()
.map(|s| s.to_string())
.collect(),
None => vec![INITIAL_COMPONENT.to_string()],
};
let bytes = render_core_prompt(inputs);
let updated_entry = CachedCore {
aggregate: new_aggregate,
components: new_components,
bytes: bytes.clone(),
};
CoreCacheDecision {
bytes,
updated_entry: Some(updated_entry),
changed,
}
}
fn render_specialists_block(entries: &[(String, String)]) -> String {
if entries.is_empty() {
return String::new();
}
let mut s = String::from(
"## Available Specialists\n\n\
When you delegate work with `spawn_agent`, pick the specialist that best matches the task. \
Sub-agents run in an isolated context window with the same tools you have, so keep the `mission` \
and `task` brief minimal — reference files by path rather than pasting contents, and skip prior \
tool output or conversation history the sub-agent does not need:\n\n",
);
for (name, description) in entries {
s.push_str("- `");
s.push_str(name);
s.push_str("`: ");
s.push_str(description);
if !description.ends_with('.') {
s.push('.');
}
s.push('\n');
}
s.push_str(
"\nOmit the `specialist` argument to let the agent infer the right kind from the mission/task text.",
);
s
}
pub(crate) fn render_core_prompt(inputs: &CoreInputs) -> String {
let mut specialists = inputs.specialists.clone();
specialists.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let specialists_block = render_specialists_block(&specialists);
let mut out = if specialists_block.is_empty() {
inputs.base_template.clone()
} else if let Some(idx) = inputs.base_template.find("## Tools") {
let (head, tail) = inputs.base_template.split_at(idx);
format!("{head}{specialists_block}\n\n{tail}")
} else {
format!("{}\n\n{specialists_block}", inputs.base_template)
};
if !inputs.channel_rules.is_empty() {
out.push_str("\n\n");
out.push_str(&inputs.channel_rules);
}
if !inputs.core_profile.is_empty() {
out.push_str("\n\n");
out.push_str(&inputs.core_profile);
}
if !inputs.skills_catalog.is_empty() {
let mut skills_catalog = inputs.skills_catalog.clone();
skills_catalog.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let enabled_skills: Vec<_> = skills_catalog
.iter()
.filter(|(_, _, enabled)| *enabled)
.collect();
if !enabled_skills.is_empty() {
out.push_str("\n\n## Available Skills\n");
for (name, description, _enabled) in &enabled_skills {
out.push_str("- **");
out.push_str(name);
out.push_str("**: ");
out.push_str(description);
out.push('\n');
}
}
}
out
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn assemble_core_inputs(
user_role: crate::types::UserRole,
channel_ctx: &crate::types::ChannelContext,
persona: String,
tool_roster: Vec<(String, String)>,
skills_catalog: Vec<(String, String, bool)>,
specialists: Vec<(String, String)>,
channel_rules: String,
core_profile: String,
) -> CoreInputs {
let specialists = if channel_ctx.visibility == ChannelVisibility::PublicExternal {
Vec::new()
} else {
specialists
};
let tool_roster = if user_role != crate::types::UserRole::Owner {
Vec::new()
} else {
tool_roster
};
CoreInputs {
base_template: persona.clone(),
tool_roster,
skills_catalog,
specialists,
channel_rules,
persona,
core_profile,
}
}
#[cfg(test)]
pub(crate) fn test_core_inputs() -> CoreInputs {
CoreInputs {
base_template: "T".into(),
tool_roster: vec![("b".into(), "{}".into()), ("a".into(), "{}".into())],
skills_catalog: vec![
("s2".into(), "d2".into(), true),
("s1".into(), "d1".into(), true),
],
specialists: vec![("x".into(), "dx".into()), ("a".into(), "da".into())],
channel_rules: "R".into(),
persona: "P".into(),
core_profile: "CP".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::prefix_fingerprint::TASK_CONTEXT_TAIL_MARKER;
#[test]
fn core_prompt_renders_identically_for_identical_inputs() {
let inputs = test_core_inputs();
let a = render_core_prompt(&inputs);
let b = render_core_prompt(&inputs);
assert_eq!(a, b, "core render must be deterministic");
assert!(
!a.contains("[Current Date & Time]"),
"timestamp belongs to the tail"
);
assert!(!a.contains(TASK_CONTEXT_TAIL_MARKER));
}
#[test]
fn core_prompt_is_order_insensitive_for_unordered_inputs() {
let mut inputs = test_core_inputs();
let a = render_core_prompt(&inputs);
inputs.skills_catalog.reverse();
inputs.specialists.reverse();
assert_eq!(a, render_core_prompt(&inputs));
}
#[test]
fn component_hash_is_order_insensitive_for_unordered_inputs() {
let a = test_core_inputs();
let mut b = a.clone();
b.tool_roster.reverse();
b.skills_catalog.reverse();
assert_eq!(a.component_hashes(), b.component_hashes());
assert_eq!(a.aggregate_hash(), b.aggregate_hash());
}
#[test]
fn core_profile_changes_aggregate_hash() {
let a = test_core_inputs();
let mut b = a.clone();
b.core_profile = "New CP".into();
assert_ne!(a.aggregate_hash(), b.aggregate_hash());
assert_eq!(
a.component_hashes().diff(&b.component_hashes()),
vec!["core_profile"]
);
}
#[test]
fn changed_component_is_named() {
let a = test_core_inputs();
let mut b = a.clone();
b.skills_catalog.push(("s3".into(), "d3".into(), true));
let diff = a.component_hashes().diff(&b.component_hashes());
assert_eq!(diff, vec!["skills_catalog"]);
}
#[test]
fn render_core_prompt_skips_disabled_skills() {
let inputs = CoreInputs {
base_template: "T".into(),
tool_roster: vec![],
skills_catalog: vec![
("enabled_skill".into(), "does something".into(), true),
("disabled_skill".into(), "should not appear".into(), false),
],
specialists: vec![],
channel_rules: String::new(),
persona: "P".into(),
core_profile: String::new(),
};
let out = render_core_prompt(&inputs);
assert!(
out.contains("enabled_skill"),
"enabled skill must appear in the output"
);
assert!(
!out.contains("disabled_skill"),
"disabled skill must NOT appear in the output"
);
}
#[test]
fn render_core_prompt_splices_specialists_before_tools_anchor() {
let inputs = CoreInputs {
base_template: "Intro\n\n## Tools\nuse them".into(),
tool_roster: vec![],
skills_catalog: vec![],
specialists: vec![("researcher".into(), "Deep research tasks".into())],
channel_rules: String::new(),
persona: "P".into(),
core_profile: String::new(),
};
let out = render_core_prompt(&inputs);
let specialists_pos = out
.find("## Available Specialists")
.expect("## Available Specialists block must be present");
let tools_pos = out
.find("## Tools")
.expect("## Tools anchor must be present");
assert!(
specialists_pos < tools_pos,
"## Available Specialists (at {}) must appear before ## Tools (at {})",
specialists_pos,
tools_pos
);
}
#[test]
fn core_cache_decision_hit_on_unchanged_inputs() {
let inputs = test_core_inputs();
let first = core_cache_decision(None, &inputs);
assert!(
!first.changed.is_empty(),
"initial miss must name a component"
);
assert_eq!(first.changed, vec![INITIAL_COMPONENT.to_string()]);
let entry = first.updated_entry.expect("initial miss stores an entry");
let second = core_cache_decision(Some(&entry), &inputs);
assert!(
second.changed.is_empty(),
"unchanged inputs must be a cache HIT (changed empty)"
);
assert!(second.updated_entry.is_none(), "HIT must not re-store");
assert_eq!(second.bytes, entry.bytes, "HIT reuses bytes verbatim");
}
#[test]
fn core_cache_decision_names_skills_catalog_on_toggle() {
let inputs = test_core_inputs();
let first = core_cache_decision(None, &inputs);
let entry = first.updated_entry.expect("initial miss stores an entry");
let mut toggled = inputs.clone();
toggled.skills_catalog[0].2 = !toggled.skills_catalog[0].2;
let second = core_cache_decision(Some(&entry), &toggled);
assert!(!second.changed.is_empty(), "a skill toggle must be a MISS");
assert_eq!(
second.changed,
vec!["skills_catalog".to_string()],
"exactly one component named, and it is skills_catalog"
);
assert_ne!(second.bytes, entry.bytes, "toggle yields new core bytes");
assert!(second.updated_entry.is_some(), "MISS replaces the entry");
}
#[test]
fn core_cache_decision_query_independent() {
use crate::types::{ChannelContext, UserRole};
let channel_ctx = ChannelContext::private("telegram");
let persona = "You are a helpful assistant.\n\n## Tools\nuse them".to_string();
let tool_roster = vec![("terminal".to_string(), "{}".to_string())];
let skills_catalog = vec![("s1".to_string(), "d1".to_string(), true)];
let specialists = vec![("researcher".to_string(), "Deep research".to_string())];
let channel_rules = "rules".to_string();
let core_profile = "profile".to_string();
let inputs_a = assemble_core_inputs(
UserRole::Owner,
&channel_ctx,
persona.clone(),
tool_roster.clone(),
skills_catalog.clone(),
specialists.clone(),
channel_rules.clone(),
core_profile.clone(),
);
let inputs_b = assemble_core_inputs(
UserRole::Owner,
&channel_ctx,
persona,
tool_roster,
skills_catalog,
specialists,
channel_rules,
core_profile,
);
assert_eq!(
inputs_a.aggregate_hash(),
inputs_b.aggregate_hash(),
"core aggregate must be query-independent"
);
let first = core_cache_decision(None, &inputs_a);
let entry = first.updated_entry.expect("initial miss stores an entry");
let second = core_cache_decision(Some(&entry), &inputs_b);
assert!(
second.changed.is_empty(),
"distinct queries with identical session-static inputs must HIT (query-independent core)"
);
assert_eq!(
second.bytes, entry.bytes,
"identical core bytes across queries"
);
}
#[test]
fn core_has_no_facts_component() {
let ch = test_core_inputs().component_hashes();
let names: Vec<&str> = ch.entries.iter().map(|(n, _)| *n).collect();
assert!(
!names.iter().any(|n| n.contains("fact")),
"facts are tail-side; no fact component may exist in the core hash: {names:?}"
);
}
#[test]
fn aggregate_hash_is_hash_of_component_hashes() {
let a = test_core_inputs();
let ch = a.component_hashes();
assert_eq!(
ch.entries.len(),
ComponentHashes::COMPONENT_COUNT,
"entry count must match COMPONENT_COUNT; add a new entry when adding a field"
);
let hash_strings: Vec<serde_json::Value> = ch
.entries
.iter()
.map(|(_, h)| serde_json::Value::String(h.clone()))
.collect();
let expected = hash_canonical(&serde_json::Value::Array(hash_strings));
assert_eq!(
a.aggregate_hash(),
expected,
"aggregate_hash must equal hash_canonical(JSON array of component hashes)"
);
}
}