use crate::{instructions::InstructionFile, skills::SkillDiscovery};
use anyhow::Context;
use std::{env, path::Path};
const SYSTEM_FRAGMENT: &str = "system.md";
const TOOLS_FRAGMENT: &str = "tools.md";
const CODE_MODE_FRAGMENT: &str = "codemode.md";
const SKILLS_FRAGMENT: &str = "skills.md";
const COMPACT_FRAGMENT: &str = "compact.md";
const DEFAULT_SYSTEM_TEMPLATE: &str = include_str!("../../prompts/system.md");
const DEFAULT_TOOLS_TEMPLATE: &str = include_str!("../../prompts/tools.md");
const DEFAULT_CODE_MODE_TEMPLATE: &str = include_str!("../../prompts/codemode.md");
const DEFAULT_SKILLS_TEMPLATE: &str = include_str!("../../prompts/skills.md");
const DEFAULT_COMPACT_TEMPLATE: &str = include_str!("../../prompts/compact.md");
#[derive(Clone, Copy)]
pub(crate) struct SessionPromptContext<'a> {
pub(crate) session_id: Option<&'a str>,
pub(crate) cwd: &'a Path,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SessionPromptVariable {
SessionId,
Cwd,
}
impl SessionPromptVariable {
fn value(&self, context: SessionPromptContext<'_>) -> String {
match self {
Self::SessionId => context.session_id.unwrap_or("unspecified").to_string(),
Self::Cwd => context.cwd.display().to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionPromptBinding {
range: std::ops::Range<usize>,
variable: SessionPromptVariable,
}
pub(super) fn bind_session_prompt(
prompt: &str,
bindings: &[SessionPromptBinding],
context: SessionPromptContext<'_>,
) -> (String, Vec<SessionPromptBinding>) {
let mut rendered = String::with_capacity(prompt.len());
let mut rebound = Vec::with_capacity(bindings.len());
let mut cursor = 0;
for binding in bindings {
rendered.push_str(&prompt[cursor..binding.range.start]);
let start = rendered.len();
rendered.push_str(&binding.variable.value(context));
rebound.push(SessionPromptBinding {
range: start..rendered.len(),
variable: binding.variable.clone(),
});
cursor = binding.range.end;
}
rendered.push_str(&prompt[cursor..]);
(rendered, rebound)
}
pub(super) fn build_system_prompt_with_prompt_dir_and_subagents(
prompt_dir: Option<&Path>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
subagents_section: Option<&str>,
code_mode_enabled: bool,
context: SessionPromptContext<'_>,
) -> anyhow::Result<(String, Vec<SessionPromptBinding>)> {
let sections = build_system_prompt_sections_with_prompt_dir_and_subagents(
prompt_dir,
instructions,
skills,
subagents_section,
code_mode_enabled,
context,
)?;
let mut prompt = String::new();
let mut bindings = Vec::new();
for section in sections {
let offset = prompt.len();
bindings.extend(section.session_variables.into_iter().map(|mut binding| {
binding.range.start += offset;
binding.range.end += offset;
binding
}));
prompt.push_str(§ion.content);
}
Ok((prompt, bindings))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemPromptSection {
pub(crate) title: String,
pub(crate) content: String,
pub(crate) session_variables: Vec<SessionPromptBinding>,
}
pub(crate) fn build_system_prompt_sections_with_prompt_dir_and_subagents(
prompt_dir: Option<&Path>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
subagents_section: Option<&str>,
code_mode_enabled: bool,
context: SessionPromptContext<'_>,
) -> anyhow::Result<Vec<SystemPromptSection>> {
let defaults = PromptTemplateDefaults::bundled();
let values = TemplateValues::from_skills(skills, context);
build_system_prompt_sections_from_templates_with_values(
prompt_dir,
&defaults,
instructions,
subagents_section,
code_mode_enabled,
&values,
)
}
pub(crate) fn load_compact_prompt(
prompt_dir: Option<&Path>,
context: SessionPromptContext<'_>,
) -> anyhow::Result<String> {
let values = TemplateValues {
skills_list: String::new(),
runtime_facts: RuntimePromptFacts::detect(),
session_context: context,
};
render_fragment(
&load_fragment(prompt_dir, COMPACT_FRAGMENT, DEFAULT_COMPACT_TEMPLATE)?,
&values,
)
.map(|(content, _)| content)
}
fn build_system_prompt_sections_from_templates_with_values(
prompt_dir: Option<&Path>,
defaults: &PromptTemplateDefaults<'_>,
instructions: &[InstructionFile],
subagents_section: Option<&str>,
code_mode_enabled: bool,
values: &TemplateValues,
) -> anyhow::Result<Vec<SystemPromptSection>> {
let mut sections = Vec::new();
push_prompt_section(
&mut sections,
"System instructions",
render_fragment(
&load_fragment(prompt_dir, SYSTEM_FRAGMENT, defaults.system)?,
values,
)?,
);
push_prompt_section(
&mut sections,
"Tools",
render_fragment(
&load_fragment(prompt_dir, TOOLS_FRAGMENT, defaults.tools)?,
values,
)?,
);
if code_mode_enabled {
push_prompt_section(
&mut sections,
"Code mode",
render_fragment(
&load_fragment(prompt_dir, CODE_MODE_FRAGMENT, defaults.code_mode)?,
values,
)?,
);
}
push_prompt_section(
&mut sections,
"Subagents",
(
subagents_section.unwrap_or_default().to_string(),
Vec::new(),
),
);
push_prompt_section(
&mut sections,
"Skills",
render_fragment(
&load_fragment(prompt_dir, SKILLS_FRAGMENT, defaults.skills)?,
values,
)?,
);
push_instruction_sections(&mut sections, instructions);
sections
.last_mut()
.expect("instruction rendering always creates a section")
.content
.push('\n');
Ok(sections)
}
fn load_fragment(
prompt_dir: Option<&Path>,
fragment_name: &'static str,
default_template: &str,
) -> anyhow::Result<PromptFragment> {
if let Some(prompt_dir) = prompt_dir {
let path = prompt_dir.join(fragment_name);
if path.exists() {
let content = crate::prompt_file::read_prompt_file(&path, 256 * 1024, false)
.with_context(|| {
format!(
"failed to read prompt template fragment '{fragment_name}' at {}",
path.display()
)
})?
.text;
return Ok(PromptFragment {
name: fragment_name,
content,
});
}
}
Ok(PromptFragment {
name: fragment_name,
content: default_template.to_string(),
})
}
fn render_fragment(
fragment: &PromptFragment,
values: &TemplateValues<'_>,
) -> anyhow::Result<(String, Vec<SessionPromptBinding>)> {
let mut rendered = String::with_capacity(fragment.content.len());
let mut bindings = Vec::new();
let mut rest = fragment.content.as_str();
loop {
let Some(open) = rest.find("{{") else {
if let Some(close) = rest.find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{}': unexpected '}}}}' at byte {}",
fragment.name,
close
);
}
rendered.push_str(rest);
return Ok((rendered, bindings));
};
if let Some(close) = rest[..open].find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{}': unexpected '}}}}' at byte {}",
fragment.name,
close
);
}
rendered.push_str(&rest[..open]);
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
anyhow::bail!(
"malformed prompt template variable in '{}': missing closing '}}}}' for token starting at byte {}",
fragment.name,
open
);
};
let token = &after_open[..close];
let variable = match token {
"session_id" => Some(SessionPromptVariable::SessionId),
"cwd" => Some(SessionPromptVariable::Cwd),
_ => None,
};
if let Some(variable) = variable {
let start = rendered.len();
rendered.push_str(&variable.value(values.session_context));
bindings.push(SessionPromptBinding {
range: start..rendered.len(),
variable,
});
rest = &after_open[close + 2..];
continue;
}
let replacement = match token.trim() {
"SKILLS_LIST" if token == "SKILLS_LIST" => values.skills_list.as_str(),
"operating_system" if token == "operating_system" => {
values.runtime_facts.operating_system.as_str()
}
"terminal_environment" if token == "terminal_environment" => {
values.runtime_facts.terminal_environment.as_str()
}
supported @ ("SKILLS_LIST"
| "operating_system"
| "terminal_environment"
| "session_id"
| "cwd") => {
anyhow::bail!(
"malformed prompt template variable in '{}': use '{{{{{supported}}}}}' without extra whitespace",
fragment.name
);
}
other => anyhow::bail!(
"unsupported prompt template variable in '{}': '{{{{{other}}}}}'; supported variables are {}",
fragment.name,
supported_system_variables()
),
};
rendered.push_str(replacement);
rest = &after_open[close + 2..];
}
}
fn push_prompt_section(
sections: &mut Vec<SystemPromptSection>,
title: impl Into<String>,
(content, mut session_variables): (String, Vec<SessionPromptBinding>),
) {
let trim_from = session_variables
.last()
.map_or(0, |binding| binding.range.end);
let content = &content[..trim_from + content[trim_from..].trim_end().len()];
if content.is_empty() {
return;
}
let mut content = content.to_string();
if !sections.is_empty() {
content.insert_str(0, "\n\n");
for binding in &mut session_variables {
binding.range.start += 2;
binding.range.end += 2;
}
}
sections.push(SystemPromptSection {
title: title.into(),
content,
session_variables,
});
}
fn push_instruction_sections(
sections: &mut Vec<SystemPromptSection>,
instructions: &[InstructionFile],
) {
if instructions.is_empty() {
push_prompt_section(
sections,
"Instructions",
(
"No AGENTS.md instruction files were discovered.".to_string(),
Vec::new(),
),
);
return;
}
for (index, instruction) in instructions.iter().enumerate() {
let mut content = String::new();
if index == 0 {
if !sections.is_empty() {
content.push_str("\n\n");
}
content.push_str("<Additional-Context-Files>\n");
content.push_str(
" These files are automatically injected and MUST ALWAYS be remembered and considered in every action, do NOT read these files again:\n",
);
}
content.push_str(&format!(
" <Context File={}>\n{}\n </Context>\n",
instruction.path.display(),
instruction.content
));
if index + 1 == instructions.len() {
content.push_str("</Additional-Context-Files>");
}
sections.push(SystemPromptSection {
title: format!("Instructions · {}", instruction.path.display()),
content,
session_variables: Vec::new(),
});
}
}
fn render_skills_list(skills: &SkillDiscovery) -> String {
if skills.skills.is_empty() {
return "- none".to_string();
}
skills
.skills
.iter()
.map(|(name, skill)| {
skill
.frontmatter
.get("description")
.map(String::as_str)
.map(str::trim)
.filter(|description| !description.is_empty())
.map_or_else(
|| format!("- {name}"),
|description| format!("- {name}: {description}"),
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn supported_system_variables() -> &'static str {
"'{{SKILLS_LIST}}', '{{operating_system}}', '{{terminal_environment}}', '{{session_id}}', and '{{cwd}}'"
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RuntimePromptFacts {
operating_system: String,
terminal_environment: String,
}
impl RuntimePromptFacts {
fn detect() -> Self {
Self {
operating_system: operating_system_value(env::consts::OS),
terminal_environment: terminal_environment_from_process(),
}
}
}
fn operating_system_value(target_os: &str) -> String {
match target_os {
"macos" => "macOS".to_string(),
"linux" => "Linux".to_string(),
"windows" => "Windows".to_string(),
other => format!("the operating system is {other}"),
}
}
fn terminal_environment_from_process() -> String {
let indicators = [
("TERM_PROGRAM", env::var("TERM_PROGRAM").ok()),
("TERM", env::var("TERM").ok()),
("COLORTERM", env::var("COLORTERM").ok()),
];
terminal_environment_value(indicators.iter().filter_map(|(name, value)| {
value
.as_deref()
.and_then(|value| sanitize_terminal_indicator_value(value).map(|value| (*name, value)))
}))
}
fn terminal_environment_value(
indicators: impl IntoIterator<Item = (&'static str, String)>,
) -> String {
let parts = indicators
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>();
if parts.is_empty() {
"unspecified".to_string()
} else {
parts.join(", ")
}
}
fn sanitize_terminal_indicator_value(value: &str) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|ch| !ch.is_control())
.take(64)
.collect::<String>();
(!sanitized.is_empty()).then_some(sanitized)
}
struct PromptFragment {
name: &'static str,
content: String,
}
struct PromptTemplateDefaults<'a> {
system: &'a str,
tools: &'a str,
code_mode: &'a str,
skills: &'a str,
}
impl PromptTemplateDefaults<'static> {
fn bundled() -> Self {
Self {
system: DEFAULT_SYSTEM_TEMPLATE,
tools: DEFAULT_TOOLS_TEMPLATE,
code_mode: DEFAULT_CODE_MODE_TEMPLATE,
skills: DEFAULT_SKILLS_TEMPLATE,
}
}
}
struct TemplateValues<'a> {
skills_list: String,
runtime_facts: RuntimePromptFacts,
session_context: SessionPromptContext<'a>,
}
impl<'a> TemplateValues<'a> {
fn from_skills(skills: &SkillDiscovery, session_context: SessionPromptContext<'a>) -> Self {
Self {
skills_list: render_skills_list(skills),
runtime_facts: RuntimePromptFacts::detect(),
session_context,
}
}
}