1use std::path::Path;
9
10use indexmap::IndexMap;
11use oino_ai::{Sections, SystemMessage, TimestampMs, UserTextField};
12use serde::{Deserialize, Serialize};
13
14use crate::context_files::ContextFile;
15use crate::skills::{Skill, SkillReader, format_skills_for_prompt};
16
17pub const DEFAULT_PREAMBLE: &str = "You are an expert coding assistant operating inside Oino, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
19
20pub const ENV_GUIDELINE: &str = "You can inspect OINO_* environment variables (also exported as PI_*) for current model and session details.";
22
23const TOOLS_FOOTER: &str = "In addition to the tools above, you may have access to other custom tools depending on the project.";
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct BuildSystemPromptOptions {
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub custom_prompt: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub force_system_prompt: Option<String>,
37 #[serde(default = "default_selected_tools")]
39 pub selected_tools: Vec<String>,
40 #[serde(default)]
42 pub hidden_tools: Vec<String>,
43 #[serde(default)]
45 pub tool_snippets: IndexMap<String, String>,
46 #[serde(default)]
48 pub tool_guidelines: IndexMap<String, Vec<String>>,
49 #[serde(default)]
51 pub prompt_guidelines: Vec<String>,
52 #[serde(default)]
54 pub append_system_prompt: String,
55 #[serde(default)]
57 pub sections: IndexMap<String, String>,
58 #[serde(default)]
60 pub cwd: String,
61 #[serde(default)]
63 pub context_files: Vec<ContextFile>,
64 #[serde(default)]
66 pub skills: Vec<Skill>,
67}
68
69fn default_selected_tools() -> Vec<String> {
70 oino_settings::tools::DEFAULT_TOOL_NAMES
71 .iter()
72 .map(|&name| name.to_owned())
73 .collect()
74}
75
76impl Default for BuildSystemPromptOptions {
77 fn default() -> Self {
78 Self {
79 custom_prompt: None,
80 force_system_prompt: None,
81 selected_tools: default_selected_tools(),
82 hidden_tools: Vec::new(),
83 tool_snippets: IndexMap::new(),
84 tool_guidelines: IndexMap::new(),
85 prompt_guidelines: Vec::new(),
86 append_system_prompt: String::new(),
87 sections: IndexMap::new(),
88 cwd: String::new(),
89 context_files: Vec::new(),
90 skills: Vec::new(),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97#[error("Invalid system prompt section name: {name}")]
98pub struct InvalidSectionName {
99 pub name: String,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct SystemPromptState {
107 pub content: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub sections: Option<IndexMap<String, String>>,
112}
113
114impl SystemPromptState {
115 #[must_use]
117 pub fn to_system_message(&self, timestamp: TimestampMs) -> SystemMessage {
118 SystemMessage {
119 content: UserTextField::Text(self.content.clone()),
120 sections: self.sections.as_ref().map(|sections| {
121 sections
122 .iter()
123 .map(|(name, text)| (name.clone(), Some(text.clone())))
124 .collect::<Sections>()
125 }),
126 tools_added: None,
127 tools_removed: None,
128 timestamp,
129 }
130 }
131
132 #[must_use]
134 pub fn render(&self) -> String {
135 oino_ai::transcript::system_message_text(&self.to_system_message(TimestampMs::default()))
136 }
137}
138
139#[must_use]
141pub fn docs_section(docs_root: &Path) -> String {
142 let root = docs_root.display();
143 format!(
144 "Oino documentation (read only when the user asks about Oino itself, its SDK, extensions, themes, skills, or TUI):
145- Main documentation: {root}/README.md
146- Additional docs: {root}/docs
147- Examples: {root}/examples (extensions, custom tools, SDK)
148- When reading Oino docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
149- When asked about: extensions (docs/extensions.md, examples/extensions/), extension API and WIT (docs/extension-api.md), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), providers and models (docs/providers.md, docs/models.md), Oino packages (docs/packages.md), environment variables (docs/environment-variables.md), RPC and ACP (docs/rpc.md, docs/acp.md)
150- When working on Oino topics, read the docs and examples, and follow .md cross-references before implementing
151- Always read Oino .md files completely and follow links to related docs (e.g., tui.md for TUI details)"
152 )
153}
154
155#[must_use]
157pub fn render_project_context(files: &[ContextFile]) -> String {
158 std::iter::once("Project-specific instructions and guidelines:".to_owned())
159 .chain(files.iter().map(|file| {
160 format!(
161 "<project_instructions path=\"{}\">\n{}\n</project_instructions>",
162 file.path.display(),
163 file.content
164 )
165 }))
166 .collect::<Vec<_>>()
167 .join("\n\n")
168}
169
170#[must_use]
172pub fn build_rules(
173 declared_tools: &[String],
174 tool_guidelines: &IndexMap<String, Vec<String>>,
175 prompt_guidelines: &[String],
176) -> String {
177 let mut rules: Vec<String> = Vec::new();
178 let mut add = |rule: &str| {
179 let rule = rule.trim();
180 if !rule.is_empty() && !rules.iter().any(|existing| existing == rule) {
181 rules.push(rule.to_owned());
182 }
183 };
184 let has = |name: &str| declared_tools.iter().any(|tool| tool == name);
185 let (bash, powershell) = (has("bash"), has("powershell"));
186 if (bash || powershell) && !has("grep") && !has("find") && !has("ls") {
187 add(if bash && powershell {
188 "Use bash or PowerShell for file operations like listing, searching, and finding files"
189 } else if powershell {
190 "Use PowerShell for file operations like listing, searching, and finding files"
191 } else {
192 "Use bash for file operations like ls, rg, find"
193 });
194 }
195 for name in declared_tools {
196 for rule in tool_guidelines.get(name).into_iter().flatten() {
197 add(rule);
198 }
199 }
200 for rule in prompt_guidelines {
201 add(rule);
202 }
203 add("Be concise in your responses");
204 add("Show file paths clearly when working with files");
205 rules
206 .iter()
207 .map(|rule| format!("- {rule}"))
208 .collect::<Vec<_>>()
209 .join("\n")
210}
211
212fn valid_section_name(name: &str) -> bool {
213 let mut bytes = name.bytes();
214 bytes.next().is_some_and(|b| b.is_ascii_lowercase())
215 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
216}
217
218fn insert_default_sections(
220 raw: &mut IndexMap<String, String>,
221 options: &BuildSystemPromptOptions,
222 declared: &[String],
223 docs_root: &Path,
224) {
225 raw.insert("preamble".into(), DEFAULT_PREAMBLE.to_owned());
226 let visible: Vec<String> = declared
227 .iter()
228 .filter_map(|name| {
229 options
230 .tool_snippets
231 .get(name)
232 .filter(|snippet| !snippet.is_empty())
233 .map(|snippet| format!("- {name}: {snippet}"))
234 })
235 .collect();
236 let tools = if visible.is_empty() {
237 "(none)".to_owned()
238 } else {
239 visible.join("\n")
240 };
241 raw.insert("tools".into(), format!("{tools}\n\n{TOOLS_FOOTER}"));
242 raw.insert(
243 "rules".into(),
244 build_rules(
245 declared,
246 &options.tool_guidelines,
247 &options.prompt_guidelines,
248 ),
249 );
250 if !docs_root.as_os_str().is_empty() {
253 raw.insert("docs".into(), docs_section(docs_root));
254 }
255}
256
257pub fn build_system_prompt_sections(
264 options: &BuildSystemPromptOptions,
265 docs_root: &Path,
266) -> Result<IndexMap<String, String>, InvalidSectionName> {
267 if let Some(name) = options
268 .sections
269 .keys()
270 .find(|name| !valid_section_name(name) || name.as_str() == "preamble")
271 {
272 return Err(InvalidSectionName { name: name.clone() });
273 }
274 let declared: Vec<String> = options
275 .selected_tools
276 .iter()
277 .filter(|name| !options.hidden_tools.contains(name))
278 .cloned()
279 .collect();
280 let mut raw: IndexMap<String, String> = IndexMap::new();
281 if let Some(custom) = options
282 .custom_prompt
283 .as_deref()
284 .filter(|prompt| !prompt.is_empty())
285 {
286 raw.insert("preamble".into(), custom.to_owned());
287 } else {
288 insert_default_sections(&mut raw, options, &declared, docs_root);
289 }
290 if !options.append_system_prompt.is_empty() {
291 raw.insert("addendum".into(), options.append_system_prompt.clone());
292 }
293 if !options.context_files.is_empty() {
294 raw.insert(
295 "project_context".into(),
296 render_project_context(&options.context_files),
297 );
298 }
299 let reader = ["read", "bash"]
300 .into_iter()
301 .find(|tool| declared.iter().any(|name| name == tool))
302 .map(|tool| {
303 if tool == "read" {
304 SkillReader::Read
305 } else {
306 SkillReader::Bash
307 }
308 })
309 .or_else(|| {
310 ["read", "bash"]
311 .iter()
312 .any(|tool| options.selected_tools.iter().any(|name| name == tool))
313 .then_some(SkillReader::Indirect)
314 });
315 if let Some(reader) = reader
316 && !options.skills.is_empty()
317 {
318 let skills = format_skills_for_prompt(&options.skills, reader);
319 let skills = skills.trim();
320 if !skills.is_empty() {
321 raw.insert("skills".into(), skills.to_owned());
322 }
323 }
324 raw.insert("cwd".into(), options.cwd.replace('\\', "/"));
325 for (name, content) in &options.sections {
326 if !content.is_empty() {
327 raw.insert(name.clone(), content.clone());
328 }
329 }
330 Ok(raw
331 .into_iter()
332 .map(|(name, content)| {
333 if name == "preamble" {
334 (name, content)
335 } else {
336 let wrapped = format!("<{name}>\n{content}\n</{name}>");
337 (name, wrapped)
338 }
339 })
340 .collect())
341}
342
343pub fn build_system_prompt_state(
349 options: &BuildSystemPromptOptions,
350 docs_root: &Path,
351) -> Result<SystemPromptState, InvalidSectionName> {
352 if let Some(forced) = &options.force_system_prompt {
353 return Ok(SystemPromptState {
354 content: forced.clone(),
355 sections: None,
356 });
357 }
358 Ok(SystemPromptState {
359 content: String::new(),
360 sections: Some(build_system_prompt_sections(options, docs_root)?),
361 })
362}
363
364pub fn build_system_prompt(
370 options: &BuildSystemPromptOptions,
371 docs_root: &Path,
372) -> Result<String, InvalidSectionName> {
373 build_system_prompt_state(options, docs_root).map(|state| state.render())
374}
375
376#[must_use]
379pub fn diff_system_prompt_sections(
380 previous: &IndexMap<String, String>,
381 current: &IndexMap<String, String>,
382) -> Option<Sections> {
383 oino_ai::transcript::diff_sections(previous, current)
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn invalid_section_names_are_rejected() {
392 let mut options = BuildSystemPromptOptions::default();
393 options.sections.insert("Bad".into(), "x".into());
394 assert_eq!(
395 build_system_prompt_sections(&options, Path::new("/d"))
396 .unwrap_err()
397 .to_string(),
398 "Invalid system prompt section name: Bad"
399 );
400 options.sections = IndexMap::from([("preamble".into(), "x".into())]);
401 assert!(build_system_prompt_sections(&options, Path::new("/d")).is_err());
402 }
403
404 #[test]
405 fn deserialize_applies_defaults() {
406 let options: BuildSystemPromptOptions = serde_json::from_str(r#"{"cwd": "/w"}"#).unwrap();
407 assert_eq!(options.selected_tools, ["read", "bash", "edit", "write"]);
408 assert_eq!(options.cwd, "/w");
409 }
410
411 #[test]
412 fn forced_prompt_has_no_sections() {
413 let options = BuildSystemPromptOptions {
414 force_system_prompt: Some("forced".into()),
415 ..BuildSystemPromptOptions::default()
416 };
417 let state = build_system_prompt_state(&options, Path::new("/d")).unwrap();
418 assert_eq!(state.sections, None);
419 assert_eq!(state.render(), "forced");
420 }
421
422 #[test]
423 fn absent_docs_root_omits_the_docs_section() {
424 let options = BuildSystemPromptOptions::default();
427 let with_root = build_system_prompt_sections(&options, Path::new("/d")).unwrap();
428 assert!(with_root.contains_key("docs"));
429 assert!(with_root["docs"].contains("/d/README.md"));
430 let absent = build_system_prompt_sections(&options, Path::new("")).unwrap();
431 assert!(!absent.contains_key("docs"));
432 assert!(absent.contains_key("preamble"));
433 }
434}