use crate::store::settings::{KNOWN_CHANNELS, KNOWN_LANGS, ThinkingLevel};
use chrono::Local;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct ContextFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct BuildSystemPromptOptions {
pub custom_prompt: Option<String>,
pub selected_tools: Vec<String>,
pub tool_snippets: std::collections::HashMap<String, String>,
pub prompt_guidelines: Vec<String>,
pub append_system_prompt: Option<String>,
pub cwd: String,
pub context_files: Vec<ContextFile>,
pub skills: Vec<Skill>,
pub readme_path: Option<String>,
pub docs_path: Option<String>,
pub examples_path: Option<String>,
pub language_directive: Option<String>,
}
pub fn thinking_level_prompt(level: ThinkingLevel) -> Option<String> {
match level {
ThinkingLevel::Off => {
Some("You are a helpful AI assistant. Provide direct, concise answers.".into())
}
ThinkingLevel::Minimal => {
Some("You are a helpful AI assistant. Provide clear and helpful answers.".into())
}
ThinkingLevel::Low => {
Some("You are a helpful AI assistant. Provide brief, actionable responses.".into())
}
ThinkingLevel::Medium => Some(
"You are a helpful AI coding assistant. Think through problems \
step by step when helpful, but keep responses focused and actionable."
.into(),
),
ThinkingLevel::High => Some(
"You are an expert AI coding assistant. Take time to thoroughly \
analyze problems, consider edge cases, and provide comprehensive \
solutions with explanations. Think deeply before responding."
.into(),
),
ThinkingLevel::XHigh => Some(
"You are an expert AI coding assistant. Use maximum reasoning depth. \
Consider all alternatives, edge cases, and potential implications. \
Provide the most thorough, comprehensive analysis possible."
.into(),
),
}
}
pub fn default_tool_snippets() -> std::collections::HashMap<String, String> {
let mut m = std::collections::HashMap::new();
m.insert("read".into(), "Read file contents (text or image)".into());
m.insert("bash".into(), "Execute bash commands".into());
m.insert(
"edit".into(),
"Edit files with exact text replacement".into(),
);
m.insert("write".into(), "Write content to files".into());
m.insert("grep".into(), "Search file contents with regex".into());
m.insert("find".into(), "Find files by name/pattern".into());
m.insert("ls".into(), "List directory contents".into());
m.insert(
"web_search".into(),
"Search the web (DuckDuckGo, Wikipedia, Bing)".into(),
);
m
}
pub fn default_tool_names() -> Vec<String> {
vec![
"read".into(),
"bash".into(),
"edit".into(),
"write".into(),
"grep".into(),
"find".into(),
"ls".into(),
"web_search".into(),
]
}
impl Default for BuildSystemPromptOptions {
fn default() -> Self {
Self {
custom_prompt: None,
selected_tools: vec!["read".into(), "bash".into(), "edit".into(), "write".into()],
tool_snippets: std::collections::HashMap::new(),
prompt_guidelines: Vec::new(),
append_system_prompt: None,
cwd: String::new(),
context_files: Vec::new(),
skills: Vec::new(),
readme_path: None,
docs_path: None,
examples_path: None,
language_directive: None,
}
}
}
fn format_skills_for_prompt(skills: &[Skill]) -> String {
if skills.is_empty() {
return String::new();
}
let mut out = String::from("\n\n# Skills\n\n");
for skill in skills {
out.push_str(&format!("## {}\n\n{}\n\n", skill.name, skill.content));
}
out
}
fn lookup_language_display(code: &str) -> &str {
KNOWN_LANGS
.iter()
.find(|(c, _)| *c == code)
.map(|(_, d)| *d)
.unwrap_or(code)
}
fn lookup_channel_label(key: &str) -> &str {
KNOWN_CHANNELS
.iter()
.find(|(k, _)| *k == key)
.map(|(_, l)| *l)
.unwrap_or(key)
}
pub fn language_directive(channels: &std::collections::HashMap<String, String>) -> Option<String> {
use std::collections::HashSet;
if channels.is_empty() {
return None;
}
let mut bullets: Vec<String> = Vec::new();
let mut seen: HashSet<&str> = HashSet::new();
for (key, label) in KNOWN_CHANNELS {
let lang = match channels.get(*key) {
Some(l) if !l.is_empty() && l != "auto" => l.as_str(),
_ => continue,
};
bullets.push(format!(
"- {}: always in {}.",
label,
lookup_language_display(lang)
));
seen.insert(*key);
}
let mut extras: Vec<(&String, &String)> = channels
.iter()
.filter(|(k, _)| !seen.contains(k.as_str()))
.collect();
extras.sort_by(|a, b| a.0.cmp(b.0));
for (key, lang) in extras {
if lang.is_empty() || lang == "auto" {
continue;
}
bullets.push(format!(
"- {}: always in {}.",
lookup_channel_label(key),
lookup_language_display(lang)
));
}
if bullets.is_empty() {
return None;
}
let bullets_text = bullets.join("\n");
Some(format!(
"\n\n# Output Language Policy (enforced)\n\n\
You MUST follow these language rules for every output. These are \
hard constraints, not preferences:\n\n\
{bullets_text}\n\n\
If a tool's natural output language conflicts (e.g. a generated \
commit message in another language), rewrite it to comply before \
returning it to the user. Do not echo verbatim multi-language tool \
output without translating it into the channel's required language."
))
}
pub fn build_system_prompt(options: &BuildSystemPromptOptions) -> String {
let prompt_cwd = options.cwd.replace('\\', "/");
let date = Local::now().format("%Y-%m-%d").to_string();
let append_section = options
.append_system_prompt
.as_deref()
.map(|s| format!("\n\n{}", s))
.unwrap_or_default();
if let Some(ref custom) = options.custom_prompt {
let mut prompt = custom.clone();
prompt.push_str(&append_section);
if !options.context_files.is_empty() {
prompt.push_str("\n\n# Project Context\n\n");
prompt.push_str("Project-specific instructions and guidelines:\n\n");
for cf in &options.context_files {
prompt.push_str(&format!("## {}\n\n{}\n\n", cf.path, cf.content));
}
}
let custom_has_read = options.selected_tools.is_empty()
|| options.selected_tools.contains(&"read".to_string());
if custom_has_read && !options.skills.is_empty() {
prompt.push_str(&format_skills_for_prompt(&options.skills));
}
prompt.push_str(&format!("\nCurrent date: {}", date));
prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
if let Some(ref directive) = options.language_directive
&& !directive.is_empty()
{
prompt.push_str(directive);
}
return prompt;
}
let readme_path = options
.readme_path
.as_deref()
.unwrap_or("(docs not available)");
let docs_path = options
.docs_path
.as_deref()
.unwrap_or("(docs not available)");
let examples_path = options
.examples_path
.as_deref()
.unwrap_or("(examples not available)");
let visible_tools: Vec<&str> = options
.selected_tools
.iter()
.filter(|name| options.tool_snippets.contains_key(name.as_str()))
.map(|s| s.as_str())
.collect();
let tools_list = if visible_tools.is_empty() {
"(none)".to_string()
} else {
visible_tools
.iter()
.map(|name| {
let snippet = options
.tool_snippets
.get(*name)
.map(|s| s.as_str())
.unwrap_or("");
format!("- {}: {}", name, snippet)
})
.collect::<Vec<_>>()
.join("\n")
};
let mut guidelines: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut add_guideline = |g: &str| {
if seen.insert(g.to_string()) {
guidelines.push(g.to_string());
}
};
let has_bash = options.selected_tools.contains(&"bash".to_string());
let has_grep = options.selected_tools.contains(&"grep".to_string());
let has_find = options.selected_tools.contains(&"find".to_string());
let has_ls = options.selected_tools.contains(&"ls".to_string());
let has_read = options.selected_tools.contains(&"read".to_string());
if has_bash && !has_grep && !has_find && !has_ls {
add_guideline("Use bash for file operations like ls, rg, find");
} else if has_bash && (has_grep || has_find || has_ls) {
add_guideline(
"Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
);
}
for g in &options.prompt_guidelines {
let trimmed = g.trim();
if !trimmed.is_empty() {
add_guideline(trimmed);
}
}
add_guideline("Be concise in your responses");
add_guideline("Show file paths clearly when working with files");
let guidelines_text = guidelines
.iter()
.map(|g| format!("- {}", g))
.collect::<Vec<_>>()
.join("\n");
let mut prompt = format!(
"You are an expert coding assistant operating inside oxi, a coding agent harness. \
You help users by reading files, executing commands, editing code, and writing new files.\n\n\
Available tools:\n{}\n\n\
In addition to the tools above, you may have access to other custom tools depending on the project.\n\n\
Guidelines:\n{}\n\n\
Oxi documentation (read only when the user asks about oxi itself, its SDK, extensions, themes, skills, or TUI):\n\
- Main documentation: {}\n\
- Additional docs: {}\n\
- Examples: {} (extensions, custom tools, SDK)\n\
- 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), oxi packages (docs/packages.md)\n\
- When working on oxi topics, read the docs and examples, and follow .md cross-references before implementing\n\
- Always read oxi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)",
tools_list, guidelines_text, readme_path, docs_path, examples_path,
);
prompt.push_str(&append_section);
if !options.context_files.is_empty() {
prompt.push_str("\n\n# Project Context\n\n");
prompt.push_str("Project-specific instructions and guidelines:\n\n");
for cf in &options.context_files {
prompt.push_str(&format!("## {}\n\n{}\n\n", cf.path, cf.content));
}
}
if has_read && !options.skills.is_empty() {
prompt.push_str(&format_skills_for_prompt(&options.skills));
}
prompt.push_str(&format!("\nCurrent date: {}", date));
prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
if let Some(ref directive) = options.language_directive
&& !directive.is_empty()
{
prompt.push_str(directive);
}
prompt
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_prompt_contains_key_sections() {
let opts = BuildSystemPromptOptions {
cwd: "/home/user/project".into(),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.contains("expert coding assistant"));
assert!(prompt.contains("Available tools:"));
assert!(prompt.contains("Guidelines:"));
assert!(prompt.contains("Be concise"));
assert!(prompt.contains("Current working directory: /home/user/project"));
assert!(prompt.contains("Current date:"));
}
#[test]
fn custom_prompt_used_as_base() {
let opts = BuildSystemPromptOptions {
custom_prompt: Some("Custom prompt here.".into()),
cwd: "/tmp".into(),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.starts_with("Custom prompt here."));
assert!(prompt.contains("Current working directory: /tmp"));
}
#[test]
fn context_files_appended() {
let opts = BuildSystemPromptOptions {
custom_prompt: Some("Base".into()),
cwd: "/tmp".into(),
context_files: vec![ContextFile {
path: "STYLE.md".into(),
content: "Use 4-space indent".into(),
}],
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.contains("Project Context"));
assert!(prompt.contains("STYLE.md"));
assert!(prompt.contains("Use 4-space indent"));
}
#[test]
fn append_section_included() {
let opts = BuildSystemPromptOptions {
append_system_prompt: Some("Extra rules".into()),
cwd: "/tmp".into(),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.contains("Extra rules"));
}
#[test]
fn language_directive_returns_none_for_empty_map() {
let map = std::collections::HashMap::new();
assert!(language_directive(&map).is_none());
}
#[test]
fn language_directive_returns_none_when_all_auto() {
let mut map = std::collections::HashMap::new();
map.insert("response".to_string(), "auto".to_string());
map.insert("commit_message".to_string(), "auto".to_string());
assert!(language_directive(&map).is_none());
}
#[test]
fn language_directive_includes_only_non_auto_channels() {
let mut map = std::collections::HashMap::new();
map.insert("response".to_string(), "ko".to_string());
map.insert("commit_message".to_string(), "auto".to_string()); let d = language_directive(&map).expect("at least one non-auto channel");
assert!(d.contains("Korean (한국어)"), "got: {d}");
assert!(d.contains("Your conversational responses"));
assert!(
!d.contains("commit_message") || !d.contains("Git commit messages"),
"auto channel must be excluded, got: {d}"
);
}
#[test]
fn language_directive_renders_unknown_code_as_is() {
let mut map = std::collections::HashMap::new();
map.insert("response".to_string(), "klingon".to_string());
let d = language_directive(&map).expect("non-auto channel");
assert!(d.contains("klingon"), "got: {d}");
}
#[test]
fn language_directive_walks_known_channels_in_order() {
let mut map = std::collections::HashMap::new();
map.insert("response".to_string(), "ko".to_string());
map.insert("code_comment".to_string(), "en".to_string());
map.insert("documentation".to_string(), "en".to_string());
map.insert("commit_message".to_string(), "en".to_string());
let d = language_directive(&map).expect("non-empty policy");
let pos_response = d.find("Your conversational responses").unwrap();
let pos_code = d.find("Code comments").unwrap();
let pos_doc = d.find("Documentation").unwrap();
let pos_commit = d.find("Git commit messages").unwrap();
assert!(pos_response < pos_code);
assert!(pos_code < pos_doc);
assert!(pos_doc < pos_commit);
}
#[test]
fn language_directive_includes_user_defined_channels_sorted() {
let mut map = std::collections::HashMap::new();
map.insert("response".to_string(), "ko".to_string()); map.insert("zeta_channel".to_string(), "en".to_string()); map.insert("alpha_channel".to_string(), "en".to_string()); let d = language_directive(&map).expect("non-empty policy");
let pos_response = d.find("Your conversational responses").unwrap();
let pos_alpha = d.find("alpha_channel").unwrap();
let pos_zeta = d.find("zeta_channel").unwrap();
assert!(pos_response < pos_alpha);
assert!(pos_alpha < pos_zeta);
assert!(d.contains("alpha_channel: always in English."));
assert!(d.contains("zeta_channel: always in English."));
}
#[test]
fn build_system_prompt_includes_language_directive_at_end() {
let opts = BuildSystemPromptOptions {
cwd: "/tmp".into(),
language_directive: Some(
"\n\n# Output Language Policy (enforced)\n\n- foo: bar.".to_string(),
),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.contains("Output Language Policy (enforced)"));
assert!(prompt.ends_with("- foo: bar."));
}
#[test]
fn build_system_prompt_skips_empty_language_directive() {
let opts = BuildSystemPromptOptions {
cwd: "/tmp".into(),
language_directive: Some(String::new()),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(!prompt.contains("Output Language Policy"));
}
#[test]
fn build_system_prompt_skips_none_language_directive() {
let opts = BuildSystemPromptOptions {
cwd: "/tmp".into(),
language_directive: None,
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(!prompt.contains("Output Language Policy"));
}
#[test]
fn build_system_prompt_language_directive_in_custom_prompt_branch() {
let opts = BuildSystemPromptOptions {
custom_prompt: Some("CUSTOM_BASE".into()),
cwd: "/tmp".into(),
language_directive: Some("\n\n# Output Language Policy (enforced)".into()),
..Default::default()
};
let prompt = build_system_prompt(&opts);
assert!(prompt.starts_with("CUSTOM_BASE"));
assert!(prompt.contains("Output Language Policy (enforced)"));
assert!(prompt.ends_with("Output Language Policy (enforced)"));
}
}