Skip to main content

yapi_core/
system_prompt.rs

1//! The system prompt: named sections the transcript holds and later patches.
2//!
3//! Port of `packages/coding-agent/src/core/system-prompt.ts` in pi `v1.0.0`. The
4//! preamble and the documentation section name yapi, and the section points
5//! to the local docs, or to the published ones without a local copy.
6
7use 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
49/// pi's `docs` section, naming yapi, with where the docs are: yapi's main
50/// page, and pi's docs and examples.
51fn 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
65/// Whether a custom section name is valid: a lowercase letter, then lowercase
66/// letters, digits, `_` or `-`, and not `preamble`.
67pub 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
74/// The prompt's sections in order, with `docs` in the documentation section.
75/// `preamble` is plain text. Every other section is wrapped in a tag of its
76/// name.
77pub 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
169/// The section patch from what the model has to what it should have: changed or
170/// new sections, and `None` for removed ones. `None` when nothing changed.
171pub 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
189/// The prompt as plain text, as the transcript renders it.
190pub 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}