#[cfg(test)]
mod tests;
use std::path::Path;
use crate::app::Entry;
use crate::app::ToolStatus;
use crate::cli::WebSearchMode;
use crate::context::ContextSource;
use crate::internals;
use crate::providers::ProviderMessage;
use crate::skills;
use crate::skills::SkillMetadata;
use crate::tools;
use crate::tools::ToolDefinition;
use crate::utils;
use crate::utils::datetime;
use thndrs_agent::context::{ContextItem, ContextLedger};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
pub enum HistoryReuse {
Available,
#[default]
Unavailable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptFragment {
pub name: &'static str,
pub content: String,
}
impl PromptFragment {
pub fn new(name: &'static str, content: impl Into<String>) -> Self {
PromptFragment { name, content: content.into() }
}
}
#[derive(Clone, Debug)]
pub struct PromptBundle {
pub fragments: Vec<PromptFragment>,
pub environment: EnvironmentMetadata,
pub project_context: Vec<ContextSource>,
pub tool_catalog: Vec<ToolDefinition>,
pub available_skills: Vec<SkillMetadata>,
pub transcript_tail: Vec<Entry>,
pub user_turn: String,
pub history_reuse: HistoryReuse,
pub prev_context_hash: Option<u64>,
pub context_ledger: Option<ContextLedger>,
}
impl PromptBundle {
pub fn new(
cwd: &Path, model: &str, mode: WebSearchMode, context_sources: &[ContextSource], transcript: &[Entry],
user_turn: &str,
) -> PromptBundle {
PromptBundle::new_with_skills(cwd, model, mode, context_sources, &[], transcript, user_turn)
}
pub fn new_with_skills(
cwd: &Path, model: &str, mode: WebSearchMode, context_sources: &[ContextSource],
available_skills: &[SkillMetadata], transcript: &[Entry], user_turn: &str,
) -> PromptBundle {
let tool_catalog = tools::tool_definitions();
let transcript_tail = project_transcript_tail(transcript);
PromptBundle {
fragments: default_fragments(),
environment: EnvironmentMetadata::new(cwd, model, mode),
project_context: context_sources.to_vec(),
tool_catalog,
available_skills: available_skills.to_vec(),
transcript_tail,
user_turn: user_turn.to_string(),
history_reuse: HistoryReuse::default(),
prev_context_hash: None,
context_ledger: None,
}
}
pub fn with_tool_catalog(mut self, tool_catalog: Vec<ToolDefinition>) -> Self {
self.tool_catalog = tool_catalog;
self
}
pub fn with_context_ledger(mut self, ledger: ContextLedger) -> Self {
self.context_ledger = Some(ledger);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvironmentMetadata {
pub cwd: String,
pub model: String,
pub search_mode: WebSearchMode,
pub date: String,
}
impl EnvironmentMetadata {
pub fn new(cwd: &Path, model: &str, search_mode: WebSearchMode) -> Self {
EnvironmentMetadata {
cwd: cwd.display().to_string(),
model: model.to_string(),
search_mode,
date: datetime::rounded_date(),
}
}
}
pub fn default_fragments() -> Vec<PromptFragment> {
vec![
PromptFragment::new("base_identity", include_str!("fragments/base_identity.xml")),
PromptFragment::new("communication_style", include_str!("fragments/communication_style.xml")),
PromptFragment::new("action_model", include_str!("fragments/action_model.xml")),
PromptFragment::new("edit_guidance", include_str!("fragments/edit_guidance.xml")),
PromptFragment::new("action_safety", include_str!("fragments/action_safety.xml")),
PromptFragment::new("self_knowledge", include_str!("fragments/self_knowledge.xml")),
PromptFragment::new("web_source_guidance", include_str!("fragments/web_source_guidance.xml")),
]
}
pub fn render_system_prompt(bundle: &PromptBundle) -> String {
let mut parts: Vec<String> = bundle.fragments.iter().map(|f| f.content.clone()).collect();
parts.push(format!(
r#"<environment>
<workspace><![CDATA[{}]]></workspace>
<model><![CDATA[{}]]></model>
<search>{}</search>
<date>{}</date>
</environment>"#,
cdata(&bundle.environment.cwd),
cdata(&bundle.environment.model),
bundle.environment.search_mode.label(),
bundle.environment.date
));
let snapshot: internals::SelfKnowledgeSnapshot = bundle.into();
parts.push(snapshot.render_model_visible());
if let Some(ledger) = &bundle.context_ledger {
let projection = render_context_projection(ledger);
if !projection.is_empty() {
parts.push(projection);
}
} else if !bundle.project_context.is_empty() {
parts.push(render_legacy_project_context(bundle));
}
let available_skills = skills::format_available_skills(&bundle.available_skills);
if !available_skills.is_empty() {
parts.push(available_skills);
}
parts.join("\n\n")
}
fn render_legacy_project_context(bundle: &PromptBundle) -> String {
let mut context_lines = vec!["<project_context>".to_string()];
for source in &bundle.project_context {
let text_unchanged =
bundle.history_reuse == HistoryReuse::Available && bundle.prev_context_hash == Some(source.content_hash);
context_lines.push(" <source>".to_string());
context_lines.push(format!(
" <path><![CDATA[{}]]></path>",
cdata(&source.path.display().to_string())
));
context_lines.push(format!(" <scope><![CDATA[{}]]></scope>", cdata(&source.scope)));
context_lines.push(format!(" <hash>{}</hash>", source.content_hash));
context_lines.push(format!(" <truncated>{}</truncated>", source.truncated));
if text_unchanged {
context_lines.push(" <status>unchanged, text omitted</status>".to_string());
} else {
context_lines.push(format!(" <content><![CDATA[{}]]></content>", cdata(&source.content)));
}
context_lines.push(" </source>".to_string());
}
context_lines.push("</project_context>".to_string());
context_lines.join("\n")
}
fn render_context_projection(ledger: &ContextLedger) -> String {
use thndrs_agent::context::ContextItemKind as Kind;
let rendered: Vec<&ContextItem> = ledger
.items
.iter()
.filter(|item| {
item.visibility.is_rendered()
&& matches!(
item.kind,
Kind::ProjectInstruction | Kind::Summary | Kind::PinnedFile | Kind::Skill
)
})
.collect();
if rendered.is_empty() {
return String::new();
}
let mut out = String::from("<selected_context>\n");
for item in &rendered {
match item.kind {
Kind::ProjectInstruction => {
out.push_str(" <instruction>\n");
push_item_meta(&mut out, 4, item);
if let Some(content) = item.content.as_deref() {
out.push_str(&format!(" <content><![CDATA[{}]]></content>\n", cdata(content)));
}
out.push_str(" </instruction>\n");
}
Kind::Summary => {
out.push_str(" <compaction_summary>\n");
push_item_meta(&mut out, 4, item);
if let Some(content) = item.content.as_deref() {
out.push_str(&format!(" <content><![CDATA[{}]]></content>\n", cdata(content)));
}
out.push_str(" </compaction_summary>\n");
}
Kind::PinnedFile => {
out.push_str(" <pinned_handle>\n");
push_item_meta(&mut out, 4, item);
out.push_str(" </pinned_handle>\n");
}
Kind::Skill => {
out.push_str(" <skill>\n");
push_item_meta(&mut out, 4, item);
out.push_str(" </skill>\n");
}
_ => {}
}
}
out.push_str("</selected_context>");
out
}
fn push_item_meta(out: &mut String, indent: usize, item: &ContextItem) {
let pad = " ".repeat(indent);
out.push_str(&format!("{pad}<id>{}</id>\n", utils::escape_xml(&item.id)));
out.push_str(&format!("{pad}<label>{}</label>\n", utils::escape_xml(&item.label)));
out.push_str(&format!("{pad}<scope>{}</scope>\n", utils::escape_xml(&item.scope)));
if let Some(path) = item.source_path.as_ref() {
let path_str = path.display().to_string();
out.push_str(&format!("{pad}<path>{}</path>\n", utils::escape_xml(&path_str)));
}
if let Some(hash) = item.content_hash {
out.push_str(&format!("{pad}<hash>{hash}</hash>\n"));
}
}
pub fn lower_to_umans_messages(bundle: &PromptBundle) -> Vec<ProviderMessage> {
let mut messages = vec![ProviderMessage::user(&render_system_prompt(bundle))];
for entry in &bundle.transcript_tail {
match entry {
Entry::User { text } => messages.push(ProviderMessage::user(text)),
Entry::Agent { text, streaming: false, .. } => messages.push(ProviderMessage::assistant(text)),
Entry::Reasoning { text, streaming: false, .. } => messages.push(ProviderMessage::assistant(text)),
Entry::Tool { name, output, status, .. } if *status != ToolStatus::Running => {
let model_output = tools::ToolOutput::ok(name, output.clone()).model.lines;
messages.push(ProviderMessage::user(
&(match model_output.is_empty() {
true => format!("[tool: {name} — no output]"),
false => format!("[tool: {name}]\n{}", model_output.join("\n")),
}),
))
}
_ => (),
}
}
if !bundle.user_turn.is_empty() {
messages.push(ProviderMessage::user(&bundle.user_turn));
}
messages
}
pub fn render_tool_catalog(bundle: &PromptBundle) -> serde_json::Value {
tools::tool_catalog_schemas(&bundle.tool_catalog)
}
fn project_transcript_tail(transcript: &[Entry]) -> Vec<Entry> {
transcript
.iter()
.rev()
.take(20)
.filter(|e| match e {
Entry::User { .. } => true,
Entry::Agent { streaming: false, .. } => true,
Entry::Reasoning { streaming: false, .. } => true,
Entry::Tool { status, .. } => *status != ToolStatus::Running,
_ => false,
})
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn cdata(text: &str) -> String {
text.replace("]]>", "]]]]><![CDATA[>")
}