1use indexmap::IndexMap;
8use yapi_types::system_prompt::SystemPromptOptions;
9
10use crate::resources::format_skills;
11
12fn rules(options: &SystemPromptOptions) -> String {
13 let mut rules: Vec<String> = Vec::new();
14 let mut add = |rule: &str| {
15 let rule = rule.trim();
16 if !rule.is_empty() && !rules.iter().any(|existing| existing == rule) {
17 rules.push(rule.to_owned());
18 }
19 };
20 let has = |name: &str| options.selected_tools.iter().any(|tool| tool == name);
21 if (has("bash") || has("powershell")) && !has("grep") && !has("find") && !has("ls") {
22 if has("bash") && has("powershell") {
23 add(
24 "Use bash or PowerShell for file operations like listing, searching, and finding files",
25 );
26 } else if has("powershell") {
27 add("Use PowerShell for file operations like listing, searching, and finding files");
28 } else {
29 add("Use bash for file operations like ls, rg, find");
30 }
31 }
32 for name in &options.selected_tools {
33 for rule in options.tool_guidelines.get(name).into_iter().flatten() {
34 add(rule);
35 }
36 }
37 for rule in &options.prompt_guidelines {
38 add(rule);
39 }
40 add("Be concise in your responses");
41 add("Show file paths clearly when working with files");
42 rules
43 .iter()
44 .map(|rule| format!("- {rule}"))
45 .collect::<Vec<_>>()
46 .join("\n")
47}
48
49fn docs(docs: &crate::docs::Locations) -> String {
52 format!(
53 "yapi documentation (read only when the user asks about yapi itself, its SDK, extensions, themes, skills, or TUI):
54- Main documentation: {}
55- Additional docs: {}
56- Examples: {} (extensions, custom tools, SDK)
57- When reading yapi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
58- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)
59- When working on yapi topics, read the docs and examples, and follow .md cross-references before implementing
60- Always read yapi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)",
61 docs.main, docs.pi_docs, docs.pi_examples,
62 )
63}
64
65pub fn valid_section_name(name: &str) -> bool {
68 let mut chars = name.chars();
69 name != "preamble"
70 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
71 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
72}
73
74pub fn build_sections(
78 options: &SystemPromptOptions,
79 docs: &crate::docs::Locations,
80) -> Result<IndexMap<String, String>, String> {
81 for name in options.sections.keys() {
82 if !valid_section_name(name) {
83 return Err(format!("Invalid system prompt section name: {name}"));
84 }
85 }
86 let mut raw: IndexMap<String, String> = IndexMap::new();
87 match &options.custom_prompt {
88 Some(custom) if !custom.is_empty() => {
89 raw.insert("preamble".into(), custom.clone());
90 }
91 _ => {
92 raw.insert(
93 "preamble".into(),
94 "You are an expert coding assistant operating inside yapi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.".into(),
95 );
96 let visible: Vec<String> = options
97 .selected_tools
98 .iter()
99 .filter_map(|name| {
100 options
101 .tool_snippets
102 .get(name)
103 .filter(|snippet| !snippet.is_empty())
104 .map(|snippet| format!("- {name}: {snippet}"))
105 })
106 .collect();
107 let tools = if visible.is_empty() {
108 "(none)".to_owned()
109 } else {
110 visible.join("\n")
111 };
112 raw.insert(
113 "tools".into(),
114 format!("{tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project."),
115 );
116 raw.insert("rules".into(), rules(options));
117 raw.insert("docs".into(), self::docs(docs));
118 }
119 }
120 if !options.append_system_prompt.is_empty() {
121 raw.insert("addendum".into(), options.append_system_prompt.clone());
122 }
123 if !options.context_files.is_empty() {
124 let mut parts = vec!["Project-specific instructions and guidelines:".to_owned()];
125 for file in &options.context_files {
126 parts.push(format!(
127 "<project_instructions path=\"{}\">\n{}\n</project_instructions>",
128 file.path.display(),
129 file.content
130 ));
131 }
132 raw.insert("project_context".into(), parts.join("\n\n"));
133 }
134 let read_tool = ["read", "bash"].into_iter().find(|tool| {
135 options
136 .selected_tools
137 .iter()
138 .any(|selected| selected == tool)
139 });
140 if let Some(read_tool) = read_tool
141 && !options.skills.is_empty()
142 {
143 let skills = format_skills(&options.skills, read_tool).trim().to_owned();
144 if !skills.is_empty() {
145 raw.insert("skills".into(), skills);
146 }
147 }
148 raw.insert(
149 "cwd".into(),
150 options.cwd.to_string_lossy().replace('\\', "/"),
151 );
152 for (name, content) in &options.sections {
153 if !content.is_empty() {
154 raw.insert(name.clone(), content.clone());
155 }
156 }
157 let mut sections = IndexMap::new();
158 for (name, content) in raw {
159 if name == "preamble" {
160 sections.insert(name, content);
161 } else {
162 let wrapped = format!("<{name}>\n{content}\n</{name}>");
163 sections.insert(name, wrapped);
164 }
165 }
166 Ok(sections)
167}
168
169pub fn diff_sections(
172 previous: &IndexMap<String, Option<String>>,
173 current: &IndexMap<String, String>,
174) -> Option<IndexMap<String, Option<String>>> {
175 let mut patch = IndexMap::new();
176 for (name, text) in current {
177 if previous.get(name).and_then(Option::as_ref) != Some(text) {
178 patch.insert(name.clone(), Some(text.clone()));
179 }
180 }
181 for name in previous.keys() {
182 if !current.contains_key(name) {
183 patch.insert(name.clone(), None);
184 }
185 }
186 (!patch.is_empty()).then_some(patch)
187}
188
189pub fn render(sections: &IndexMap<String, String>) -> String {
191 sections
192 .values()
193 .filter(|text| !text.is_empty())
194 .cloned()
195 .collect::<Vec<_>>()
196 .join("\n\n")
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn builds_pi_sections() {
205 let mut options = SystemPromptOptions {
206 selected_tools: vec!["read".into(), "bash".into()],
207 cwd: "/work".into(),
208 ..SystemPromptOptions::default()
209 };
210 let docs = crate::docs::Locations {
211 main: "/agent/docs/index.md".into(),
212 pi_docs: "/agent/docs/pi/docs".into(),
213 pi_examples: "/agent/docs/pi/examples".into(),
214 };
215 options
216 .tool_snippets
217 .insert("read".into(), "Read file contents".into());
218 options.tool_snippets.insert(
219 "bash".into(),
220 "Execute bash commands (ls, grep, find, etc.)".into(),
221 );
222 options.tool_guidelines.insert(
223 "read".into(),
224 vec!["Use read to examine files instead of cat or sed.".into()],
225 );
226 let sections = build_sections(&options, &docs).unwrap();
227 assert_eq!(
228 sections.keys().cloned().collect::<Vec<_>>(),
229 ["preamble", "tools", "rules", "docs", "cwd"]
230 );
231 assert_eq!(
232 sections["rules"],
233 "<rules>\n- Use bash for file operations like ls, rg, find\n- Use read to examine files instead of cat or sed.\n- Be concise in your responses\n- Show file paths clearly when working with files\n</rules>"
234 );
235 assert!(sections["docs"].starts_with(
236 "<docs>\nyapi documentation (read only when the user asks about yapi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /agent/docs/index.md\n- Additional docs: /agent/docs/pi/docs\n- Examples: /agent/docs/pi/examples (extensions, custom tools, SDK)\n"
237 ));
238 assert_eq!(sections["cwd"], "<cwd>\n/work\n</cwd>");
239 }
240}